Upload files to "q4-SIEM-build"
هذا الالتزام موجود في:
154
q4-SIEM-build/PUBLISH_TO_GHAYMAH.md
Normal file
154
q4-SIEM-build/PUBLISH_TO_GHAYMAH.md
Normal file
@@ -0,0 +1,154 @@
|
||||
# Publishing this SIEM Dashboard to Ghaymah Storage Block
|
||||
|
||||
## Read this first: what I could and couldn't verify
|
||||
|
||||
I looked for Ghaymah Systems' own documentation before writing this. I
|
||||
found:
|
||||
|
||||
- Their docs portal: **https://docs.ghaymah.cloud/**
|
||||
- Their deployment portal: **https://deploy.ghaymah.systems/**
|
||||
|
||||
Both are JavaScript-rendered apps, so I could confirm they exist but
|
||||
couldn't pull the actual step-by-step page content for the "Storage Block"
|
||||
feature specifically — I don't have exact screenshots/menu names to give
|
||||
you with certainty, and I don't want to hand you confidently-wrong click
|
||||
paths for something that's going to be graded.
|
||||
|
||||
What follows is the **standard workflow that essentially every cloud
|
||||
"storage block"/bucket/object-storage service uses** (AWS S3, DigitalOcean
|
||||
Spaces, Linode/Akamai Object Storage, etc.), written so you can map it onto
|
||||
Ghaymah's actual console. Before your exam submission, open
|
||||
**docs.ghaymah.cloud** and search for "storage" to confirm exact menu
|
||||
names, size limits, and whether static-site hosting is a toggle you need to
|
||||
enable — the mechanics below are almost certainly right, the exact button
|
||||
labels might differ slightly.
|
||||
|
||||
There are two different things a "storage block" can mean on a cloud
|
||||
platform — check which one Ghaymah's product actually is before you start,
|
||||
since the workflow is different:
|
||||
|
||||
| If "Storage Block" is... | It behaves like... | Use it to... |
|
||||
|---|---|---|
|
||||
| **Object storage** (a bucket you upload files into, with a public URL) | AWS S3 / DigitalOcean Spaces | Host this static dashboard directly — this is the common case, and the one the rest of this guide assumes |
|
||||
| **Block storage** (a raw virtual disk volume, like AWS EBS) | An attached hard drive | You'd still need a compute instance/VM running a web server (nginx, `python3 -m http.server`, etc.) — the volume just gives that VM extra disk space. See the [alternate path](#alternate-path-if-storage-block-means-a-disk-volume) below. |
|
||||
|
||||
---
|
||||
|
||||
## Path A: publishing to object/bucket-style storage (most likely case)
|
||||
|
||||
### 1. Regenerate fresh data before publishing
|
||||
|
||||
Run the analyzer one last time so `siem_report.json` and `data.js` are
|
||||
current:
|
||||
|
||||
```bash
|
||||
cd siem_project
|
||||
python3 siem_analyzer.py
|
||||
```
|
||||
|
||||
### 2. Collect exactly what needs to go live
|
||||
|
||||
Only the `dashboard/` folder needs to be published — the Python script and
|
||||
raw `logs/` are your local tooling, not part of the served site:
|
||||
|
||||
```
|
||||
dashboard/
|
||||
├── index.html
|
||||
├── style.css
|
||||
├── script.js
|
||||
├── siem_report.json
|
||||
└── data.js
|
||||
```
|
||||
|
||||
(You can optionally publish `logs/` and `siem_analyzer.py` alongside it too
|
||||
if the exam wants the whole repo visible — just make sure `index.html` ends
|
||||
up at the bucket's root so it's served as the default page.)
|
||||
|
||||
### 3. Create the storage block / bucket
|
||||
|
||||
In the Ghaymah console (or `deploy.ghaymah.systems`):
|
||||
|
||||
1. Log in and find **Storage** in the main navigation.
|
||||
2. Create a new storage block — give it a unique name (e.g.
|
||||
`siem-dashboard-<yourname>`), and pick a region close to you/your
|
||||
grader.
|
||||
3. If there's a **public access / static website hosting** toggle, enable
|
||||
it — object storage buckets are private by default on most platforms,
|
||||
and a private bucket will just return "Access Denied" in a browser.
|
||||
4. If it asks for an **index document**, set it to `index.html`.
|
||||
|
||||
### 4. Upload the files
|
||||
|
||||
Most storage-block consoles support drag-and-drop upload in the browser —
|
||||
upload the 5 files from `dashboard/` (keep them at the bucket's root, not
|
||||
inside a subfolder, so relative paths like `href="style.css"` still
|
||||
resolve).
|
||||
|
||||
If Ghaymah instead exposes an **S3-compatible API** (common for
|
||||
smaller/regional clouds — check their docs for an endpoint URL, access
|
||||
key, and secret key), you can upload with the standard AWS CLI pointed at
|
||||
their endpoint instead of the console:
|
||||
|
||||
```bash
|
||||
aws s3 cp ./dashboard s3://siem-dashboard-<yourname> \
|
||||
--recursive \
|
||||
--endpoint-url https://<ghaymah-storage-endpoint>
|
||||
```
|
||||
|
||||
(Only use this if their docs confirm an S3-compatible endpoint and give
|
||||
you access/secret keys — don't guess the endpoint URL.)
|
||||
|
||||
### 5. Get the public URL and verify
|
||||
|
||||
The console should show a public URL after upload (something like
|
||||
`https://<bucket-name>.<region>.ghaymah.systems/` or a
|
||||
platform-generated domain). Open it and confirm:
|
||||
|
||||
- The stat cards, radar, and alert table all render (not a blank page —
|
||||
that usually means `style.css`/`script.js`/`data.js` didn't upload to
|
||||
the same folder as `index.html`).
|
||||
- The alert table search/filter and row-expand still work.
|
||||
|
||||
### 6. (Optional) Custom domain
|
||||
|
||||
If Ghaymah supports attaching a custom domain/CNAME to a storage block,
|
||||
that's normally: add a CNAME record at your DNS provider pointing your
|
||||
subdomain at the bucket's platform domain, then add that domain in the
|
||||
bucket's settings and wait for it to verify (and, if offered, request/attach
|
||||
an SSL certificate).
|
||||
|
||||
---
|
||||
|
||||
## Alternate path: if "Storage Block" means a disk volume
|
||||
|
||||
If Ghaymah's "Storage Block" is closer to AWS EBS (a virtual disk you
|
||||
attach to a VM rather than something with its own public URL), the flow is
|
||||
instead:
|
||||
|
||||
1. Provision a small compute instance (VM) on Ghaymah.
|
||||
2. Create and attach a storage block/volume to it, then mount it
|
||||
(`mkfs`, `mount` — same as any Linux block device).
|
||||
3. Copy `dashboard/` onto that mounted volume (`scp`, `git clone`, etc.).
|
||||
4. Serve it from the VM:
|
||||
```bash
|
||||
cd /mnt/<your-volume>/dashboard
|
||||
python3 -m http.server 80
|
||||
# or install nginx and point its document root at this folder
|
||||
```
|
||||
5. Open the VM's public IP (or attach a domain to it) to verify.
|
||||
|
||||
---
|
||||
|
||||
## Either way — a pre-submission checklist
|
||||
|
||||
- [ ] Ran `python3 siem_analyzer.py` so `siem_report.json`/`data.js` reflect
|
||||
the current logs
|
||||
- [ ] `index.html` is reachable at the bucket/site root, not nested in a
|
||||
subfolder
|
||||
- [ ] `style.css`, `script.js`, `data.js`, `siem_report.json` are in the
|
||||
**same folder** as `index.html`
|
||||
- [ ] Public URL loads with no blank/broken page and no browser console
|
||||
errors
|
||||
- [ ] Confirmed the actual click-path against **docs.ghaymah.cloud**, since
|
||||
the steps above are the generic pattern, not a verified Ghaymah
|
||||
screenshot-by-screenshot guide
|
||||
95
q4-SIEM-build/README.md
Normal file
95
q4-SIEM-build/README.md
Normal file
@@ -0,0 +1,95 @@
|
||||
# Simple SIEM — Log Analyzer + Threat Dashboard
|
||||
|
||||
A small SIEM built for a 3-endpoint environment: a **web server**, a
|
||||
**firewall/router**, and an **SSH authentication** log source. A Python
|
||||
script parses and correlates suspicious activity across all three; a static
|
||||
HTML/CSS/JS dashboard visualizes the resulting alerts and malicious IPs.
|
||||
|
||||
```
|
||||
siem_project/
|
||||
├── logs/
|
||||
│ ├── endpoint1_web_access.log # sample nginx-style access log
|
||||
│ ├── endpoint2_firewall.log # sample SRC/DST/PORT/ACTION firewall log
|
||||
│ └── endpoint3_auth.log # sample sshd auth log
|
||||
├── siem_analyzer.py # Part 1 — the analyzer
|
||||
├── dashboard/
|
||||
│ ├── index.html # Part 2 — the dashboard
|
||||
│ ├── style.css
|
||||
│ ├── script.js
|
||||
│ ├── siem_report.json # generated by siem_analyzer.py
|
||||
│ └── data.js # generated by siem_analyzer.py (offline fallback)
|
||||
├── README.md # this file
|
||||
└── PUBLISH_TO_GHAYMAH.md # Part 3 — publishing guide
|
||||
```
|
||||
|
||||
## Part 1 — `siem_analyzer.py`
|
||||
|
||||
Pure standard library, no `pip install` needed.
|
||||
|
||||
```bash
|
||||
python3 siem_analyzer.py
|
||||
# or point it at different files:
|
||||
python3 siem_analyzer.py --logs-dir ./logs --out-dir ./dashboard
|
||||
```
|
||||
|
||||
It parses all three log formats, runs the detection rules below, prints a
|
||||
console summary, and writes `siem_report.json` + `data.js` into `dashboard/`.
|
||||
|
||||
### Detection rules
|
||||
|
||||
| Rule | Source | Logic | Severity |
|
||||
|---|---|---|---|
|
||||
| `SQL_INJECTION` / `XSS` / `PATH_TRAVERSAL` / `COMMAND_INJECTION` | web | Regex signatures against the (URL-decoded) request path | critical/high |
|
||||
| `RECON_SCANNER` | web | ≥3 requests with a known scanner user-agent (sqlmap, nikto, nmap…) | medium |
|
||||
| `HIGH_REQUEST_RATE` | web | ≥15 requests from one IP within 60s | medium/high |
|
||||
| `PORT_SCAN` | firewall | ≥8 distinct destination ports from one IP within 120s | high |
|
||||
| `BRUTE_FORCE_SSH` | auth | ≥5 failed logins from one IP within 300s | high |
|
||||
| `ACCOUNT_COMPROMISE_SUSPECTED` | auth | A successful login from an IP right after it triggered a brute-force alert | critical |
|
||||
| `BLACKLISTED_IP_ACTIVITY` | any | IP matches a seeded threat-intel list | critical |
|
||||
| `MULTI_VECTOR_ATTACK` | correlation | Same source IP triggered alerts on ≥2 different endpoints | critical |
|
||||
|
||||
That last rule is the actual point of a SIEM: no single log tells the whole
|
||||
story, but seeing the *same* IP port-scan the firewall, brute-force SSH,
|
||||
*and* throw SQLi at the web app is what turns three noisy logs into one
|
||||
clear "this IP is attacking us" signal. All thresholds live in the
|
||||
`THRESHOLDS` dict at the top of the script if you want to tune them.
|
||||
|
||||
## Part 2 — the dashboard
|
||||
|
||||
Open `dashboard/index.html` directly in a browser — it works out of the box
|
||||
because it falls back to the embedded `data.js` snapshot. For the "live"
|
||||
experience (auto re-fetching `siem_report.json` when you click Refresh),
|
||||
serve the folder instead:
|
||||
|
||||
```bash
|
||||
cd dashboard
|
||||
python3 -m http.server 8000
|
||||
# open http://localhost:8000
|
||||
```
|
||||
|
||||
What's on it:
|
||||
- **Stat cards** — total alerts and the severity breakdown.
|
||||
- **Threat Radar** — the top malicious IPs plotted by score (closer to
|
||||
center = more dangerous); a critical IP gets a pulsing ring.
|
||||
- **Severity Mix** — a proportional bar of critical/high/medium/low.
|
||||
- **Top Malicious IPs** — ranked list with a relative-score bar.
|
||||
- **Alerts table** — searchable, filterable by severity/endpoint; click a
|
||||
row to expand the raw log line(s) behind that alert.
|
||||
|
||||
Re-running `siem_analyzer.py` regenerates both output files — refresh the
|
||||
page (or click the in-app Refresh button if you're on a local server) to
|
||||
see updated results.
|
||||
|
||||
### A note on why the table doesn't use `innerHTML`
|
||||
|
||||
Alert descriptions and evidence are literally attacker-supplied log text —
|
||||
some of the sample data contains a real `<script>` payload. `script.js`
|
||||
builds every row with `createElement`/`textContent`, never string-built
|
||||
HTML, so the console can't be XSS'd by the very payloads it's reporting on.
|
||||
|
||||
## Regenerating with your own logs
|
||||
|
||||
Swap the three files in `logs/` for real exports (keep the same filenames,
|
||||
or pass `--logs-dir`) and adjust the regexes in `siem_analyzer.py` if your
|
||||
log format differs from the nginx / iptables-style / sshd formats assumed
|
||||
here.
|
||||
539
q4-SIEM-build/siem_analyzer.py
Normal file
539
q4-SIEM-build/siem_analyzer.py
Normal file
@@ -0,0 +1,539 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
siem_analyzer.py
|
||||
=================
|
||||
A small, dependency-free SIEM (Security Information & Event Management) log
|
||||
analyzer.
|
||||
|
||||
It ingests traffic/activity logs from THREE endpoints:
|
||||
|
||||
1. Web server access log (endpoint-1 : nginx/Apache "combined" style)
|
||||
2. Firewall / router log (endpoint-2 : SRC/DST/PORT/ACTION style)
|
||||
3. SSH authentication log (endpoint-3 : syslog "sshd" style)
|
||||
|
||||
...runs a set of detection rules against each, correlates activity from the
|
||||
SAME source IP across DIFFERENT endpoints (the core value-add of a real
|
||||
SIEM), and writes the results as JSON for the companion HTML/CSS/JS
|
||||
dashboard to render.
|
||||
|
||||
Usage
|
||||
-----
|
||||
python3 siem_analyzer.py
|
||||
python3 siem_analyzer.py --logs-dir ./logs --out-dir ./dashboard
|
||||
|
||||
No third-party packages required (standard library only).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from urllib.parse import unquote
|
||||
from collections import Counter, defaultdict
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Any, Tuple
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Configuration: detection thresholds & signatures
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
THRESHOLDS = {
|
||||
"brute_force_attempts": 5, # failed logins ...
|
||||
"brute_force_window_sec": 300, # ... within this many seconds
|
||||
"port_scan_distinct_ports": 8, # distinct dest ports ...
|
||||
"port_scan_window_sec": 120, # ... within this many seconds
|
||||
"high_rate_requests": 15, # requests ...
|
||||
"high_rate_window_sec": 60, # ... within this many seconds
|
||||
}
|
||||
|
||||
# Example threat-intel seed list: IPs already known to be bad regardless of
|
||||
# behaviour observed in these logs. In production this would be pulled from
|
||||
# a feed (AbuseIPDB, OTX, internal blocklist, etc).
|
||||
KNOWN_MALICIOUS_IPS = {
|
||||
"192.0.2.77": "Listed in external threat-intel feed (example seed entry)",
|
||||
}
|
||||
|
||||
# (regex, label, severity) -- checked against URL path + query + referer
|
||||
SUSPICIOUS_WEB_PATTERNS: List[Tuple[re.Pattern, str, str]] = [
|
||||
(re.compile(r"union(\s|%20)+select", re.I), "SQL_INJECTION", "critical"),
|
||||
(re.compile(r"'\s*or\s*'?1'?\s*=\s*'?1", re.I), "SQL_INJECTION", "critical"),
|
||||
(re.compile(r"sleep\(\d+\)", re.I), "SQL_INJECTION", "critical"),
|
||||
(re.compile(r"<script.*?>", re.I), "XSS", "high"),
|
||||
(re.compile(r"javascript:", re.I), "XSS", "high"),
|
||||
(re.compile(r"\.\./\.\./", re.I), "PATH_TRAVERSAL", "high"),
|
||||
(re.compile(r"etc/passwd|etc/shadow", re.I), "PATH_TRAVERSAL", "high"),
|
||||
(re.compile(r";\s*(rm|wget|curl|nc)\s", re.I), "COMMAND_INJECTION", "critical"),
|
||||
]
|
||||
|
||||
SCANNER_USER_AGENTS = ["sqlmap", "nikto", "nmap", "masscan", "acunetix", "nessus", "wpscan"]
|
||||
|
||||
SEVERITY_WEIGHT = {"critical": 10, "high": 5, "medium": 2, "low": 1}
|
||||
SEVERITY_ORDER = ["critical", "high", "medium", "low"]
|
||||
|
||||
ENDPOINT_WEB = "web-server (endpoint-1)"
|
||||
ENDPOINT_FW = "firewall (endpoint-2)"
|
||||
ENDPOINT_AUTH = "ssh-auth (endpoint-3)"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Parsing
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
WEB_LOG_RE = re.compile(
|
||||
r'(?P<ip>\S+) \S+ \S+ \[(?P<time>[^\]]+)\] '
|
||||
r'"(?P<method>\S+) (?P<path>\S+)(?: \S+)?" '
|
||||
r'(?P<status>\d+) (?P<size>\S+) "(?P<referer>[^"]*)" "(?P<ua>[^"]*)"'
|
||||
)
|
||||
WEB_TIME_FMT = "%d/%b/%Y:%H:%M:%S %z"
|
||||
|
||||
FW_LOG_RE = re.compile(
|
||||
r'(?P<time>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) '
|
||||
r'SRC=(?P<src_ip>\S+) SPT=(?P<src_port>\d+) '
|
||||
r'DST=(?P<dst_ip>\S+) DPT=(?P<dst_port>\d+) '
|
||||
r'PROTO=(?P<proto>\S+) ACTION=(?P<action>\S+)'
|
||||
)
|
||||
FW_TIME_FMT = "%Y-%m-%d %H:%M:%S"
|
||||
|
||||
AUTH_LOG_RE = re.compile(
|
||||
r'(?P<time>\w{3}\s+\d{1,2} \d{2}:\d{2}:\d{2}) \S+ sshd\[\d+\]: '
|
||||
r'(?P<result>Failed password|Accepted password|Accepted publickey) '
|
||||
r'for (?:invalid user )?(?P<user>\S+) from (?P<ip>\S+) port (?P<port>\d+)'
|
||||
)
|
||||
AUTH_TIME_FMT = "%b %d %H:%M:%S"
|
||||
AUTH_LOG_YEAR = 2026 # syslog timestamps have no year; assume current year
|
||||
|
||||
|
||||
def parse_web_log(path: Path) -> List[Dict[str, Any]]:
|
||||
events = []
|
||||
for lineno, line in enumerate(_read_lines(path), start=1):
|
||||
m = WEB_LOG_RE.match(line)
|
||||
if not m:
|
||||
continue
|
||||
try:
|
||||
ts = datetime.strptime(m.group("time"), WEB_TIME_FMT).replace(tzinfo=None)
|
||||
except ValueError:
|
||||
continue
|
||||
events.append({
|
||||
"endpoint": ENDPOINT_WEB,
|
||||
"ts": ts,
|
||||
"ip": m.group("ip"),
|
||||
"method": m.group("method"),
|
||||
"path": m.group("path"),
|
||||
"status": m.group("status"),
|
||||
"referer": m.group("referer"),
|
||||
"ua": m.group("ua"),
|
||||
"raw": line,
|
||||
})
|
||||
return events
|
||||
|
||||
|
||||
def parse_firewall_log(path: Path) -> List[Dict[str, Any]]:
|
||||
events = []
|
||||
for line in _read_lines(path):
|
||||
m = FW_LOG_RE.match(line)
|
||||
if not m:
|
||||
continue
|
||||
try:
|
||||
ts = datetime.strptime(m.group("time"), FW_TIME_FMT)
|
||||
except ValueError:
|
||||
continue
|
||||
events.append({
|
||||
"endpoint": ENDPOINT_FW,
|
||||
"ts": ts,
|
||||
"ip": m.group("src_ip"),
|
||||
"dst_ip": m.group("dst_ip"),
|
||||
"dst_port": int(m.group("dst_port")),
|
||||
"proto": m.group("proto"),
|
||||
"action": m.group("action"),
|
||||
"raw": line,
|
||||
})
|
||||
return events
|
||||
|
||||
|
||||
def parse_auth_log(path: Path) -> List[Dict[str, Any]]:
|
||||
events = []
|
||||
for line in _read_lines(path):
|
||||
m = AUTH_LOG_RE.match(line)
|
||||
if not m:
|
||||
continue
|
||||
try:
|
||||
ts = datetime.strptime(f"{AUTH_LOG_YEAR} {m.group('time')}", f"%Y {AUTH_TIME_FMT}")
|
||||
except ValueError:
|
||||
continue
|
||||
events.append({
|
||||
"endpoint": ENDPOINT_AUTH,
|
||||
"ts": ts,
|
||||
"ip": m.group("ip"),
|
||||
"user": m.group("user"),
|
||||
"result": "failed" if m.group("result") == "Failed password" else "accepted",
|
||||
"raw": line,
|
||||
})
|
||||
return events
|
||||
|
||||
|
||||
def _read_lines(path: Path) -> List[str]:
|
||||
if not path.exists():
|
||||
print(f"[!] Log file not found, skipping: {path}", file=sys.stderr)
|
||||
return []
|
||||
with path.open("r", encoding="utf-8", errors="ignore") as f:
|
||||
return [ln.rstrip("\n") for ln in f if ln.strip()]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Alert helper
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
_alert_counter = 0
|
||||
|
||||
|
||||
def make_alert(ts: datetime, endpoint: str, src_ip: str, alert_type: str,
|
||||
severity: str, description: str, evidence: List[str]) -> Dict[str, Any]:
|
||||
global _alert_counter
|
||||
_alert_counter += 1
|
||||
return {
|
||||
"id": f"ALT-{_alert_counter:04d}",
|
||||
"timestamp": ts.isoformat(),
|
||||
"endpoint": endpoint,
|
||||
"src_ip": src_ip,
|
||||
"alert_type": alert_type,
|
||||
"severity": severity,
|
||||
"description": description,
|
||||
"evidence": evidence[:5], # cap sample evidence lines
|
||||
}
|
||||
|
||||
|
||||
def _find_bursts(sorted_ts: List[datetime], window_sec: int, threshold: int) -> List[Tuple[int, int]]:
|
||||
"""Greedy sliding window: return non-overlapping (start_idx, end_idx)
|
||||
ranges where >= `threshold` events occur within `window_sec` seconds."""
|
||||
bursts = []
|
||||
n = len(sorted_ts)
|
||||
i = 0
|
||||
while i < n:
|
||||
j = i
|
||||
while j < n and (sorted_ts[j] - sorted_ts[i]).total_seconds() <= window_sec:
|
||||
j += 1
|
||||
count = j - i
|
||||
if count >= threshold:
|
||||
bursts.append((i, j - 1))
|
||||
i = j # skip past this burst to avoid overlapping duplicate alerts
|
||||
else:
|
||||
i += 1
|
||||
return bursts
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Detection rules
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def detect_web_attacks(events: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
alerts = []
|
||||
for e in events:
|
||||
# Decode percent-encoding first -- attack tools URL-encode payloads,
|
||||
# and a real WAF/SIEM normalizes input before signature matching.
|
||||
haystack = unquote(f"{e['path']} {e['referer']}")
|
||||
for pattern, label, severity in SUSPICIOUS_WEB_PATTERNS:
|
||||
if pattern.search(haystack):
|
||||
alerts.append(make_alert(
|
||||
e["ts"], ENDPOINT_WEB, e["ip"], label, severity,
|
||||
f"{label.replace('_', ' ').title()} attempt detected in request to {e['path']}",
|
||||
[e["raw"]],
|
||||
))
|
||||
break # one alert per line is enough
|
||||
return alerts
|
||||
|
||||
|
||||
def detect_scanner_user_agents(events: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
groups: Dict[Tuple[str, str], List[Dict[str, Any]]] = defaultdict(list)
|
||||
for e in events:
|
||||
ua_lower = e["ua"].lower()
|
||||
for tool in SCANNER_USER_AGENTS:
|
||||
if tool in ua_lower:
|
||||
groups[(e["ip"], tool)].append(e)
|
||||
break
|
||||
alerts = []
|
||||
for (ip, tool), grp in groups.items():
|
||||
if len(grp) >= 3:
|
||||
grp.sort(key=lambda x: x["ts"])
|
||||
alerts.append(make_alert(
|
||||
grp[0]["ts"], ENDPOINT_WEB, ip, "RECON_SCANNER", "medium",
|
||||
f"Automated vulnerability scan detected ({tool}): {len(grp)} probe requests "
|
||||
f"against paths such as {', '.join(g['path'] for g in grp[:3])}",
|
||||
[g["raw"] for g in grp],
|
||||
))
|
||||
return alerts
|
||||
|
||||
|
||||
def detect_high_request_rate(events: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
by_ip: Dict[str, List[Dict[str, Any]]] = defaultdict(list)
|
||||
for e in events:
|
||||
by_ip[e["ip"]].append(e)
|
||||
alerts = []
|
||||
for ip, evs in by_ip.items():
|
||||
evs.sort(key=lambda x: x["ts"])
|
||||
ts_list = [e["ts"] for e in evs]
|
||||
for start, end in _find_bursts(ts_list, THRESHOLDS["high_rate_window_sec"], THRESHOLDS["high_rate_requests"]):
|
||||
count = end - start + 1
|
||||
severity = "high" if count >= 30 else "medium"
|
||||
alerts.append(make_alert(
|
||||
evs[start]["ts"], ENDPOINT_WEB, ip, "HIGH_REQUEST_RATE", severity,
|
||||
f"{count} requests from a single IP within "
|
||||
f"{THRESHOLDS['high_rate_window_sec']}s (possible scraping/DoS/automation)",
|
||||
[evs[k]["raw"] for k in range(start, min(start + 5, end + 1))],
|
||||
))
|
||||
return alerts
|
||||
|
||||
|
||||
def detect_port_scan(events: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
by_ip: Dict[str, List[Dict[str, Any]]] = defaultdict(list)
|
||||
for e in events:
|
||||
by_ip[e["ip"]].append(e)
|
||||
alerts = []
|
||||
for ip, evs in by_ip.items():
|
||||
evs.sort(key=lambda x: x["ts"])
|
||||
n = len(evs)
|
||||
i = 0
|
||||
while i < n:
|
||||
j = i
|
||||
ports_seen = set()
|
||||
while j < n and (evs[j]["ts"] - evs[i]["ts"]).total_seconds() <= THRESHOLDS["port_scan_window_sec"]:
|
||||
ports_seen.add(evs[j]["dst_port"])
|
||||
j += 1
|
||||
if len(ports_seen) >= THRESHOLDS["port_scan_distinct_ports"]:
|
||||
denied = sum(1 for k in range(i, j) if evs[k]["action"] == "DENY")
|
||||
allowed = (j - i) - denied
|
||||
alerts.append(make_alert(
|
||||
evs[i]["ts"], ENDPOINT_FW, ip, "PORT_SCAN", "high",
|
||||
f"Port scan detected: {len(ports_seen)} distinct destination ports probed "
|
||||
f"against {evs[i]['dst_ip']} within {THRESHOLDS['port_scan_window_sec']}s "
|
||||
f"({denied} denied, {allowed} allowed)",
|
||||
[evs[k]["raw"] for k in range(i, min(i + 5, j))],
|
||||
))
|
||||
i = j
|
||||
else:
|
||||
i += 1
|
||||
return alerts
|
||||
|
||||
|
||||
def detect_brute_force(events: List[Dict[str, Any]]) -> Tuple[List[Dict[str, Any]], Dict[str, datetime]]:
|
||||
"""Returns (alerts, {ip: last_burst_end_ts}) -- the second value lets us
|
||||
check for a successful login shortly after a brute-force burst."""
|
||||
by_ip: Dict[str, List[Dict[str, Any]]] = defaultdict(list)
|
||||
for e in events:
|
||||
by_ip[e["ip"]].append(e)
|
||||
|
||||
alerts = []
|
||||
burst_end_by_ip: Dict[str, datetime] = {}
|
||||
for ip, evs in by_ip.items():
|
||||
evs.sort(key=lambda x: x["ts"])
|
||||
failed = [e for e in evs if e["result"] == "failed"]
|
||||
ts_list = [e["ts"] for e in failed]
|
||||
for start, end in _find_bursts(ts_list, THRESHOLDS["brute_force_window_sec"], THRESHOLDS["brute_force_attempts"]):
|
||||
count = end - start + 1
|
||||
users_tried = sorted({failed[k]["user"] for k in range(start, end + 1)})
|
||||
alerts.append(make_alert(
|
||||
failed[start]["ts"], ENDPOINT_AUTH, ip, "BRUTE_FORCE_SSH", "high",
|
||||
f"{count} failed SSH login attempts within {THRESHOLDS['brute_force_window_sec']}s "
|
||||
f"trying {len(users_tried)} usernames ({', '.join(users_tried[:6])})",
|
||||
[failed[k]["raw"] for k in range(start, min(start + 5, end + 1))],
|
||||
))
|
||||
burst_end_by_ip[ip] = failed[end]["ts"]
|
||||
return alerts, burst_end_by_ip
|
||||
|
||||
|
||||
def detect_compromise_after_brute_force(events: List[Dict[str, Any]],
|
||||
burst_end_by_ip: Dict[str, datetime]) -> List[Dict[str, Any]]:
|
||||
alerts = []
|
||||
by_ip: Dict[str, List[Dict[str, Any]]] = defaultdict(list)
|
||||
for e in events:
|
||||
by_ip[e["ip"]].append(e)
|
||||
for ip, burst_end in burst_end_by_ip.items():
|
||||
for e in sorted(by_ip.get(ip, []), key=lambda x: x["ts"]):
|
||||
if e["result"] == "accepted" and e["ts"] >= burst_end:
|
||||
alerts.append(make_alert(
|
||||
e["ts"], ENDPOINT_AUTH, ip, "ACCOUNT_COMPROMISE_SUSPECTED", "critical",
|
||||
f"Successful SSH login as '{e['user']}' immediately following a brute-force "
|
||||
f"burst from the same IP -- account may be compromised",
|
||||
[e["raw"]],
|
||||
))
|
||||
break # only need the first successful login after the burst
|
||||
return alerts
|
||||
|
||||
|
||||
def check_blacklist(all_events: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
alerts = []
|
||||
seen: set = set()
|
||||
for e in sorted(all_events, key=lambda x: x["ts"]):
|
||||
ip = e["ip"]
|
||||
if ip in KNOWN_MALICIOUS_IPS and (ip, e["endpoint"]) not in seen:
|
||||
seen.add((ip, e["endpoint"]))
|
||||
alerts.append(make_alert(
|
||||
e["ts"], e["endpoint"], ip, "BLACKLISTED_IP_ACTIVITY", "critical",
|
||||
f"Traffic from known-malicious IP {ip}: {KNOWN_MALICIOUS_IPS[ip]}",
|
||||
[e["raw"]],
|
||||
))
|
||||
return alerts
|
||||
|
||||
|
||||
def correlate_cross_endpoint(alerts: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
by_ip: Dict[str, set] = defaultdict(set)
|
||||
earliest_ts: Dict[str, datetime] = {}
|
||||
for a in alerts:
|
||||
by_ip[a["src_ip"]].add(a["endpoint"])
|
||||
ts = datetime.fromisoformat(a["timestamp"])
|
||||
if a["src_ip"] not in earliest_ts or ts < earliest_ts[a["src_ip"]]:
|
||||
earliest_ts[a["src_ip"]] = ts
|
||||
|
||||
correlated = []
|
||||
for ip, endpoints in by_ip.items():
|
||||
if len(endpoints) >= 2:
|
||||
correlated.append(make_alert(
|
||||
earliest_ts[ip], "correlation-engine", ip, "MULTI_VECTOR_ATTACK", "critical",
|
||||
f"IP {ip} triggered alerts across {len(endpoints)} different endpoints "
|
||||
f"({', '.join(sorted(endpoints))}) -- consistent with a coordinated, "
|
||||
f"multi-stage attack (recon -> exploitation -> access)",
|
||||
[],
|
||||
))
|
||||
return correlated
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Aggregation
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def build_ip_summary(alerts: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
by_ip: Dict[str, List[Dict[str, Any]]] = defaultdict(list)
|
||||
for a in alerts:
|
||||
by_ip[a["src_ip"]].append(a)
|
||||
|
||||
summary = []
|
||||
for ip, a_list in by_ip.items():
|
||||
sev_counts = Counter(a["severity"] for a in a_list)
|
||||
score = sum(SEVERITY_WEIGHT[a["severity"]] for a in a_list)
|
||||
endpoints = sorted({a["endpoint"] for a in a_list if a["endpoint"] != "correlation-engine"})
|
||||
alert_types = sorted({a["alert_type"] for a in a_list})
|
||||
timestamps = [datetime.fromisoformat(a["timestamp"]) for a in a_list]
|
||||
summary.append({
|
||||
"ip": ip,
|
||||
"threat_score": score,
|
||||
"severity_counts": {s: sev_counts.get(s, 0) for s in SEVERITY_ORDER},
|
||||
"total_alerts": len(a_list),
|
||||
"alert_types": alert_types,
|
||||
"endpoints_involved": endpoints,
|
||||
"first_seen": min(timestamps).isoformat(),
|
||||
"last_seen": max(timestamps).isoformat(),
|
||||
"known_threat_intel": KNOWN_MALICIOUS_IPS.get(ip),
|
||||
})
|
||||
summary.sort(key=lambda x: x["threat_score"], reverse=True)
|
||||
return summary
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Main pipeline
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def run(logs_dir: Path, out_dir: Path) -> Dict[str, Any]:
|
||||
web_path = logs_dir / "endpoint1_web_access.log"
|
||||
fw_path = logs_dir / "endpoint2_firewall.log"
|
||||
auth_path = logs_dir / "endpoint3_auth.log"
|
||||
|
||||
web_events = parse_web_log(web_path)
|
||||
fw_events = parse_firewall_log(fw_path)
|
||||
auth_events = parse_auth_log(auth_path)
|
||||
all_events = web_events + fw_events + auth_events
|
||||
|
||||
alerts: List[Dict[str, Any]] = []
|
||||
alerts += detect_web_attacks(web_events)
|
||||
alerts += detect_scanner_user_agents(web_events)
|
||||
alerts += detect_high_request_rate(web_events)
|
||||
alerts += detect_port_scan(fw_events)
|
||||
|
||||
bf_alerts, burst_ends = detect_brute_force(auth_events)
|
||||
alerts += bf_alerts
|
||||
alerts += detect_compromise_after_brute_force(auth_events, burst_ends)
|
||||
|
||||
alerts += check_blacklist(all_events)
|
||||
|
||||
# correlation must run AFTER all per-endpoint alerts exist
|
||||
alerts += correlate_cross_endpoint(alerts)
|
||||
|
||||
alerts.sort(key=lambda a: a["timestamp"], reverse=True)
|
||||
malicious_ips = build_ip_summary(alerts)
|
||||
|
||||
sev_counts = Counter(a["severity"] for a in alerts)
|
||||
report = {
|
||||
"generated_at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
|
||||
"meta": {
|
||||
"endpoints": [ENDPOINT_WEB, ENDPOINT_FW, ENDPOINT_AUTH],
|
||||
"lines_parsed": {
|
||||
ENDPOINT_WEB: len(web_events),
|
||||
ENDPOINT_FW: len(fw_events),
|
||||
ENDPOINT_AUTH: len(auth_events),
|
||||
},
|
||||
},
|
||||
"stats": {
|
||||
"total_alerts": len(alerts),
|
||||
"by_severity": {s: sev_counts.get(s, 0) for s in SEVERITY_ORDER},
|
||||
"unique_malicious_ips": len(malicious_ips),
|
||||
"total_events_parsed": len(all_events),
|
||||
},
|
||||
"alerts": alerts,
|
||||
"malicious_ips": malicious_ips,
|
||||
}
|
||||
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
json_path = out_dir / "siem_report.json"
|
||||
js_path = out_dir / "data.js"
|
||||
json_text = json.dumps(report, indent=2)
|
||||
json_path.write_text(json_text, encoding="utf-8")
|
||||
|
||||
# Alert evidence is raw log data and may legitimately contain the literal
|
||||
# text "</script>" (e.g. a captured XSS payload). Escape the closing tag
|
||||
# sequence so this JSON can never be mistaken for the end of a <script>
|
||||
# block if it's ever inlined into an HTML document.
|
||||
js_safe_text = json_text.replace("</script", "<\\/script")
|
||||
js_path.write_text(f"// Auto-generated by siem_analyzer.py -- do not edit by hand\n"
|
||||
f"const SIEM_DATA = {js_safe_text};\n", encoding="utf-8")
|
||||
|
||||
_print_console_summary(report)
|
||||
print(f"\n[+] Full report written to: {json_path}")
|
||||
print(f"[+] Dashboard data written to: {js_path}")
|
||||
return report
|
||||
|
||||
|
||||
def _print_console_summary(report: Dict[str, Any]) -> None:
|
||||
stats = report["stats"]
|
||||
print("=" * 70)
|
||||
print(" SIEM ANALYSIS SUMMARY")
|
||||
print("=" * 70)
|
||||
print(f" Events parsed : {stats['total_events_parsed']}")
|
||||
print(f" Total alerts : {stats['total_alerts']}")
|
||||
for sev in SEVERITY_ORDER:
|
||||
print(f" - {sev:<9}: {stats['by_severity'][sev]}")
|
||||
print(f" Malicious IPs : {stats['unique_malicious_ips']}")
|
||||
print("-" * 70)
|
||||
print(" TOP MALICIOUS IPs")
|
||||
print("-" * 70)
|
||||
for ip_info in report["malicious_ips"][:10]:
|
||||
print(f" {ip_info['ip']:<16} score={ip_info['threat_score']:<4} "
|
||||
f"alerts={ip_info['total_alerts']:<3} "
|
||||
f"endpoints={len(ip_info['endpoints_involved'])} "
|
||||
f"types={','.join(ip_info['alert_types'])}")
|
||||
print("=" * 70)
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
p = argparse.ArgumentParser(description="Analyze SIEM logs from 3 endpoints and emit dashboard JSON.")
|
||||
p.add_argument("--logs-dir", type=Path, default=Path(__file__).parent / "logs",
|
||||
help="Directory containing endpoint1_web_access.log, endpoint2_firewall.log, endpoint3_auth.log")
|
||||
p.add_argument("--out-dir", type=Path, default=Path(__file__).parent / "dashboard",
|
||||
help="Directory to write siem_report.json and data.js into")
|
||||
return p.parse_args()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
args = parse_args()
|
||||
run(args.logs_dir, args.out_dir)
|
||||
المرجع في مشكلة جديدة
حظر مستخدم