Local LLM Series, Part 3: Keeping Reference Data Current

October 24, 2026

Series: Overview · Part 1, Setup guide · Part 2, Deployment record · Part 3, Keeping reference data current (this page) · Part 4, Testing on a phishing email. Each part can be read on its own.

Built with AI, on purpose. I used Claude (Sonnet 5.5, from Anthropic) to help plan this build, troubleshoot it, write the scripts and draft this part. I am not presenting the scripts as my own coding. What I am showing is the project itself: choosing the goal, running it on my own hardware, testing the results, and using AI tools to move faster on work I am still learning. See "How this was made" near the end of this page.

Part 3 of a series on running a private language model for SOC work. Part 1 is the setup guide and Part 2 is the deployment record. This installment keeps the model's reference material current without sending a single query to the internet.

Update, later on October 24, 2026: the refresh script now also downloads Sigma rules, Atomic Red Team tests, LOLBAS and LOLDrivers, creates a folder for your own runbooks, and a local web page can use the whole library. The new material is in the section "Four more sources, a runbooks folder and a web page", and the code listing below is the current version.

The problem: a 14B model's knowledge is frozen

Qwen3-14B answers from what it learned before its training cutoff, which appears to be around 2023 judging by its answers (an observation, not a figure from the model card). Security moves faster than that. MITRE released ATT&CK v20 on October 13, 2026, and the CISA catalog of known exploited vulnerabilities (KEV) gained entries as recently as the day before this was written. The model cannot know any of it, and in Part 2 it already gave the wrong ATT&CK ID for Kerberoasting.

Four ways to fix stale knowledge were on the table:

Option What it fixes Cost or risk
Web search through a local MCP server Fresh answers on any topic Every query leaves the machine, including anything the model puts in it
Scheduled download of public data (this installment) Fresh reference facts, as of the last refresh Covers only the sources you choose; stays fresh only if you refresh
A newer model A later cutoff Goes stale the same way; a cutoff date still has to be checked on each model card
Fine-tuning Teaches new facts Impractical on a 16 GB card and tends to produce confident errors

The requirement for this series is that case data stays local. That rules out the first option for now and points to the second.

The design: download public files, never send queries

A search query reveals what you are investigating. A download of a fixed public file reveals nothing, because everyone who fetches it sends the same request. So the refresh script pulls two public data sets in bulk, turns them into short text records, and leaves all questions to the local model reading local files.

The design rules, each one a lesson from earlier parts:

  1. One record per topic. Part 2 showed a wrong ATT&CK ID passing the identifier check because a neighboring technique sat in the same excerpt. Every technique and every CVE now gets its own record, with its ID and name on the first line, so a citation points to one thing.
  2. Include detection guidance. ATT&CK v20 carries detection strategies and analytics, such as DET0157 for Kerberoasting, which names Windows Event ID 4769 and the RC4 encryption type to look for. That answers the Event ID 4769 question left open in Part 2, from a source newer than the model.
  3. Refuse bad downloads. The script checks that each file parses and has a plausible size before using it. If anything fails, the existing reference files stay as they were, so a failed refresh never leaves you with an empty library.
  4. Keep an audit trail. The raw downloads are cached, and refresh_info.json records the source, retrieval time, version and SHA-256 of each file. Every record also carries its source version, so an answer can be traced to the data it came from.
  5. Allow an offline route. --attack-file and --kev-file accept copies you downloaded elsewhere, for machines that should not reach the internet at all.

The refresh script

refresh_refs.py uses only Python's standard library, so it needs no new packages. Save it as C:\soc-llm\refresh_refs.py:

Show the full code: refresh_refs.py (662 lines)
"""Refresh the local reference library from public bulk data.

Downloads these public data sets and writes short, one-record-per-topic text
files that ask_grounded.py and ask_grounded_web.py can use as references:

    attack     MITRE ATT&CK Enterprise (techniques, detection guidance)
    kev        CISA Known Exploited Vulnerabilities
    sigma      SigmaHQ detection rules (release bundle)
    atomic     Atomic Red Team tests, as listed in its index
    lolbas     LOLBAS: Windows binaries and scripts attackers abuse
    loldrivers LOLDrivers: vulnerable and malicious Windows drivers (with hashes)

Output, in refs\\:

    attack_techniques.txt  attack_detections.txt  cisa_kev.txt  sigma_rules.txt
    atomic_tests.txt  lolbas.txt  loldrivers.txt  refresh_info.json  _downloads\\

Usage (PowerShell, .venv active):

    python -m pip install pyyaml              # once; Sigma, Atomic and LOLBAS need it
    python refresh_refs.py                    # download everything, rebuild refs\\
    python refresh_refs.py --only kev sigma   # refresh just those sources
    python refresh_refs.py --offline          # rebuild from the files already in refs\\_downloads
    python refresh_refs.py --file kev=C:\\transfer\\kev.json --file attack=C:\\transfer\\attack.json

Only public, fixed files are downloaded. No question, case detail or IOC is ever
sent anywhere. If a download fails or looks truncated, that source's existing
reference file is left untouched and the others still refresh.

It also creates a runbooks\\ folder (once) for your own procedures and case notes.
"""
import argparse
import hashlib
import io
import json
import os
import re
import sys
import zipfile
from datetime import datetime, timezone
from pathlib import Path
from urllib.request import Request, urlopen

