File size: 6,887 Bytes
c4f5819
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
"""Rule-based security log classifier.

Ported from the original demo so detection works fully offline (no API key,
no model download). Given a log row it assigns a category, severity hint,
MITRE technique, risk score and human-readable reasons.
"""
from __future__ import annotations

import re
from typing import Any

# Single source of truth for category metadata (label / MITRE / risk / runbook).
CATEGORY_META: dict[str, dict[str, Any]] = {
    "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",
    },
}

# Severity derived from category when the source row does not provide one.
CATEGORY_DEFAULT_SEVERITY = {
    "benign": "info",
    "brute_force": "high",
    "port_scan": "medium",
    "web_attack": "high",
    "priv_esc": "critical",
    "data_exfil": "critical",
    "c2_beacon": "critical",
}

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 detect_rule(row: dict[str, Any]) -> dict[str, Any]:
    """Classify a single log row purely from its message + fields.

    Returns the detected category and explanation. Does NOT trust an incoming
    `category` label — that is what we are detecting — but compares against it
    when present so callers can measure accuracy.
    """
    message = str(row.get("message", "") or "")
    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]
    severity = row.get("severity") or CATEGORY_DEFAULT_SEVERITY[category]
    expected = row.get("category")
    return {
        "category": category,
        "label": meta["label"],
        "risk_score": meta["risk"],
        "severity": severity,
        "mitre": meta["mitre"],
        "reasons": reasons or ["ไม่พบ pattern เสี่ยงตาม rule พื้นฐาน"],
        "matches_label": (expected == category) if expected else None,
    }


def row_text(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)