File size: 9,356 Bytes
cf08e63
e0dbc76
 
 
 
cf08e63
 
 
e0dbc76
 
 
 
cf08e63
 
 
e0dbc76
 
cf08e63
 
e0dbc76
 
cf08e63
e0dbc76
 
 
 
 
cf08e63
e0dbc76
 
 
 
cf08e63
e0dbc76
 
 
 
 
 
 
 
 
cf08e63
e0dbc76
cf08e63
 
 
 
 
 
 
 
e0dbc76
cf08e63
e0dbc76
 
 
 
 
 
cf08e63
e0dbc76
cf08e63
 
e0dbc76
 
 
 
cf08e63
 
 
 
e0dbc76
 
cf08e63
e0dbc76
 
 
cf08e63
 
 
 
e0dbc76
 
 
 
 
 
 
 
 
cf08e63
 
e0dbc76
cf08e63
 
e0dbc76
 
 
 
cf08e63
 
e0dbc76
 
 
 
 
 
 
cf08e63
e0dbc76
 
 
 
 
cf08e63
e0dbc76
 
 
 
 
 
cf08e63
e0dbc76
cf08e63
 
e0dbc76
cf08e63
e0dbc76
cf08e63
e0dbc76
cf08e63
e0dbc76
 
 
cf08e63
 
e0dbc76
cf08e63
e0dbc76
cf08e63
e0dbc76
 
cf08e63
 
e0dbc76
cf08e63
 
e0dbc76
 
 
 
cf08e63
 
 
e0dbc76
cf08e63
 
e0dbc76
 
cf08e63
e0dbc76
cf08e63
e0dbc76
 
cf08e63
 
e0dbc76
cf08e63
 
e0dbc76
 
 
 
cf08e63
e0dbc76
cf08e63
 
e0dbc76
cf08e63
e0dbc76
cf08e63
e0dbc76
 
 
 
cf08e63
 
e0dbc76
cf08e63
e0dbc76
 
 
 
cf08e63
 
e0dbc76
 
 
 
 
 
 
 
 
 
 
 
 
 
cf08e63
e0dbc76
 
 
cf08e63
e0dbc76
 
 
 
 
 
cf08e63
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
# file: train_and_generate.py
import os
import math
import torch
import torch.nn as nn
import torch.nn.functional as F
from torch.utils.data import DataLoader
from architecture import TransformerBlock
from dataset import build_or_load_tokenizer, AutoregressiveLogDataset


class LogSentryLM(nn.Module):
    """Decoder-only (GPT-style) LM. Defaults are deliberately tiny (~2M params)
    so it trains in under a minute on a laptop GPU."""

    def __init__(self, vocab_size, d_model=128, n_heads=4, n_layers=3, max_seq_len=64):
        super().__init__()
        # max_seq_len is the context window. It's fixed up front because positions
        # are LEARNED embeddings (one vector per slot), not computed on the fly.
        self.max_seq_len = max_seq_len
        self.token_embeddings = nn.Embedding(vocab_size, d_model)
        self.position_embeddings = nn.Embedding(max_seq_len, d_model)  # attention has no order sense on its own

        self.blocks = nn.ModuleList([
            TransformerBlock(d_model=d_model, n_heads=n_heads) for _ in range(n_layers)
        ])
        self.ln_final = nn.LayerNorm(d_model)
        self.lm_head = nn.Linear(d_model, vocab_size, bias=False)  # project back to vocab logits

    def forward(self, idx):
        b, s = idx.shape
        positions = torch.arange(0, s, device=idx.device).unsqueeze(0)
        # token identity + position — the model needs both
        x = self.token_embeddings(idx) + self.position_embeddings(positions)

        for block in self.blocks:
            x = block(x, is_causal=True)

        return self.lm_head(self.ln_final(x))

    @torch.no_grad()
    def generate(self, idx, max_new_tokens, temperature=0.7):
        """Autoregressive sampling — predict one token, append, repeat.

        temperature < 1 sharpens toward the likely token (safe/repetitive),
        > 1 flattens it (more diverse, more mistakes).
        """
        self.eval()
        for _ in range(max_new_tokens):
            idx_cond = idx[:, -self.max_seq_len:]  # keep only what fits the context window
            logits = self(idx_cond)
            logits = logits[:, -1, :] / temperature  # only the last position predicts the next token
            probs = torch.softmax(logits, dim=-1)
            next_token = torch.multinomial(probs, num_samples=1)  # sample rather than argmax for variety
            idx = torch.cat((idx, next_token), dim=1)
        return idx