ATTACK_URLS = [
    "https://raw.githubusercontent.com/mitre-attack/attack-stix-data/master/enterprise-attack/enterprise-attack.json",
]
KEV_URLS = [
    "https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json",
    "https://raw.githubusercontent.com/cisagov/kev-data/develop/known_exploited_vulnerabilities.json",
]
SIGMA_URLS = [
    "https://github.com/SigmaHQ/sigma/releases/latest/download/sigma_all_rules.zip",
]
ATOMIC_URLS = [
    "https://raw.githubusercontent.com/redcanaryco/atomic-red-team/master/atomics/Indexes/index.yaml",
]
LOLBAS_URLS = [  # the project's JSON API first, then the GitHub bundle of its YAML files
    "https://lolbas-project.github.io/api/lolbas.json",
    "https://github.com/LOLBAS-Project/LOLBAS/archive/refs/heads/master.zip",
]
LOLDRIVERS_URLS = [
    "https://raw.githubusercontent.com/magicsword-io/LOLDrivers/main/loldrivers.io/content/api/drivers.json",
    "https://www.loldrivers.io/api/drivers.json",
]

SEP = "====="            # record separator that ask_grounded.py understands
MAX_RECORD = 1400        # stays under ask_grounded.py's 1,500-character chunk size
MIN_TECHNIQUES = 400     # sanity floors: a smaller result means a bad download
MIN_KEV = 1000
MIN_SIGMA = 1000
MIN_ATOMIC = 500
MIN_LOLBAS = 100
MIN_LOLDRIVERS = 200
USER_AGENT = "refresh_refs/2.0 (local SOC reference refresh)"


# ---------- helpers ----------

def clean_text(text):
    """Strip citation markers, markdown links and HTML tags; collapse whitespace."""
    text = re.sub(r"\(Citation:[^)]*\)", "", str(text or ""))
    text = re.sub(r"\[([^\]]+)\]\([^)]*\)", r"\1", text)
    text = re.sub(r"</?[a-zA-Z][^>]*>", "", text)
    text = re.sub(r"[\x00-\x08\x0b-\x1f\x7f]", " ", text)
    return re.sub(r"\s+", " ", text).strip()


def trim(text, limit):
    """Cut text to at most `limit` characters, preferring a sentence boundary."""
    if len(text) <= limit:
        return text
    cut = text[:limit]
    end = max(cut.rfind(". "), cut.rfind("; "))
    if end > limit * 0.5:
        return cut[: end + 1]
    return cut.rsplit(" ", 1)[0] + " ..."


def write_atomic(path, text):
    tmp = path.with_suffix(path.suffix + ".tmp")
    tmp.write_text(text, encoding="utf-8")
    os.replace(tmp, path)


def v_json(data):
    json.loads(data)


def v_zip(data):
    if not zipfile.is_zipfile(io.BytesIO(data)):
        raise ValueError("not a zip file")


def v_yaml(data):
    head = data[:200].lstrip().lower()
    if head.startswith(b"<!doctype") or head.startswith(b"<html"):
        raise ValueError("got a web page instead of the data file")
    if len(data) < 10000:
        raise ValueError("file is too small to be the full index")


def v_json_or_zip(data):
    try:
        v_zip(data)
    except ValueError:
        v_json(data)


def download(urls, dest, validate):
    """Try each URL in order; save the first that downloads and validates. Returns (url, sha256)."""
    errors = []
    for url in urls:
        try:
            req = Request(url, headers={"User-Agent": USER_AGENT})
            with urlopen(req, timeout=300) as resp:
                data = resp.read()
            validate(data)  # must look right before it is kept
            dest.write_bytes(data)
            return url, hashlib.sha256(data).hexdigest()
        except Exception as err:  # network, HTTP, or bad content: try the next source
            errors.append(f"  {url}: {err}")
    raise RuntimeError("All download sources failed:\n" + "\n".join(errors))


def load_local(path, dest, validate):
    data = Path(path).read_bytes()
    validate(data)
    dest.write_bytes(data)
    return str(Path(path)), hashlib.sha256(data).hexdigest()


def pack(header, lines, limit=MAX_RECORD):
    """Pack lines into records no longer than `limit`, repeating the header."""
    records, body = [], []
    room = limit - len(header) - 12
    size = 0
    for line in lines:
        line = trim(line, room)
        if body and size + len(line) + 1 > room:
            records.append(body)
            body, size = [], 0
        body.append(line)
        size += len(line) + 1
    if body:
        records.append(body)
    out = []
    for n, part in enumerate(records, 1):
        title = header if len(records) == 1 else header.replace("\n", f" (part {n} of {len(records)})\n", 1)
        out.append(title + "\n" + "\n".join(part))
    return out


def need_yaml():
    try:
        import yaml
    except ImportError:
        raise RuntimeError("PyYAML is not installed. Run:  python -m pip install pyyaml")
    return yaml, getattr(yaml, "CSafeLoader", yaml.SafeLoader)


def short(value, limit=70):
    return trim(clean_text(value), limit)


# ---------- ATT&CK ----------

def attack_external_id(obj):
    for ref in obj.get("external_references", []):
        if ref.get("source_name") == "mitre-attack" and ref.get("external_id"):
            return ref["external_id"], ref.get("url", "")
    return None, ""


