| |
| """ROGLAG demo server. |
| |
| Stdlib-only HTTP server for a small security-log RAG demo: |
| - loads synthetic JSONL logs |
| - exposes dashboard/search/Q&A APIs |
| - uses a local retrieval + rule explanation fallback |
| - optionally calls OpenRouter when OPENROUTER_API_KEY is set |
| """ |
| from __future__ import annotations |
|
|
| import json |
| import heapq |
| import hashlib |
| import csv |
| import io |
| import math |
| import os |
| import re |
| import sqlite3 |
| import sys |
| import time |
| import urllib.error |
| import urllib.request |
| from array import array |
| from collections import Counter, defaultdict |
| from dataclasses import dataclass |
| from datetime import datetime |
| from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer |
| from pathlib import Path |
| from typing import Any |
| from urllib.parse import parse_qs, urlparse |
|
|
| try: |
| from app.generator import generate as gen_records, generate_live, to_csv |
| GENERATOR_OK = True |
| except Exception: |
| GENERATOR_OK = False |
|
|
|
|
| ROOT = Path(__file__).resolve().parent |
| WEB_DIR = ROOT / "web" |
|
|
| |
| CATEGORY_DEFAULT_SEVERITY = { |
| "benign": "info", "brute_force": "high", "port_scan": "medium", |
| "web_attack": "high", "priv_esc": "critical", "data_exfil": "critical", |
| "c2_beacon": "critical", |
| } |
|
|
|
|
| def resolve_project_path(env_name: str, default: Path) -> Path: |
| value = os.getenv(env_name) |
| if not value: |
| return default |
| path = Path(value) |
| return path if path.is_absolute() else ROOT / path |
|
|
|
|
| def load_dotenv(path: Path = ROOT / ".env") -> None: |
| if not path.exists(): |
| return |
| for raw in path.read_text(encoding="utf-8").splitlines(): |
| line = raw.strip() |
| if not line or line.startswith("#") or "=" not in line: |
| continue |
| key, value = line.split("=", 1) |
| key = key.strip() |
| value = value.strip().strip('"').strip("'") |
| if key and key not in os.environ: |
| os.environ[key] = value |
|
|
|
|
| load_dotenv() |
|
|
| DATA_FILE = resolve_project_path("LOGRAG_DATA_FILE", ROOT / "Data" / "files" / "security_logs.jsonl") |
| VECTOR_FILE = resolve_project_path("LOGRAG_VECTOR_FILE", ROOT / "Data" / "files" / "lograg_vectors.sqlite") |
|
|
| DEFAULT_MODEL = os.getenv("OPENROUTER_MODEL", "openrouter/free") |
| DEFAULT_EMBED_MODEL = os.getenv("OPENROUTER_EMBED_MODEL", "nvidia/llama-nemotron-embed-vl-1b-v2:free") |
|
|
| |
| QDRANT_URL = os.getenv("QDRANT_URL", "").rstrip("/") |
| QDRANT_API_KEY = os.getenv("QDRANT_API_KEY", "") |
| QDRANT_COLLECTION = os.getenv("QDRANT_COLLECTION", "roglag_logs") |
|
|
| CATEGORY_META = { |
| "benign": { |
| "label": "ปกติ", |
| "mitre": "-", |
| "risk": 10, |
| "runbook": "บันทึกไว้เป็น baseline และใช้เทียบพฤติกรรมผิดปกติในช่วงเวลาเดียวกัน", |
| }, |
| "brute_force": { |
| "label": "Brute force", |
| "mitre": "T1110", |
| "risk": 78, |
| "runbook": "ตรวจ IP ต้นทาง, lock account ชั่วคราว, เพิ่ม MFA, block IP ถ้ามี failed login ถี่ผิดปกติ", |
| }, |
| "port_scan": { |
| "label": "Port scan", |
| "mitre": "T1046", |
| "risk": 62, |
| "runbook": "ตรวจ firewall drop burst, จำกัด exposure ของ port สำคัญ, เพิ่ม deny rule สำหรับ source ที่ scan ซ้ำ", |
| }, |
| "web_attack": { |
| "label": "Web attack", |
| "mitre": "T1190", |
| "risk": 82, |
| "runbook": "ตรวจ request payload, WAF rule, access log รอบเวลาเดียวกัน และ patch endpoint ที่รับ parameter เสี่ยง", |
| }, |
| "priv_esc": { |
| "label": "Privilege escalation", |
| "mitre": "T1068/T1548", |
| "risk": 94, |
| "runbook": "ตรวจ sudo command, revoke session, review sudoers, rotate credential และดู command history", |
| }, |
| "data_exfil": { |
| "label": "Data exfiltration", |
| "mitre": "T1041", |
| "risk": 96, |
| "runbook": "ตัด connection ปลายทาง, ตรวจปริมาณ bytes, หา process เจ้าของ traffic และ preserve evidence", |
| }, |
| "c2_beacon": { |
| "label": "C2 beacon", |
| "mitre": "T1071", |
| "risk": 95, |
| "runbook": "isolate host, ตรวจ interval คงที่, block C2 destination และเก็บ memory/network artifact", |
| }, |
| } |
|
|
| TOKEN_RE = re.compile(r"[a-zA-Z0-9_.:/-]+|[\u0E00-\u0E7F]+") |
| SQLI_RE = re.compile(r"(union\s+select|drop\s+table|or\s+'?1'?='?1|sleep\()", re.I) |
| XSS_RE = re.compile(r"(<script|onerror=|document\.cookie)", re.I) |
|
|
| QUERY_EXPANSIONS = { |
| "brute_force": ["brute_force", "failed", "password", "login_failed", "ssh"], |
| "port_scan": ["port_scan", "drop", "syn", "dpt", "scan"], |
| "web_attack": ["web_attack", "sqli", "xss", "union", "select", "script", "payload"], |
| "priv_esc": ["priv_esc", "sudo", "root", "shadow", "passwd"], |
| "data_exfil": ["data_exfil", "outbound", "bytes", "exfil", "dst"], |
| "c2_beacon": ["c2_beacon", "c2", "beacon", "conn", "interval"], |
| } |
|
|
| QUERY_HINTS = { |
| "brute": "brute_force", |
| "bruteforce": "brute_force", |
| "failed login": "brute_force", |
| "เดารหัส": "brute_force", |
| "ล็อกอิน": "brute_force", |
| "scan": "port_scan", |
| "port": "port_scan", |
| "สแกน": "port_scan", |
| "sqli": "web_attack", |
| "sql": "web_attack", |
| "xss": "web_attack", |
| "payload": "web_attack", |
| "เว็บ": "web_attack", |
| "sudo": "priv_esc", |
| "root": "priv_esc", |
| "privilege": "priv_esc", |
| "ยกระดับ": "priv_esc", |
| "exfil": "data_exfil", |
| "outbound": "data_exfil", |
| "ข้อมูลรั่ว": "data_exfil", |
| "ส่งข้อมูล": "data_exfil", |
| "c2": "c2_beacon", |
| "beacon": "c2_beacon", |
| "interval": "c2_beacon", |
| "คงที่": "c2_beacon", |
| } |
|
|
|
|
| def tokenize(text: str) -> list[str]: |
| return [t.lower() for t in TOKEN_RE.findall(text or "") if len(t) > 1] |
|
|
|
|
| def expand_query(query: str) -> tuple[list[str], set[str]]: |
| lowered = query.lower() |
| categories = {cat for cat in CATEGORY_META if cat in lowered} |
| for hint, category in QUERY_HINTS.items(): |
| if hint in lowered: |
| categories.add(category) |
|
|
| terms = tokenize(query.replace("_", " ")) |
| for category in categories: |
| terms.extend(QUERY_EXPANSIONS.get(category, [])) |
| return terms, categories |
|
|
|
|
| def parse_csv_text(text: str) -> list[dict[str, Any]]: |
| reader = csv.DictReader(io.StringIO(text or "")) |
| rows: list[dict[str, Any]] = [] |
| for raw in reader: |
| row = {(k or "").strip(): (v.strip() if isinstance(v, str) else v) for k, v in raw.items()} |
| row = {k: (v if v != "" else None) for k, v in row.items()} |
| if row.get("message") or row.get("source"): |
| rows.append(row) |
| return rows |
|
|
|
|
| def json_response(handler: BaseHTTPRequestHandler, payload: Any, status: int = 200) -> None: |
| body = json.dumps(payload, ensure_ascii=False).encode("utf-8") |
| handler.send_response(status) |
| handler.send_header("Content-Type", "application/json; charset=utf-8") |
| handler.send_header("Content-Length", str(len(body))) |
| handler.end_headers() |
| handler.wfile.write(body) |
|
|
|
|
| def read_json_body(handler: BaseHTTPRequestHandler) -> dict[str, Any]: |
| length = int(handler.headers.get("Content-Length") or 0) |
| if length <= 0: |
| return {} |
| raw = handler.rfile.read(length) |
| return json.loads(raw.decode("utf-8")) |
|
|
|
|
| def detect_rule(row: dict[str, Any]) -> dict[str, Any]: |
| message = row.get("message", "") |
| category = "benign" |
| reasons: list[str] = [] |
|
|
| if "Failed password" in message: |
| category = "brute_force" |
| reasons.append("พบ failed SSH login") |
| if "DROP" in message and "FLAGS=SYN" in message: |
| category = "port_scan" |
| reasons.append("พบ SYN หลายปลายทาง/หลาย port") |
| if SQLI_RE.search(message) or XSS_RE.search(message): |
| category = "web_attack" |
| reasons.append("พบ SQLi/XSS payload ใน request") |
| if "sudo:" in message and "USER=root" in message: |
| category = "priv_esc" |
| reasons.append("พบ sudo ไปยัง root") |
| if "OUTBOUND" in message and "bytes=" in message: |
| category = "data_exfil" |
| reasons.append("พบ outbound transfer ขนาดใหญ่") |
| if "interval=" in message and "CONN" in message: |
| category = "c2_beacon" |
| reasons.append("พบ connection interval คงที่แบบ beacon") |
|
|
| meta = CATEGORY_META[category] |
| expected = row.get("category") |
| return { |
| "category": category, |
| "label": meta["label"], |
| "risk_score": meta["risk"], |
| "severity": row.get("severity", "info"), |
| "mitre": meta["mitre"], |
| "reasons": reasons or ["ไม่พบ pattern เสี่ยงตาม rule พื้นฐาน"], |
| "matches_label": expected == category, |
| } |
|
|
|
|
| @dataclass |
| class SearchHit: |
| score: float |
| row: dict[str, Any] |
| rule: dict[str, Any] |
| retrieval: str = "keyword" |
|
|
|
|
| def normalize_vector(values: list[float]) -> array: |
| norm = math.sqrt(sum(float(v) * float(v) for v in values)) or 1.0 |
| return array("f", (float(v) / norm for v in values)) |
|
|
|
|
| def dot_product(left: array, right: array) -> float: |
| return sum(a * b for a, b in zip(left, right)) |
|
|
|
|
| def call_openrouter_embeddings(inputs: list[str], model: str = DEFAULT_EMBED_MODEL) -> list[list[float]]: |
| api_key = os.getenv("OPENROUTER_API_KEY") |
| if not api_key: |
| raise RuntimeError("OPENROUTER_API_KEY is not set") |
|
|
| payload = {"model": model, "input": inputs} |
| req = urllib.request.Request( |
| "https://openrouter.ai/api/v1/embeddings", |
| data=json.dumps(payload).encode("utf-8"), |
| headers={ |
| "Authorization": f"Bearer {api_key}", |
| "Content-Type": "application/json", |
| "HTTP-Referer": "http://localhost:8765", |
| "X-Title": "ROGLAG Demo", |
| }, |
| method="POST", |
| ) |
| try: |
| with urllib.request.urlopen(req, timeout=60) as resp: |
| result = json.loads(resp.read().decode("utf-8")) |
| except urllib.error.HTTPError as exc: |
| detail = exc.read().decode("utf-8", errors="replace") |
| raise RuntimeError(f"OpenRouter embedding error {exc.code}: {detail}") from exc |
|
|
| data = sorted(result["data"], key=lambda item: item["index"]) |
| return [item["embedding"] for item in data] |
|
|
|
|
| class VectorIndex: |
| def __init__(self, path: Path, rows: list[dict[str, Any]]): |
| self.path = path |
| self.rows = rows |
| self.ready = path.exists() |
| self.model = "" |
| self.dimensions = 0 |
| self.count = 0 |
| self.error = None |
| if self.ready: |
| try: |
| self._load_meta() |
| except Exception as exc: |
| self.ready = False |
| self.error = str(exc) |
|
|
| def _load_meta(self) -> None: |
| with sqlite3.connect(self.path) as conn: |
| meta = dict(conn.execute("select key, value from meta").fetchall()) |
| self.model = meta.get("model", "") |
| self.dimensions = int(meta.get("dimensions", "0")) |
| self.count = int(conn.execute("select count(*) from vectors").fetchone()[0]) |
|
|
| def search(self, query: str, limit: int = 8) -> list[SearchHit]: |
| if not self.ready: |
| return [] |
| model = self.model or DEFAULT_EMBED_MODEL |
| query_vector = normalize_vector(call_openrouter_embeddings([query], model=model)[0]) |
| scored: list[tuple[float, int]] = [] |
| with sqlite3.connect(self.path) as conn: |
| cursor = conn.execute("select row_id, embedding from vectors") |
| for row_id, blob in cursor: |
| values = array("f") |
| values.frombytes(blob) |
| score = dot_product(query_vector, values) |
| scored.append((score, int(row_id))) |
| hits = heapq.nlargest(limit, scored, key=lambda item: item[0]) |
| return [ |
| SearchHit( |
| score=round(score, 4), |
| row=self.rows[row_id], |
| rule=detect_rule(self.rows[row_id]), |
| retrieval="vector", |
| ) |
| for score, row_id in hits |
| if 0 <= row_id < len(self.rows) |
| ] |
|
|
|
|
| def qdrant_search(query: str, limit: int = 8) -> list[SearchHit]: |
| """Query the real Qdrant vector DB: embed the query then ANN search.""" |
| if not QDRANT_URL: |
| return [] |
| vector = call_openrouter_embeddings([query])[0] |
| headers = {"Content-Type": "application/json"} |
| if QDRANT_API_KEY: |
| headers["api-key"] = QDRANT_API_KEY |
| req = urllib.request.Request( |
| f"{QDRANT_URL}/collections/{QDRANT_COLLECTION}/points/search", |
| data=json.dumps({"vector": vector, "limit": limit, "with_payload": True}).encode("utf-8"), |
| headers=headers, |
| method="POST", |
| ) |
| with urllib.request.urlopen(req, timeout=30) as resp: |
| result = json.loads(resp.read().decode("utf-8")) |
| hits: list[SearchHit] = [] |
| for item in result.get("result", []): |
| payload = item.get("payload") or {} |
| row = payload.get("row") if isinstance(payload.get("row"), dict) else payload |
| if isinstance(row, dict) and row: |
| hits.append(SearchHit( |
| score=round(float(item.get("score", 0)), 4), |
| row=row, |
| rule=detect_rule(row), |
| retrieval="qdrant", |
| )) |
| return hits |
|
|
|
|
| def qdrant_status() -> dict[str, Any]: |
| if not QDRANT_URL: |
| return {"enabled": False} |
| try: |
| headers = {"api-key": QDRANT_API_KEY} if QDRANT_API_KEY else {} |
| req = urllib.request.Request(f"{QDRANT_URL}/collections/{QDRANT_COLLECTION}", headers=headers) |
| with urllib.request.urlopen(req, timeout=5) as resp: |
| data = json.loads(resp.read().decode("utf-8")).get("result", {}) |
| return {"enabled": True, "url": QDRANT_URL, "collection": QDRANT_COLLECTION, |
| "points": data.get("points_count", 0)} |
| except Exception as exc: |
| return {"enabled": True, "url": QDRANT_URL, "error": str(exc)} |
|
|
|
|
| class LogRagIndex: |
| def __init__(self, data_file: Path): |
| started = time.time() |
| self.data_file = data_file |
| self.rows = self._load_rows(data_file) |
| self.doc_tokens: list[Counter[str]] = [] |
| self.doc_freq: Counter[str] = Counter() |
| self.postings: defaultdict[str, list[tuple[int, int]]] = defaultdict(list) |
| self.category_index: defaultdict[str, list[int]] = defaultdict(list) |
| self.idf: dict[str, float] = {} |
| self.stats = self._build_stats() |
| self._build_index() |
| self.vector_index = VectorIndex(VECTOR_FILE, self.rows) |
| self.loaded_at = datetime.now().isoformat(timespec="seconds") |
| self.load_seconds = round(time.time() - started, 3) |
|
|
| def _load_rows(self, path: Path) -> list[dict[str, Any]]: |
| if not path.exists(): |
| raise FileNotFoundError(f"Dataset not found: {path}") |
| rows = [] |
| with path.open(encoding="utf-8") as f: |
| for line in f: |
| if line.strip(): |
| rows.append(json.loads(line)) |
| return rows |
|
|
| def _build_stats(self) -> dict[str, Any]: |
| by_category = Counter(row.get("category", "unknown") for row in self.rows) |
| by_severity = Counter(row.get("severity", "unknown") for row in self.rows) |
| by_source = Counter(row.get("source", "unknown") for row in self.rows) |
| risky = sum(1 for row in self.rows if row.get("category") != "benign") |
| top_ips = Counter(row.get("src_ip") for row in self.rows if row.get("src_ip")).most_common(10) |
| top_targets = Counter(row.get("dst_ip") for row in self.rows if row.get("dst_ip")).most_common(10) |
| times = [row.get("timestamp") for row in self.rows if row.get("timestamp")] |
| timeline = Counter((ts or "")[:13] + ":00" for ts in times) |
| timeline_by_severity: dict[str, dict[str, int]] = defaultdict(lambda: {"critical": 0, "high": 0, "medium": 0, "info": 0}) |
| for row in self.rows: |
| bucket = (row.get("timestamp") or "")[:13] + ":00" |
| if not bucket.strip(":0"): |
| continue |
| severity = row.get("severity", "info") |
| timeline_by_severity[bucket][severity] = timeline_by_severity[bucket].get(severity, 0) + 1 |
|
|
| return { |
| "total": len(self.rows), |
| "risky": risky, |
| "risk_rate": round((risky / max(len(self.rows), 1)) * 100, 2), |
| "by_category": dict(by_category), |
| "by_severity": dict(by_severity), |
| "by_source": dict(by_source), |
| "top_ips": top_ips, |
| "top_targets": top_targets, |
| "timeline": dict(sorted(timeline.items())), |
| "timeline_by_severity": dict(sorted(timeline_by_severity.items())), |
| "categories": CATEGORY_META, |
| } |
|
|
| def _row_text(self, row: dict[str, Any]) -> str: |
| parts = [ |
| row.get("message", ""), |
| row.get("category", ""), |
| row.get("severity", ""), |
| row.get("source", ""), |
| row.get("src_ip", ""), |
| row.get("dst_ip", ""), |
| row.get("path", ""), |
| row.get("user", ""), |
| ] |
| return " ".join(str(p) for p in parts if p is not None) |
|
|
| def _build_index(self) -> None: |
| for idx, row in enumerate(self.rows): |
| counts = Counter(tokenize(self._row_text(row))) |
| self.doc_tokens.append(counts) |
| self.doc_freq.update(counts.keys()) |
| self.category_index[row.get("category", "unknown")].append(idx) |
| for term, tf in counts.items(): |
| self.postings[term].append((idx, tf)) |
| total = max(len(self.rows), 1) |
| self.idf = {term: math.log((1 + total) / (1 + df)) + 1 for term, df in self.doc_freq.items()} |
|
|
| def add_rows(self, raw_rows: list[dict[str, Any]]) -> dict[str, Any]: |
| """Classify + append rows into the live in-memory index, refresh stats. |
| |
| Returns an ingest summary (counts + detection accuracy vs any label).""" |
| by_cat: Counter[str] = Counter() |
| by_sev: Counter[str] = Counter() |
| correct = labelled = 0 |
| start = len(self.rows) |
| events: list[dict[str, Any]] = [] |
|
|
| for raw in raw_rows: |
| rule = detect_rule(raw) |
| category = rule["category"] |
| severity = raw.get("severity") or CATEGORY_DEFAULT_SEVERITY.get(category, "info") |
| expected = raw.get("category") |
| row = dict(raw) |
| row["category"] = category |
| row["severity"] = severity |
| row.setdefault("timestamp", datetime.now().isoformat(timespec="seconds")) |
|
|
| idx = len(self.rows) |
| self.rows.append(row) |
| counts = Counter(tokenize(self._row_text(row))) |
| self.doc_tokens.append(counts) |
| self.doc_freq.update(counts.keys()) |
| self.category_index[category].append(idx) |
| for term, tf in counts.items(): |
| self.postings[term].append((idx, tf)) |
|
|
| by_cat[category] += 1 |
| by_sev[severity] += 1 |
| if expected: |
| labelled += 1 |
| if expected == category: |
| correct += 1 |
| if len(events) < 400: |
| events.append({ |
| "timestamp": row.get("timestamp"), |
| "source": row.get("source"), |
| "category": category, |
| "label": rule["label"], |
| "severity": severity, |
| "mitre": rule["mitre"], |
| "risk_score": rule["risk_score"], |
| "src_ip": row.get("src_ip"), |
| "dst_ip": row.get("dst_ip"), |
| "message": row.get("message"), |
| }) |
|
|
| total = max(len(self.rows), 1) |
| self.idf = {term: math.log((1 + total) / (1 + df)) + 1 for term, df in self.doc_freq.items()} |
| self.stats = self._build_stats() |
|
|
| ingested = len(self.rows) - start |
| return { |
| "ingested": ingested, |
| "total": len(self.rows), |
| "by_category": dict(by_cat), |
| "by_severity": dict(by_sev), |
| "risky": sum(v for k, v in by_cat.items() if k != "benign"), |
| "critical": by_sev.get("critical", 0), |
| "detection_accuracy": round(correct / labelled * 100, 1) if labelled else None, |
| "events": events, |
| } |
|
|
| def filter_rows(self, params: dict[str, list[str]]) -> dict[str, Any]: |
| limit = min(int((params.get("limit") or ["100"])[0]), 500) |
| offset = max(int((params.get("offset") or ["0"])[0]), 0) |
| category = (params.get("category") or [""])[0] |
| severity = (params.get("severity") or [""])[0] |
| source = (params.get("source") or [""])[0] |
| query = (params.get("q") or [""])[0].strip().lower() |
|
|
| filtered = [] |
| for row in self.rows: |
| if category and row.get("category") != category: |
| continue |
| if severity and row.get("severity") != severity: |
| continue |
| if source and row.get("source") != source: |
| continue |
| if query and query not in self._row_text(row).lower(): |
| continue |
| filtered.append(row) |
|
|
| page = filtered[offset : offset + limit] |
| return { |
| "total": len(filtered), |
| "offset": offset, |
| "limit": limit, |
| "rows": [{**row, "rule": detect_rule(row)} for row in page], |
| } |
|
|
| def _alert_key(self, row: dict[str, Any]) -> str: |
| if row.get("category") == "priv_esc": |
| return "|".join([row.get("category", ""), row.get("user", ""), row.get("host", "")]) |
| return "|".join( |
| [ |
| row.get("category", ""), |
| row.get("src_ip", ""), |
| row.get("dst_ip", ""), |
| row.get("user", ""), |
| str(row.get("beacon_interval", "")), |
| ] |
| ) |
|
|
| def critical_alerts(self, params: dict[str, list[str]]) -> dict[str, Any]: |
| limit = min(int((params.get("limit") or ["20"])[0]), 100) |
| since = (params.get("since") or [""])[0] |
| groups: dict[str, list[dict[str, Any]]] = defaultdict(list) |
|
|
| for row in self.rows: |
| if row.get("severity") != "critical": |
| continue |
| if since and str(row.get("timestamp", "")) <= since: |
| continue |
| groups[self._alert_key(row)].append(row) |
|
|
| alerts = [] |
| for key, items in groups.items(): |
| items.sort(key=lambda item: str(item.get("timestamp", ""))) |
| first = items[0] |
| last = items[-1] |
| meta = CATEGORY_META.get(first.get("category"), CATEGORY_META["benign"]) |
| alert_id = hashlib.sha1(f"{key}|{first.get('timestamp')}|{last.get('timestamp')}".encode("utf-8")).hexdigest()[:12] |
| rule = detect_rule(last) |
| alerts.append( |
| { |
| "id": f"ALT-{alert_id}", |
| "title": meta["label"], |
| "category": first.get("category"), |
| "severity": "critical", |
| "mitre": meta["mitre"], |
| "status": "new", |
| "count": len(items), |
| "first_seen": first.get("timestamp"), |
| "last_seen": last.get("timestamp"), |
| "src_ip": first.get("src_ip", "-"), |
| "dst_ip": first.get("dst_ip", "-"), |
| "user": first.get("user", "-"), |
| "message": last.get("message"), |
| "rule": rule["reasons"], |
| "recommended_action": meta["runbook"], |
| } |
| ) |
|
|
| alerts.sort(key=lambda item: str(item["last_seen"]), reverse=True) |
| return {"total": len(alerts), "limit": limit, "alerts": alerts[:limit]} |
|
|
| def _search_keyword(self, query: str, limit: int = 8) -> list[SearchHit]: |
| terms, category_terms = expand_query(query) |
| if not terms: |
| return [] |
|
|
| query_counts = Counter(terms) |
| scores: defaultdict[int, float] = defaultdict(float) |
|
|
| for term, qtf in query_counts.items(): |
| for idx, tf in self.postings.get(term, []): |
| scores[idx] += (1 + math.log(tf)) * self.idf.get(term, 1.0) * qtf |
|
|
| for category in category_terms: |
| for idx in self.category_index.get(category, []): |
| scores[idx] += 12 |
|
|
| lowered = query.lower() |
| for idx in list(scores.keys()): |
| if lowered and lowered in self._row_text(self.rows[idx]).lower(): |
| scores[idx] += 8 |
|
|
| ranked = sorted(scores.items(), key=lambda item: item[1], reverse=True)[:limit] |
| return [SearchHit(score=round(score, 3), row=self.rows[idx], rule=detect_rule(self.rows[idx])) for idx, score in ranked] |
|
|
| def hits_from_rows(self, rows: list[dict[str, Any]], limit: int = 8) -> list[SearchHit]: |
| return [ |
| SearchHit(score=1.0, row=row, rule=detect_rule(row), retrieval="selected_incident") |
| for row in rows[:limit] |
| ] |
|
|
| def search(self, query: str, limit: int = 8, retrieval: str = "auto") -> list[SearchHit]: |
| |
| if retrieval in {"auto", "vector", "qdrant"} and QDRANT_URL and os.getenv("OPENROUTER_API_KEY"): |
| try: |
| hits = qdrant_search(query, limit) |
| if hits or retrieval in {"vector", "qdrant"}: |
| return hits |
| except Exception: |
| if retrieval == "qdrant": |
| raise |
| |
| if retrieval in {"auto", "vector"} and self.vector_index.ready and os.getenv("OPENROUTER_API_KEY"): |
| try: |
| hits = self.vector_index.search(query, limit) |
| if hits or retrieval == "vector": |
| return hits |
| except Exception: |
| if retrieval == "vector": |
| raise |
| return self._search_keyword(query, limit) |
|
|
| def answer_local(self, question: str, hits: list[SearchHit]) -> dict[str, Any]: |
| rows = [hit.row for hit in hits] |
| by_cat = Counter(row.get("category") for row in rows) |
| by_sev = Counter(row.get("severity") for row in rows) |
| categories = [cat for cat, _ in by_cat.most_common()] |
|
|
| if not rows: |
| answer = ( |
| "## ยังไม่พบหลักฐาน\n\n" |
| "- ลองถามด้วย IP, category เช่น `brute_force`, `c2_beacon`\n" |
| "- หรือเลือก incident ก่อนแล้วกดถามจากหลักฐาน" |
| ) |
| else: |
| top = rows[0] |
| cat = top.get("category", "benign") |
| meta = CATEGORY_META.get(cat, CATEGORY_META["benign"]) |
| answer = ( |
| f"## สรุปความเสี่ยง\n\n" |
| f"- พบหลักฐานที่เกี่ยวข้อง **{len(rows)} รายการ**\n" |
| f"- กลุ่มเด่น: **{', '.join(categories[:3])}**\n" |
| f"- รายการสำคัญสุด: **{meta['label']}** severity=`{top.get('severity')}` source=`{top.get('source')}`\n" |
| f"- เวลา: `{top.get('timestamp')}`\n\n" |
| f"## เหตุผล\n\n" |
| f"- {', '.join(detect_rule(top)['reasons'])}\n\n" |
| f"## Next action\n\n" |
| f"- {meta['runbook']}" |
| ) |
|
|
| return { |
| "answer": answer, |
| "mode": "local", |
| "summary": { |
| "categories": dict(by_cat), |
| "severities": dict(by_sev), |
| }, |
| } |
|
|
| def build_context(self, hits: list[SearchHit]) -> str: |
| lines = [] |
| for i, hit in enumerate(hits, 1): |
| row = hit.row |
| meta = CATEGORY_META.get(row.get("category"), CATEGORY_META["benign"]) |
| lines.append( |
| "\n".join( |
| [ |
| f"[{i}] score={hit.score}", |
| f"time={row.get('timestamp')} source={row.get('source')} severity={row.get('severity')}", |
| f"category={row.get('category')} label={meta['label']} mitre={meta['mitre']}", |
| f"src_ip={row.get('src_ip', '-')} dst_ip={row.get('dst_ip', '-')}", |
| f"message={row.get('message')}", |
| f"rule_reason={'; '.join(hit.rule['reasons'])}", |
| f"runbook={meta['runbook']}", |
| ] |
| ) |
| ) |
| return "\n\n".join(lines) |
|
|
|
|
| def call_openrouter(question: str, context: str, model: str) -> str: |
| api_key = os.getenv("OPENROUTER_API_KEY") |
| if not api_key: |
| raise RuntimeError("OPENROUTER_API_KEY is not set") |
|
|
| payload = { |
| "model": model or DEFAULT_MODEL, |
| "messages": [ |
| { |
| "role": "system", |
| "content": ( |
| "You are ROGLAG, a defensive security log analyst. " |
| "Answer in Thai. Use only the provided retrieved logs and runbook context. " |
| "ALWAYS output ALL four Markdown sections in this order and never stop early: " |
| "## สรุป, ## หลักฐาน, ## ความเสี่ยง, ## Next action. " |
| "Keep each section short; finish every section. " |
| "Use short bullet points and inline code for IPs, users, MITRE IDs, and log fields. " |
| "Do not include safety labels such as 'User Safety'. " |
| "Do not provide offensive instructions." |
| ), |
| }, |
| { |
| "role": "user", |
| "content": f"คำถาม: {question}\n\nRetrieved context:\n{context}", |
| }, |
| ], |
| "temperature": 0.2, |
| "max_tokens": 900, |
| } |
| data = json.dumps(payload).encode("utf-8") |
| req = urllib.request.Request( |
| "https://openrouter.ai/api/v1/chat/completions", |
| data=data, |
| headers={ |
| "Authorization": f"Bearer {api_key}", |
| "Content-Type": "application/json", |
| "HTTP-Referer": "http://localhost:8765", |
| "X-Title": "ROGLAG Demo", |
| }, |
| method="POST", |
| ) |
| try: |
| with urllib.request.urlopen(req, timeout=45) as resp: |
| result = json.loads(resp.read().decode("utf-8")) |
| except urllib.error.HTTPError as exc: |
| detail = exc.read().decode("utf-8", errors="replace") |
| raise RuntimeError(f"OpenRouter error {exc.code}: {detail}") from exc |
|
|
| return result["choices"][0]["message"]["content"] |
|
|
|
|
| def ensure_answer_sections(answer: str, hits: list[SearchHit]) -> str: |
| """Guarantee the answer always ends with a concrete Next action + risk line, |
| even when a flaky free LLM returns a truncated/incomplete response.""" |
| text = (answer or "").strip() |
| if not hits: |
| return text |
| meta = CATEGORY_META.get(hits[0].row.get("category"), CATEGORY_META["benign"]) |
| lowered = text.lower() |
| if "ความเสี่ยง" not in text: |
| text += f"\n\n## ความเสี่ยง\n\n- ระดับเด่น **{meta['label']}** · MITRE `{meta['mitre']}` · risk `{meta['risk']}`" |
| if "next action" not in lowered: |
| text += f"\n\n## Next action\n\n- {meta['runbook']}" |
| return text |
|
|
|
|
| INDEX = LogRagIndex(DATA_FILE) |
|
|
|
|
| class Handler(BaseHTTPRequestHandler): |
| server_version = "ROGLAG/0.1" |
|
|
| def do_HEAD(self) -> None: |
| parsed = urlparse(self.path) |
| if parsed.path in {"/api/health", "/api/stats", "/api/logs", "/api/alerts"}: |
| self.send_response(200) |
| self.send_header("Content-Type", "application/json; charset=utf-8") |
| self.end_headers() |
| return |
| if parsed.path in {"", "/"}: |
| file_path = WEB_DIR / "index.html" |
| else: |
| file_path = (WEB_DIR / parsed.path.lstrip("/")).resolve() |
| if not str(file_path).startswith(str(WEB_DIR.resolve())): |
| self.send_error(403) |
| return |
| if not file_path.exists() or file_path.is_dir(): |
| self.send_error(404) |
| return |
| self.send_response(200) |
| self.send_header("Content-Length", str(file_path.stat().st_size)) |
| self.end_headers() |
|
|
| def do_GET(self) -> None: |
| parsed = urlparse(self.path) |
| if parsed.path == "/api/health": |
| json_response( |
| self, |
| { |
| "ok": True, |
| "dataset": str(DATA_FILE.relative_to(ROOT)), |
| "records": len(INDEX.rows), |
| "loaded_at": INDEX.loaded_at, |
| "load_seconds": INDEX.load_seconds, |
| "openrouter_ready": bool(os.getenv("OPENROUTER_API_KEY")), |
| "default_model": DEFAULT_MODEL, |
| "embed_model": DEFAULT_EMBED_MODEL, |
| "vector_ready": INDEX.vector_index.ready, |
| "vector_records": INDEX.vector_index.count, |
| "vector_dimensions": INDEX.vector_index.dimensions, |
| "vector_error": INDEX.vector_index.error, |
| "qdrant": qdrant_status(), |
| }, |
| ) |
| return |
| if parsed.path == "/api/stats": |
| json_response(self, INDEX.stats) |
| return |
| if parsed.path == "/api/logs": |
| json_response(self, INDEX.filter_rows(parse_qs(parsed.query))) |
| return |
| if parsed.path == "/api/alerts": |
| json_response(self, INDEX.critical_alerts(parse_qs(parsed.query))) |
| return |
| if parsed.path == "/api/generate/csv": |
| self.serve_generated_csv(parse_qs(parsed.query)) |
| return |
| self.serve_static(parsed.path) |
|
|
| def do_POST(self) -> None: |
| parsed = urlparse(self.path) |
| try: |
| body = read_json_body(self) |
| if parsed.path == "/api/mock/emit": |
| if not GENERATOR_OK: |
| json_response(self, {"error": "generator not available"}, 503) |
| return |
| params = parse_qs(parsed.query) |
| count = min(int((params.get("count") or ["20"])[0]), 500) |
| profile = (params.get("profile") or ["mixed"])[0] |
| summary = INDEX.add_rows(generate_live(count, profile=profile)) |
| json_response(self, summary) |
| return |
| if parsed.path == "/api/ingest/csv": |
| rows = parse_csv_text(str(body.get("content") or "")) |
| if not rows: |
| json_response(self, {"error": "no parseable rows (need a message or source column)"}, 400) |
| return |
| if len(rows) > 50000: |
| rows = rows[:50000] |
| summary = INDEX.add_rows(rows) |
| summary["filename"] = body.get("filename") |
| json_response(self, summary) |
| return |
| if parsed.path == "/api/search": |
| query = str(body.get("query", "")) |
| limit = min(int(body.get("limit", 8)), 30) |
| retrieval = str(body.get("retrieval") or "auto") |
| hits = INDEX.search(query, limit, retrieval=retrieval) |
| json_response(self, {"hits": [hit.__dict__ for hit in hits]}) |
| return |
| if parsed.path == "/api/ask": |
| question = str(body.get("question", "")).strip() |
| limit = min(int(body.get("limit", 8)), 20) |
| model = str(body.get("model") or DEFAULT_MODEL) |
| retrieval = str(body.get("retrieval") or "auto") |
| if not question: |
| json_response(self, {"error": "question is required"}, 400) |
| return |
| context_rows = body.get("context_rows") |
| if isinstance(context_rows, list) and context_rows: |
| hits = INDEX.hits_from_rows([row for row in context_rows if isinstance(row, dict)], limit) |
| else: |
| hits = INDEX.search(question, limit, retrieval=retrieval) |
| context = INDEX.build_context(hits) |
| local = INDEX.answer_local(question, hits) |
| mode = "local" |
| answer = local["answer"] |
| llm_error = None |
| if body.get("use_llm") and os.getenv("OPENROUTER_API_KEY"): |
| try: |
| answer = call_openrouter(question, context, model) |
| mode = "openrouter" |
| except Exception as exc: |
| llm_error = str(exc) |
| answer = ensure_answer_sections(answer, hits) |
| json_response( |
| self, |
| { |
| "answer": answer, |
| "mode": mode, |
| "model": model, |
| "retrieval": hits[0].retrieval if hits else "none", |
| "llm_error": llm_error, |
| "local_summary": local["summary"], |
| "hits": [hit.__dict__ for hit in hits], |
| }, |
| ) |
| return |
| json_response(self, {"error": "not found"}, 404) |
| except Exception as exc: |
| json_response(self, {"error": str(exc)}, 500) |
|
|
| def serve_generated_csv(self, params: dict[str, list[str]]) -> None: |
| if not GENERATOR_OK: |
| json_response(self, {"error": "generator not available"}, 503) |
| return |
| count = min(int((params.get("count") or ["200"])[0]), 20000) |
| profile = (params.get("profile") or ["mixed"])[0] |
| records = gen_records(count, profile=profile, seed=None) |
| body = to_csv(records).encode("utf-8") |
| fname = f"roglag_logs_{profile}_{len(records)}.csv" |
| self.send_response(200) |
| self.send_header("Content-Type", "text/csv; charset=utf-8") |
| self.send_header("Content-Disposition", f'attachment; filename="{fname}"') |
| self.send_header("Content-Length", str(len(body))) |
| self.end_headers() |
| self.wfile.write(body) |
|
|
| def serve_static(self, path: str) -> None: |
| if path in {"", "/"}: |
| file_path = WEB_DIR / "index.html" |
| else: |
| file_path = (WEB_DIR / path.lstrip("/")).resolve() |
| if not str(file_path).startswith(str(WEB_DIR.resolve())): |
| self.send_error(403) |
| return |
| if not file_path.exists() or file_path.is_dir(): |
| self.send_error(404) |
| return |
| content_type = "text/plain; charset=utf-8" |
| if file_path.suffix == ".html": |
| content_type = "text/html; charset=utf-8" |
| elif file_path.suffix == ".css": |
| content_type = "text/css; charset=utf-8" |
| elif file_path.suffix == ".js": |
| content_type = "application/javascript; charset=utf-8" |
| elif file_path.suffix == ".svg": |
| content_type = "image/svg+xml; charset=utf-8" |
| elif file_path.suffix == ".png": |
| content_type = "image/png" |
| data = file_path.read_bytes() |
| self.send_response(200) |
| self.send_header("Content-Type", content_type) |
| self.send_header("Content-Length", str(len(data))) |
| self.end_headers() |
| self.wfile.write(data) |
|
|
| def log_message(self, fmt: str, *args: Any) -> None: |
| sys.stderr.write("[%s] %s\n" % (self.log_date_time_string(), fmt % args)) |
|
|
|
|
| def main() -> None: |
| port = int(os.getenv("PORT", "8765")) |
| host = os.getenv("HOST", "127.0.0.1") |
| httpd = ThreadingHTTPServer((host, port), Handler) |
| print(f"ROGLAG demo running at http://{host}:{port}") |
| print(f"Loaded {len(INDEX.rows)} records from {DATA_FILE}") |
| print(f"OpenRouter: {'ready' if os.getenv('OPENROUTER_API_KEY') else 'not configured'}") |
| print( |
| "Vector index: " |
| f"{'ready' if INDEX.vector_index.ready else 'not built'} " |
| f"({INDEX.vector_index.count} records, {INDEX.vector_index.dimensions} dims)" |
| ) |
| httpd.serve_forever() |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|