@torch.no_grad()
def score_line(model, tokenizer, text, device, max_seq_len=128):
    """Anomaly score for one line = average next-token loss.

    The model learned normal logs, so a line it predicts well scores LOW and a
    surprising line scores HIGH. Returns (loss, perplexity); None if too short.
    """
    model.eval()
    ids = tokenizer.encode(text).ids
    if len(ids) < 2:
        return None  # need at least one (context -> target) pair
    ids = ids[: max_seq_len + 1]
    x = torch.tensor([ids[:-1]], dtype=torch.long, device=device)
    y = torch.tensor([ids[1:]], dtype=torch.long, device=device)
    logits = model(x)
    loss = F.cross_entropy(logits.view(-1, logits.size(-1)), y.view(-1))
    return loss.item(), math.exp(loss.item())  # perplexity is the intuitive scale


def fit_anomaly_threshold(model, tokenizer, lines, device, max_seq_len=128, k=2.0):
    """Derive the normal-vs-anomalous cutoff from the corpus itself.

    Most lines are normal, so threshold = mean + k*std of the scores. Lower k =
    more sensitive (more flags/false positives); higher k = stricter.
    """
    scored = []
    for line in lines:
        result = score_line(model, tokenizer, line, device, max_seq_len)
        if result is not None:
            loss, ppl = result
            scored.append((loss, ppl, line))

    losses = [s[0] for s in scored]
    mean = sum(losses) / len(losses)
    var = sum((l - mean) ** 2 for l in losses) / len(losses)
    std = math.sqrt(var)
    threshold = mean + k * std
    scored.sort(key=lambda s: s[0], reverse=True)  # most anomalous first
    return threshold, mean, std, scored


def generate_mock_logs(file_path):
    """Fallback synthetic corpus. Lines are duplicated so the tiny model has an
    obvious pattern to latch onto if the real logs are missing."""
    os.makedirs(os.path.dirname(file_path), exist_ok=True)
    mock_data = """[ERROR] nginx failed. Fix: run systemctl restart nginx
[INFO] database connection secure.
[ERROR] out of memory. Fix: upgrade ram capacity
[ERROR] nginx failed. Fix: run systemctl restart nginx
[INFO] disk space clean.
[ERROR] out of memory. Fix: upgrade ram capacity
""" * 50
    with open(file_path, "w") as f:
        f.write(mock_data)