def build_attack(bundle):
    objs = bundle["objects"]
    by_id = {o["id"]: o for o in objs}
    version = next((o.get("x_mitre_version") for o in objs if o.get("type") == "x-mitre-collection"), "unknown")

    techniques = {}
    for o in objs:
        if o.get("type") != "attack-pattern" or o.get("revoked") or o.get("x_mitre_deprecated"):
            continue
        tid, url = attack_external_id(o)
        if tid:
            techniques[o["id"]] = (tid, url, o)
    if len(techniques) < MIN_TECHNIQUES:
        raise RuntimeError(f"Only {len(techniques)} techniques found; expected at least {MIN_TECHNIQUES}.")
    names = {tid: o["name"] for tid, _, o in techniques.values()}

    tech_records = []
    for tid, url, o in sorted(techniques.values(), key=lambda t: t[0]):
        parent = ""
        if o.get("x_mitre_is_subtechnique") and "." in tid:
            pid = tid.split(".")[0]
            parent = f" Sub-technique of {pid} {names.get(pid, '')}.".rstrip()
        tactics = ", ".join(p["phase_name"].replace("-", " ").title() for p in o.get("kill_chain_phases", []))
        platforms = ", ".join(o.get("x_mitre_platforms", []))
        header = f"{tid} {o['name']}\nMITRE ATT&CK Enterprise v{version} technique ID {tid}.{parent}"
        lines = []
        if tactics:
            lines.append(f"Tactics: {tactics}.")
        if platforms:
            lines.append(f"Platforms: {platforms}.")
        lines.append(f"URL: {url}")
        lines.append("Description: " + clean_text(o.get("description", "")))
        tech_records.extend(pack(header, lines))

    det_records = []
    for rel in objs:
        if rel.get("type") != "relationship" or rel.get("relationship_type") != "detects":
            continue
        strat, tech = by_id.get(rel["source_ref"]), techniques.get(rel["target_ref"])
        if not strat or not tech or strat.get("type") != "x-mitre-detection-strategy":
            continue
        tid, _, t = tech
        did, _ = attack_external_id(strat)
        header = (f"{tid} {t['name']} - detection guidance ({did})\n"
                  f"MITRE ATT&CK Enterprise v{version} detection strategy: {clean_text(strat['name'])}.")
        lines = []
        for aref in strat.get("x_mitre_analytic_refs", []):
            an = by_id.get(aref)
            if not an:
                continue
            logs = "; ".join(
                f"{s.get('name', '')} {s.get('channel', '')}".strip()
                for s in an.get("x_mitre_log_source_references", [])
            )
            plat = ", ".join(an.get("x_mitre_platforms", []))
            lines.append(f"{an['name']} ({plat}). Log sources: {logs}. {clean_text(an.get('description', ''))}")
        if lines:
            det_records.extend(pack(header, lines))

    return version, tech_records, det_records, len(techniques)


# ---------- CISA KEV ----------

def build_kev(catalog):
    vulns = catalog.get("vulnerabilities", [])
    if len(vulns) < MIN_KEV:
        raise RuntimeError(f"Only {len(vulns)} KEV entries found; expected at least {MIN_KEV}.")
    version = catalog.get("catalogVersion", "unknown")
    released = catalog.get("dateReleased", "unknown")
    records = []
    for v in sorted(vulns, key=lambda x: (x.get("dateAdded", ""), x.get("cveID", "")), reverse=True):
        header = f"{v['cveID']} {v.get('vendorProject', '')} {v.get('product', '')}: {clean_text(v.get('vulnerabilityName', ''))}"
        cwes = ", ".join(v.get("cwes", []) or [])
        lines = [
            f"CISA Known Exploited Vulnerabilities catalog (version {version}). "
            f"Date added: {v.get('dateAdded', '')}. Remediation due: {v.get('dueDate', '')}. "
            f"Known ransomware campaign use: {v.get('knownRansomwareCampaignUse', 'Unknown')}.",
            "Description: " + clean_text(v.get("shortDescription", "")),
            "Required action: " + clean_text(v.get("requiredAction", "")),
        ]
        if cwes:
            lines.append(f"CWE: {cwes}.")
        if v.get("notes"):
            lines.append("Notes: " + clean_text(v["notes"]))
        records.extend(pack(header, lines))  # long entries split into numbered parts
    return version, released, records, len(vulns)


# ---------- Sigma ----------

def attack_tags(tags):
    """Split Sigma tags into ATT&CK technique IDs, tactics, CVEs and other tags."""
    techniques, tactics, cves, other = [], [], [], []
    for tag in tags or []:
        tag = str(tag).lower()
        m = re.fullmatch(r"attack\.(t\d{4}(?:\.\d{3})?)", tag)
        if m:
            techniques.append(m.group(1).upper())
        elif tag.startswith("attack."):
            tactics.append(tag[7:].replace("-", " ").replace("_", " ").title())
        elif tag.startswith("cve."):
            cves.append("CVE-" + tag[4:])
        else:
            other.append(tag)
    return techniques, tactics, cves, other


def render_detection(det, limit=650):
    """Condense a Sigma detection block: which fields are matched against which values."""
    parts = []
    for name, body in det.items():
        if name in ("condition", "timeframe"):
            continue
        items = body if isinstance(body, list) else [body]
        pieces = []
        for item in items:
            if isinstance(item, dict):
                for field, val in item.items():
                    vals = val if isinstance(val, list) else [val]
                    shown = " | ".join(short(v, 60) for v in vals[:8])
                    more = f" (+{len(vals) - 8} more)" if len(vals) > 8 else ""
                    pieces.append(f"{field}: {shown}{more}")
            else:
                pieces.append(short(item, 80))
        parts.append(f"{name} = " + "; ".join(pieces[:12]))
    cond = det.get("condition")
    if cond:
        parts.append("condition: " + (cond if isinstance(cond, str) else " / ".join(map(str, cond))))
    return trim(" || ".join(parts), limit)


def build_sigma(data):
    yaml, loader = need_yaml()
    records, rules, skipped = [], 0, 0
    with zipfile.ZipFile(io.BytesIO(data)) as z:
        for name in sorted(z.namelist()):
            if not name.lower().endswith((".yml", ".yaml")):
                continue
            try:
                docs = list(yaml.load_all(z.read(name).decode("utf-8", errors="replace"), Loader=loader))
            except Exception:
                skipped += 1
                continue
            rule = next((d for d in docs if isinstance(d, dict) and d.get("title") and d.get("detection")), None)
            if not rule or str(rule.get("status", "")).lower() in ("deprecated", "unsupported"):
                skipped += 1
                continue
            rules += 1
            techniques, tactics, cves, other = attack_tags(rule.get("tags"))
            ls = rule.get("logsource") or {}
            logsource = ", ".join(f"{k} {v}" for k, v in ls.items() if v) or "not stated"
            header = (f"{clean_text(rule['title'])} (Sigma rule)\n"
                      f"Sigma detection rule. Status: {rule.get('status', '?')}. Level: {rule.get('level', '?')}. "
                      f"Rule file: {name}.")
            lines = []
            if techniques or tactics:
                lines.append(f"ATT&CK techniques: {', '.join(techniques) or 'none tagged'}. Tactics: {', '.join(tactics) or 'none tagged'}.")
            if cves:
                lines.append("CVEs: " + ", ".join(cves) + ".")
            lines.append(f"Log source: {logsource}.")
            lines.append("Description: " + clean_text(rule.get("description", "")))
            lines.append("Detection: " + render_detection(rule["detection"]))
            fps = rule.get("falsepositives")
            if fps:
                fps = fps if isinstance(fps, list) else [fps]
                lines.append("False positives: " + "; ".join(short(x, 90) for x in fps[:4]) + ".")
            records.extend(pack(header, lines))
    if rules < MIN_SIGMA:
        raise RuntimeError(f"Only {rules} Sigma rules found; expected at least {MIN_SIGMA}.")
    return records, rules, skipped


# ---------- Atomic Red Team ----------

def build_atomic(data):
    yaml, loader = need_yaml()
    index = yaml.load(data.decode("utf-8", errors="replace"), Loader=loader)
    seen, records, tactics_of = set(), [], {}
    for tactic, techs in index.items():
        for tid in techs:
            tactics_of.setdefault(tid, []).append(tactic.replace("-", " ").title())
    for tactic, techs in index.items():
        for tid, body in techs.items():
            tname = clean_text((body.get("technique") or {}).get("name", ""))
            for test in body.get("atomic_tests") or []:
                key = test.get("auto_generated_guid") or (tid, test.get("name"))
                if key in seen:
                    continue
                seen.add(key)
                ex = test.get("executor") or {}
                cmd = clean_text(ex.get("command") or ex.get("steps") or "")
                header = (f"{tid} {tname} - Atomic Red Team test: {clean_text(test.get('name', ''))}\n"
                          f"Atomic Red Team test for ATT&CK {tid} {tname}. Tactics: {', '.join(tactics_of[tid])}.")
                lines = [
                    f"Platforms: {', '.join(test.get('supported_platforms', []))}. Executor: {ex.get('name', '?')}"
                    + (". Needs elevation." if ex.get("elevation_required") else "."),
                    "Description: " + clean_text(test.get("description", "")),
                ]
                if cmd:
                    lines.append("Test command (what this looks like on an endpoint): " + trim(cmd, 450))
                records.extend(pack(header, lines))
    if len(seen) < MIN_ATOMIC:
        raise RuntimeError(f"Only {len(seen)} Atomic tests found; expected at least {MIN_ATOMIC}.")
    return records, len(seen)


# ---------- LOLBAS ----------

def lolbas_entries(data):
    """Read LOLBAS entries from the JSON API file or from the GitHub zip of YAML files."""
    if zipfile.is_zipfile(io.BytesIO(data)):
        yaml, loader = need_yaml()
        entries = []
        with zipfile.ZipFile(io.BytesIO(data)) as z:
            for name in sorted(z.namelist()):
                parts = name.split("/")
                if name.lower().endswith(".yml") and "yml" in parts[:3] and "Archive-Old-Version" not in parts:
                    doc = yaml.load(z.read(name).decode("utf-8", errors="replace"), Loader=loader)
                    if isinstance(doc, dict) and doc.get("Name") and doc.get("Commands"):
                        entries.append(doc)
        return entries
    return json.loads(data)


def build_lolbas(data):
    entries = [e for e in lolbas_entries(data) if isinstance(e, dict) and e.get("Name")]
    if len(entries) < MIN_LOLBAS:
        raise RuntimeError(f"Only {len(entries)} LOLBAS entries found; expected at least {MIN_LOLBAS}.")
    records = []
    for e in sorted(entries, key=lambda x: str(x["Name"]).lower()):
        header = (f"{clean_text(e['Name'])} (LOLBAS)\n"
                  f"LOLBAS entry: a legitimate Windows binary, script or library that attackers abuse (living off the land).")
        lines = ["Description: " + clean_text(e.get("Description", ""))]
        paths = [clean_text(p.get("Path", "")) for p in e.get("Full_Path") or [] if isinstance(p, dict)]
        if paths:
            lines.append("Typical paths: " + "; ".join(paths[:4]) + ".")
        for c in e.get("Commands") or []:
            lines.append(
                f"Abuse: {clean_text(c.get('Usecase', ''))} [{clean_text(c.get('Category', ''))}, "
                f"ATT&CK {clean_text(c.get('MitreID', ''))}, privileges {clean_text(c.get('Privileges', ''))}]. "
                f"Command: {clean_text(c.get('Command', ''))}. {clean_text(c.get('Description', ''))}")
        for d in e.get("Detection") or []:
            if isinstance(d, dict):
                lines.append("Detection: " + "; ".join(f"{k} {clean_text(v)}" for k, v in d.items() if v))
        records.extend(pack(header, lines))
    return records, len(entries)


# ---------- LOLDrivers ----------