if __name__ == "__main__":
    # Prefer GPU: cuda -> apple mps -> cpu
    if torch.cuda.is_available():
        device = "cuda"
    elif torch.backends.mps.is_available():
        device = "mps"
    else:
        device = "cpu"
    print(f"[SETUP] Using device: {device}")

    LOG_FILE = "data/raw_logs.txt"
    TOKENIZER_FILE = "tokenizer/log_tokenizer.json"

    # --- Data ---
    if not os.path.exists(LOG_FILE):
        print("[DATA] raw_logs.txt not found — generating synthetic mock logs as fallback.")
        generate_mock_logs(LOG_FILE)
    tokenizer = build_or_load_tokenizer(LOG_FILE, TOKENIZER_FILE, vocab_size=10000)  # ceiling; small corpus fills ~5.5k

    max_len = 128
    dataset = AutoregressiveLogDataset(LOG_FILE, tokenizer, max_seq_len=max_len, stride=64)
    dataloader = DataLoader(dataset, batch_size=64, shuffle=True)  # bigger batch = better GPU use
    print(f"[DATA] {len(dataset)} windows | {len(dataloader)} batches/epoch")

    # --- Model ---
    model = LogSentryLM(vocab_size=tokenizer.get_vocab_size(), max_seq_len=max_len).to(device)
    optimizer = torch.optim.AdamW(model.parameters(), lr=1e-3)  # 1e-3 is fine at this size; drop to 3e-4 if it diverges
    loss_criterion = nn.CrossEntropyLoss()

    # --- Train ---
    EPOCHS = 5
    n_batches = len(dataloader)
    print("[TRAIN] Starting pre-training pass...", flush=True)
    model.train()
    for epoch in range(EPOCHS):
        total_loss = 0
        for step, (x, y) in enumerate(dataloader, start=1):
            x, y = x.to(device), y.to(device)
            optimizer.zero_grad()
            logits = model(x)
            # flatten (B, T, vocab) -> (B*T, vocab) so loss covers every position
            loss = loss_criterion(logits.view(-1, logits.size(-1)), y.view(-1))
            loss.backward()
            optimizer.step()
            total_loss += loss.item()

            # flush so progress shows even when piped to a file (stdout buffering)
            if step % 20 == 0 or step == n_batches:
                print(f"[TRAIN] Epoch {epoch+1}/{EPOCHS} | batch {step}/{n_batches} "
                      f"| loss {loss.item():.4f}", flush=True)

        print(f"[TRAIN] Epoch {epoch+1}/{EPOCHS} done | avg CE loss "
              f"{total_loss/n_batches:.4f}", flush=True)

    # --- Generate (quick sanity check) ---
    prompt_text = "Invalid user"  # a phrase that actually appears in the corpus
    prompt_tokens = tokenizer.encode(prompt_text).ids
    input_tensor = torch.tensor([prompt_tokens], dtype=torch.long, device=device)

    generated_ids = model.generate(input_tensor, max_new_tokens=6, temperature=0.5)
    decoded_output = tokenizer.decode(generated_ids[0].tolist())

    print(f"\n[GEN] Seed  : {prompt_text}")
    print(f"[GEN] Output: {decoded_output}")

    # --- Anomaly detection ---
    with open(LOG_FILE, "r") as f:
        corpus_lines = [ln.strip() for ln in f if ln.strip()]

    threshold, mean, std, scored = fit_anomaly_threshold(
        model, tokenizer, corpus_lines, device, max_seq_len=max_len, k=2.0
    )
    print(f"\n[ANOMALY] Score stats: mean={mean:.3f} std={std:.3f} | "
          f"threshold (mean+2*std)={threshold:.3f}", flush=True)

    print("[ANOMALY] Top 5 most anomalous lines in the corpus:")
    for loss, ppl, line in scored[:5]:
        flag = "ANOMALY" if loss > threshold else "normal "
        print(f"  [{flag}] loss={loss:.3f} ppl={ppl:8.1f} | {line[:90]}")

    # A couple of real lines + one injected off-distribution line (should score huge)
    print("[ANOMALY] Predicting on new lines:")
    test_lines = [
        "Dec 10 06:55:46 LabSZ sshd[24200]: Invalid user webmaster from 173.234.31.186",
        "Failed password for root from 112.95.230.3 port 49204 ssh2",
        "kjshdf!! TOTALLY RANDOM gibberish $$$ 999 not-a-real-log ~~~~",
    ]
    for line in test_lines:
        result = score_line(model, tokenizer, line, device, max_seq_len=max_len)
        if result is None:
            print(f"  [skipped: too short] {line}")
            continue
        loss, ppl = result
        flag = "ANOMALY" if loss > threshold else "normal "
        print(f"  [{flag}] loss={loss:.3f} ppl={ppl:8.1f} | {line[:90]}")

    # --- Save weights + threshold so detect_anomalies.py can run standalone ---
    MODEL_FILE = "model/logsentry_lm.pt"
    os.makedirs(os.path.dirname(MODEL_FILE), exist_ok=True)
    torch.save({
        "model_state": model.state_dict(),
        "vocab_size": tokenizer.get_vocab_size(),
        "max_seq_len": max_len,
        "threshold": threshold,
        "score_mean": mean,
        "score_std": std,
    }, MODEL_FILE)
    print(f"\n[SAVE] Saved model + anomaly threshold to {MODEL_FILE}")