def build_loldrivers(drivers):
    if not isinstance(drivers, list) or len(drivers) < MIN_LOLDRIVERS:
        raise RuntimeError(f"Only {len(drivers) if isinstance(drivers, list) else 0} LOLDrivers entries; expected at least {MIN_LOLDRIVERS}.")
    records = []
    for d in sorted(drivers, key=lambda x: str((x.get("Tags") or [""])[0]).lower()):
        tags = [clean_text(t) for t in d.get("Tags") or []]
        cmds = d.get("Commands") or {}
        if isinstance(cmds, list):
            cmds = cmds[0] if cmds else {}
        header = (f"{tags[0] if tags else d.get('Id', 'unknown')} (LOLDrivers: {d.get('Category', 'driver')})\n"
                  f"LOLDrivers entry {d.get('Id', '')}: {d.get('Category', '')}. Names: {', '.join(tags)}. "
                  f"ATT&CK {clean_text(d.get('MitreID', ''))}. Verified: {d.get('Verified', '?')}.")
        lines = []
        if cmds.get("Description"):
            lines.append("Description: " + clean_text(cmds["Description"]))
        if cmds.get("Usecase"):
            lines.append("Use case: " + clean_text(cmds["Usecase"]))
        if cmds.get("Command"):
            lines.append("Example command: " + clean_text(cmds["Command"]))
        samples = d.get("KnownVulnerableSamples") or []
        for s in samples[:8]:
            hashes = "; ".join(f"{k} {s[k]}" for k in ("SHA256", "SHA1", "MD5") if s.get(k))
            who = clean_text(s.get("Company") or s.get("Publisher") or "")
            lines.append(f"Sample {clean_text(s.get('Filename', ''))} ({who}, {clean_text(s.get('Signature', ''))}): {hashes}.")
        if len(samples) > 8:
            lines.append(f"{len(samples) - 8} more known samples are listed in the LOLDrivers project.")
        records.extend(pack(header, lines))
    return records, len(drivers)


# ---------- runbooks folder ----------

RUNBOOK_README = """# Runbooks folder

Put your own procedures, escalation rules and past case notes here as .md or .txt files.
The grounded assistant reads this folder together with refs\\ every time it starts
(or when you press "Reload library" on the web page).

Tips for files the assistant can use well:
- One topic per section. Each heading (#, ##, ###) becomes its own searchable record.
- Put the key words in the heading, for example "## Phishing: user clicked a link".
- Write steps as short sentences with names, thresholds and contacts spelled out.
- Do not store passwords, API keys or customer data here. Anything in this folder
  can be sent to the model as reference text.
- Files whose names start with an underscore (like this one) are skipped.
"""

RUNBOOK_TEMPLATE = """# Runbook: <alert or scenario name>

## Scope
When this applies, and what it does not cover.

## Triage
1. First check, and where to look (tool, query, log source).
2. Second check.

## Escalate when
- Condition that moves this to the next tier, and who to contact.

## Containment
- Approved actions and who must approve them.

## Related
ATT&CK technique IDs, Sigma rule names, and past case numbers.
"""


def scaffold_runbooks(folder):
    folder.mkdir(parents=True, exist_ok=True)
    made = False
    for name, text in (("_README.md", RUNBOOK_README), ("_template.md", RUNBOOK_TEMPLATE)):
        path = folder / name
        if not path.exists():
            path.write_text(text, encoding="utf-8")
            made = True
    return made


# ---------- output ----------

def records_text(source_note, records):
    return "\n".join(f"{SEP}\n{r}" for r in [source_note] + records) + "\n"


def job_attack(data, stamp):
    version, tech, det, count = build_attack(json.loads(data))
    files = {
        "attack_techniques.txt": records_text(
            f"SOURCE NOTE: MITRE ATT&CK Enterprise v{version}, retrieved {stamp}. One record per technique.", tech),
        "attack_detections.txt": records_text(
            f"SOURCE NOTE: MITRE ATT&CK Enterprise v{version} detection guidance, retrieved {stamp}.", det),
    }
    info = {"version": version, "techniques": count, "technique_records": len(tech), "detection_records": len(det)}
    return files, info, f"ATT&CK v{version}: {count} techniques, {len(tech)} technique records, {len(det)} detection records."


def job_kev(data, stamp):
    version, released, recs, count = build_kev(json.loads(data))
    files = {"cisa_kev.txt": records_text(
        f"SOURCE NOTE: CISA Known Exploited Vulnerabilities catalog version {version} "
        f"(released {released}), retrieved {stamp}. One record per CVE.", recs)}
    info = {"catalog_version": version, "released": released, "entries": count}
    return files, info, f"CISA KEV {version}: {count} entries (released {released})."


def job_sigma(data, stamp):
    recs, rules, skipped = build_sigma(data)
    files = {"sigma_rules.txt": records_text(
        f"SOURCE NOTE: SigmaHQ rules (release bundle sigma_all_rules.zip), retrieved {stamp}. Rules are licensed "
        f"under the Detection Rule License 1.1. One record per rule; deprecated rules left out.", recs)}
    return files, {"rules": rules, "records": len(recs)}, f"Sigma: {rules} rules, {len(recs)} records ({skipped} files skipped: deprecated or unreadable)."


def job_atomic(data, stamp):
    recs, tests = build_atomic(data)
    files = {"atomic_tests.txt": records_text(
        f"SOURCE NOTE: Atomic Red Team (Red Canary, MIT license) test index, retrieved {stamp}. "
        f"One record per test. These are simulation commands for detection testing.", recs)}
    return files, {"tests": tests, "records": len(recs)}, f"Atomic Red Team: {tests} tests, {len(recs)} records."


def job_lolbas(data, stamp):
    recs, count = build_lolbas(data)
    files = {"lolbas.txt": records_text(
        f"SOURCE NOTE: LOLBAS project (GPL-3.0), retrieved {stamp}. One record per binary, script or library.", recs)}
    return files, {"entries": count, "records": len(recs)}, f"LOLBAS: {count} entries, {len(recs)} records."


def job_loldrivers(data, stamp):
    recs, count = build_loldrivers(json.loads(data))
    files = {"loldrivers.txt": records_text(
        f"SOURCE NOTE: LOLDrivers project (Apache-2.0), retrieved {stamp}. One record per driver, with sample hashes.", recs)}
    return files, {"entries": count, "records": len(recs)}, f"LOLDrivers: {count} drivers, {len(recs)} records."


# name -> (cache file, urls, validator, job, label)
SOURCES = {
    "attack": ("enterprise-attack.json", ATTACK_URLS, v_json, job_attack, "ATT&CK"),
    "kev": ("known_exploited_vulnerabilities.json", KEV_URLS, v_json, job_kev, "CISA KEV"),
    "sigma": ("sigma_all_rules.zip", SIGMA_URLS, v_zip, job_sigma, "Sigma"),
    "atomic": ("atomic_index.yaml", ATOMIC_URLS, v_yaml, job_atomic, "Atomic Red Team"),
    "lolbas": ("lolbas_data", LOLBAS_URLS, v_json_or_zip, job_lolbas, "LOLBAS"),
    "loldrivers": ("loldrivers.json", LOLDRIVERS_URLS, v_json, job_loldrivers, "LOLDrivers"),
}


def main():
    parser = argparse.ArgumentParser(description="Refresh the local reference library from public bulk data.")
    parser.add_argument("--out", default="refs", help="reference folder (default: refs)")
    parser.add_argument("--runbooks", default="runbooks", help="folder for your own runbooks (default: runbooks)")
    parser.add_argument("--only", nargs="+", choices=sorted(SOURCES), metavar="SOURCE",
                        help="refresh just these sources: " + ", ".join(sorted(SOURCES)))
    parser.add_argument("--offline", action="store_true", help="rebuild from the files already in <out>\\_downloads")
    parser.add_argument("--file", action="append", default=[], metavar="SOURCE=PATH",
                        help="use a local copy of a source's data file instead of downloading (repeatable)")
    parser.add_argument("--attack-file", help="same as --file attack=PATH")
    parser.add_argument("--kev-file", help="same as --file kev=PATH")
    args = parser.parse_args()

    local = {}
    for item in args.file:
        name, _, path = item.partition("=")
        if name not in SOURCES or not path:
            parser.error(f"--file expects SOURCE=PATH with SOURCE one of {', '.join(sorted(SOURCES))}")
        local[name] = path
    if args.attack_file:
        local["attack"] = args.attack_file
    if args.kev_file:
        local["kev"] = args.kev_file

    out = Path(args.out)
    cache = out / "_downloads"
    cache.mkdir(parents=True, exist_ok=True)
    stamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
    info_path = out / "refresh_info.json"
    info = json.loads(info_path.read_text(encoding="utf-8")) if info_path.exists() else {}
    failures = []

    for key in (args.only or list(SOURCES)):
        cache_name, urls, validate, job, label = SOURCES[key]
        dest = cache / cache_name
        try:
            if args.offline:
                if not dest.exists():
                    raise RuntimeError(f"--offline needs {dest}, which does not exist.")
                data = dest.read_bytes()
                source = info.get(key, {}).get("source", "cached file")
                digest = hashlib.sha256(data).hexdigest()
            else:
                if key in local:
                    source, digest = load_local(local[key], dest, validate)
                else:
                    print(f"Downloading {label} ...", flush=True)
                    source, digest = download(urls, dest, validate)
                data = dest.read_bytes()
            files, details, message = job(data, stamp)
            for fname, text in files.items():
                write_atomic(out / fname, text)
            info[key] = {"retrieved": stamp, "source": source, "sha256": digest, **details}
            print(message)
        except Exception as err:
            failures.append(f"{label}: {err}")

    if info:
        write_atomic(info_path, json.dumps(info, indent=2) + "\n")
    if scaffold_runbooks(Path(args.runbooks)):
        print(f"\nCreated the {args.runbooks}\\ folder for your own runbooks (see _README.md inside).")
    if failures:
        print("\nNot refreshed (existing files, if any, were left as they were):")
        for f in failures:
            print("  " + f)
        sys.exit(1)
    print(f"\nReference files are in {out}. Example:")
    print('  python ask_grounded.py -r "refs\\*.txt" -r "runbooks\\*.md" -q "What is the MITRE ATT&CK ID for Kerberoasting?"')


if __name__ == "__main__":
    main()

Run it from the (.venv) window. It downloads about 100 MB the first time (ATT&CK is 55 MB, LOLDrivers 33 MB, and the other four sources under 10 MB each) and finishes in minutes. PyYAML is a one-time install that the Sigma, Atomic Red Team and LOLBAS sources need:

cd C:\soc-llm
python -m pip install pyyaml
python refresh_refs.py

It writes seven reference files and a manifest into refs\. Every record starts with the technique or CVE it describes, so a citation reads like T1558.003 Kerberoasting.

Upgrading ask_grounded.py. A library of about 3,750 records breaks the first version's retrieval, which only counted shared words and sorted ties by file order. Three changes fix that. The full updated script is in Part 1, Step 7.

  • Files with ===== lines between records are split into one excerpt per record, so neighbors never blend together.
  • Rare words now count more than common ones, and a match in a record's first line counts double. A record titled "Kerberoasting" now outranks records that only mention it.
  • Excerpts scoring below half of the best match are dropped, though the top three are always kept. --min-ratio 0 turns the cutoff off.

IDs such as T1558.003 and CVE-2023-33538 are matched whole, and also by their parts.

Results

The script was first run in a cloud test environment on October 24, 2026, and has since been run on the author's own PC. The counts below are from the cloud test run. It took about four seconds and produced these files:

File Source Records Size
attack_techniques.txt ATT&CK Enterprise v20.0 917, from 706 techniques and sub-techniques 0.9 MB
attack_detections.txt ATT&CK Enterprise v20.0 detection strategies 964 0.9 MB
cisa_kev.txt CISA KEV catalog 2026.10.23 1,867, from 1,756 vulnerabilities 1.4 MB

Some techniques and CVEs have more than one record because long entries are split, with the header repeated and marked "part 1 of 2". No record exceeds 1,403 characters.

The Kerberoasting record the model needs now reads, in part: "T1558.003 Kerberoasting ... Tactics: Credential Access. Platforms: Windows." Its detection record names Windows Event ID 4769 with RC4 encryption type 0x17, the item that stayed unanswered at the end of Part 2.

Retrieval was tested on six questions by checking which records would be sent to the model:

Question Best match Excerpts sent
ATT&CK ID for Kerberoasting T1558.003 Kerberoasting 5
What Event ID 4769 indicates for Kerberoasting T1558.003 detection record (DET0157) 9
Tell me about CVE-2023-33538 The CVE-2023-33538 record, TP-Link routers 3
Known exploited vulnerabilities in Arista VeloCloud Orchestrator The three Arista VeloCloud Orchestrator records 6
What technique is AS-REP Roasting T1558.004 AS-REP Roasting 3
How adversaries abuse pass-the-hash T1550.002 Pass the Hash 7

The right record was among the excerpts every time. In the first test the Silver Ticket record (T1558.002) was also sent. That is the case that fooled the identifier check in Part 2, and it now carries its own technique name on the first line, so the context the script prints shows which technique an ID belongs to.

What these tests do not show: they test which excerpts are sent, with a stand-in for the model. At the time of this test the real Qwen3-14B had not yet been asked questions against the new library; Part 4 covers the live runs.

Four more sources, a runbooks folder and a web page

Techniques and CVEs are not enough for a SOC assistant, which also needs detection logic, test procedures and indicators. The script now fetches four more public sources, each turned into one record per rule, test, binary or driver. A sandbox run on October 24, 2026 produced these counts:

Source What it adds License Records
SigmaHQ rules Detection rules: log source, matched fields, false positives, ATT&CK tags Detection Rule License 1.1 3,363, from 3,302 rules
Atomic Red Team Test commands per technique, showing what an attack looks like on an endpoint MIT 1,945, from 1,896 tests
LOLBAS Windows binaries and scripts that attackers abuse, with commands and detection notes GPL-3.0 338, from 248 entries
LOLDrivers Vulnerable and malicious drivers, with SHA-256, SHA-1 and MD5 hashes Apache-2.0 862, from 706 drivers

With ATT&CK and KEV that is about 10,250 records. Building the search index takes about a second and answering a question about 0.02 seconds on the test machine. The licenses were read from each project's own license file. Using the files locally is fine; check each license before republishing the generated text. Two details: the Atomic Red Team index lists a test under every tactic its technique belongs to, so the script keeps each test once, and the Sigma bundle's deprecated rules are skipped.

What a first real test showed. The first question typed into the web page pasted a whole phishing email into the question box. The answer called it phishing, but its only citation pointed at an excerpt about forged sender headers that said nothing about the claim it was attached to, several sentences came from the model's memory, and the grounding check still passed. Two causes: the email's random sender strings and common words drove the search, so all ten excerpts were unrelated, and the check only confirmed that the cited excerpt number existed. Four changes followed:

  1. The page has a separate evidence box. Only the short question searches the library; the pasted log or email goes to the model as untrusted evidence, cited as [E].
  2. If the best excerpt matches under 30% of the question's terms, no excerpts are sent, and the page says so instead of padding the prompt with weak matches.
  3. The grounding check now flags sentences that carry no citation, and sentences that share almost no wording with the source they cite. Both are rough heuristics, not proof of support.
  4. Identifiers count triple in the ranking and no more than six excerpts come from any one file. Without the file limit, a Kerberoasting question gave eight of its 14 excerpts to Atomic Red Team tests; with it, six of 13, which leaves room for more ATT&CK records.

Hashes, CVEs and technique IDs in the evidence are also looked up directly in the library, so a SHA-256 from a log that matches a LOLDrivers entry shows up without relying on the model.

Retrieval checks. These ran in the sandbox against the full library without the model, so they show what the model would be given, not what it would say:

Question What came back
Which ATT&CK technique fits a phishing email with a link to cloud storage? T1566.002 Spearphishing Link and T1566 Phishing detection guidance, plus an Atomic test for T1566.002
What is the ATT&CK ID for Kerberoasting? The T1558.003 technique and detection records, six Atomic tests, and the Sigma rule "Kerberoasting Activity - Initial Query"
What does LOLBAS say about certutil abuse? All three parts of the Certutil.exe LOLBAS record and one Sigma rule
Is CVE-2025-20333 exploited and what is its due date? The KEV record for CVE-2025-20333
Is the driver with SHA-256 94c3294b... known vulnerable? (full hash in the test) The giveio.sys LOLDrivers record, found by exact hash lookup

Your own runbooks. The script creates a runbooks folder the first time it runs, with a short README and a template (both start with an underscore, so they are not searched). Any .md or .txt file placed there is read with the reference files. A markdown file is split at its headings, and each section stays whole where it fits in about 3,500 characters, so a numbered procedure is not cut in half. Longer sections are split at line ends and titled "(part 1 of 2)". The web page also matches a runbook to the case and prints it as a checklist, so a runbook with Scope, Triage, Escalate when, Containment and Related sections works best (the Scope section is what the match reads). Part 4 uses a phishing triage runbook built this way. Do not put passwords or customer data in it, because whatever matches a question is sent to the model.

The web page and the larger context. The page is described in Step 8 of Part 1. With the server started at -c 16384, run it with --context 16384 so it sends up to about 28,000 characters of sources and evidence per question instead of about 12,000. Longer prompts take longer to process before the first word appears, and the larger cache uses more of the card's memory. Measured on the RX 9070 XT on October 24, 2026 (one fresh request, a 6,311-token prompt: the phishing email plus 22 excerpts):

  • Prompt processing: 3.6 seconds (1,736 tokens per second), so that is the wait before the first word.
  • Generation: 48.8 tokens per second (272 tokens in 5.6 seconds). An earlier request with about 6,700 tokens already cached ran at 43.8, and Part 2 measured 53.3 at 8K context with a short prompt.
  • Total for the request: 9.2 seconds.
  • GPU memory: 11,028 MiB used (model 8,161, context cache 2,560, compute buffers 306) of 16,304 MiB, leaving about 5 GiB. The context cache size matches 160 KiB per token times 16,384 tokens.

Full-window test. A script sent a 15,000-token prompt of filler text straight to the server (a 16,384-token window). Prompt processing took 11.1 seconds (1,353 tokens per second), generation ran at 44.3 tokens per second on a 44-token reply, and the request took 12.1 seconds in all. Processing speed fell from 1,736 to 1,353 tokens per second as the prompt grew from about 6,300 to 15,000 tokens. GPU memory was not recorded during this run. These are single requests.

What the phishing tests changed in retrieval. Nine runs of one phishing email with a live Qwen3-14B are written up in Part 4. Two retrieval problems turned up first. Filler words in the question, such as "steps taken", matched Atomic Red Team descriptions, so they are now ignored. And a source file could still fill the prompt: per-file caps now apply (Atomic tests 2, CISA KEV 2, Sigma 3, LOLBAS 2, LOLDrivers 2), lifted only when the question names that source. A simple suffix-stripping step also lets "phishing" match "phish" and "spearphishing" match "spear phishing". Before these changes about 2 of 15 returned excerpts were relevant to the phishing question; afterwards most were. The library files and refresh_refs.py did not change.

Open items and next in the series

The library exists and its retrieval works; what remains is proving it on the real model and keeping it fresh.

  • Run python refresh_refs.py on the PC. Done since the cloud test. In the cloud test environment cisa.gov itself was blocked, so the CISA catalog came from CISA's own GitHub copy (cisagov/kev-data); this page does not record which source the PC run used.
  • Repeat the Kerberoasting ATT&CK ID question against refs\*.txt and confirm Qwen3-14B answers T1558.003 with a citation.
  • Repeat the out-of-scope question (Azure AD service account password policy). The earlier test passed on a one-page reference; with 3,750 records the model will now receive plausible-looking neighbors, such as records on password policy discovery, and may be tempted to answer from them.
  • Ask the question left open in Part 2, what Windows Event ID 4769 records, and compare the answer with the DET0157 record.
  • Decide how often to refresh. ATT&CK ships a few releases a year and the CISA catalog gains entries often (the newest in the test copy was added the day before); a weekly run in Windows Task Scheduler is a reasonable start.
  • Install PyYAML and run refresh_refs.py again on the PC to fetch the four new sources. Done since the cloud test. The LOLBAS project's website was blocked in the cloud test environment, so LOLBAS was tested there from a copy of its GitHub files; this page does not record how the PC run fetched it.
  • Run the web page against the live Qwen3-14B at -c 16384. Done: the phishing email was run nine times (Part 4), and speed and GPU memory at that context size were measured, including a 15,000-token prompt (see "The web page and the larger context" above).
  • Confirm the llama.cpp tab shows the server's real page inside the web page. It was tested only with a stand-in page.
  • Done in Part 4: a phishing triage runbook was saved in runbooks\ and matched on all eight runs. Still to do: a Windows log case, to confirm the matching works for a runbook that is not about email.
  • Spot-check the new Sigma, Atomic and LOLDrivers records against their sources before trusting an answer built on them.

Search by meaning is the next upgrade. The current retrieval matches words, so a question phrased very differently from a record's text can miss it. A local embedding model would fix that without any network traffic, and is a candidate for a later part, along with adding Microsoft event documentation and Sigma detection rules to the library.

How this was made

I want to be direct about who did what.

  • Written with Claude (Sonnet 5.5): the refresh script, the retrieval upgrades and the first draft of this part. I did not write that code from scratch, and I don't claim to.
  • Mine: the goal (a private, local model for SOC work), the hardware and environment, running everything on my own PC, working through the failures, testing the model against answers I already knew, and deciding what to trust.
  • Why I'm saying so: in a SOC, being honest about what a tool did and what you verified is part of the job. The same applies to AI-assisted work. The Kerberoasting test in Part 2 is the clearest example: the model sounded confident and was wrong, so I checked it.

Using AI tools to learn faster, build real projects and then verify the output is a method I plan to keep using and keep being open about.

Sources

Pages opened and files downloaded on October 24, 2026.

  • SigmaHQ/sigma: the README states that the rules are released under Detection Rule License 1.1; the release bundle sigma_all_rules.zip was the file downloaded (3,302 usable rules).
  • redcanaryco/atomic-red-team: atomics/Indexes/index.yaml was the file downloaded; the repository's LICENSE.txt is the MIT License.
  • LOLBAS-Project/LOLBAS: the YAML files were read from a clone of the repository; its LICENSE is GPL-3.0.
  • magicsword-io/LOLDrivers: drivers.json from the repository was downloaded (706 entries); its LICENSE is Apache-2.0.

← Back to Cybersecurity