Build a triage assistant over a canned SIEM alert export that summarizes each alert and proposes a disposition — but is forced through a citation/verification rubric that flags every claim the alert data does not support.
The skill this lab trains is not "summarize alerts faster." It is refusing to let an AI verdict leave your hands until each sentence is traceable to a field in the data. You will deliberately let the copilot reach a confident, wrong conclusion, catch it with the gate, and record the override in a false-conclusion catch log. That log — not the summaries — is the artifact your future self and your auditors will trust.
An AI triage verdict is a hypothesis, not a finding. The verification gate is what converts one into the other.
~90 minutes hands-on · Foundation tier (maps to module M1 · AI in the SOC — Triage & Investigation with Trust Calibration).
Everything runs offline on your laptop, $0, no cloud SIEM and no third-party systems. You will load a provided alert export into DuckDB (or plain grep/jq), and drive an LLM either through the local dry-run stub (no key needed) or a client-approved endpoint. Install: Python ≥ 3.10, pip install duckdb pydantic, and — only if you have a key — pip install anthropic. Vendor-neutral: the same pattern works with Security Copilot, Google SecOps + Gemini, or a local model via Ollama; we build on plain files so nobody is gated by a paid SIEM.
Create a working directory triage-lab/ and save this canned export as alerts.json. It is a five-alert slice of a single night on one host, exactly the kind of export you would pull from Sentinel, Splunk, or Elastic. One alert is deliberately under-specified so the copilot will be tempted to over-claim.
[
{"alert_id":"A-1001","ts":"2026-07-20T02:14:07Z","rule":"Impossible travel","severity":"high","user":"j.rivera","src_ip":"203.0.113.44","host":"HR-LAP-12","entity_country":"BR","raw":"sign-in from BR 900ms after prior IN sign-in"},
{"alert_id":"A-1002","ts":"2026-07-20T02:15:31Z","rule":"Multiple failed logons","severity":"medium","user":"j.rivera","src_ip":"203.0.113.44","host":"HR-LAP-12","entity_country":"BR","raw":"12 failed then 1 success"},
{"alert_id":"A-1003","ts":"2026-07-20T02:22:19Z","rule":"Suspicious PowerShell","severity":"high","user":"j.rivera","src_ip":"10.4.4.12","host":"HR-LAP-12","entity_country":"IN","raw":"EncodedCommand + DownloadString observed"},
{"alert_id":"A-1004","ts":"2026-07-20T03:02:00Z","rule":"EDR: LSASS handle access","severity":"critical","user":"SYSTEM","src_ip":"10.4.4.12","host":"HR-LAP-12","entity_country":"IN","raw":"procdump-like handle to lsass.exe"},
{"alert_id":"A-1005","ts":"2026-07-20T09:11:00Z","rule":"Newsletter bounce","severity":"low","user":"marketing","src_ip":"10.9.9.9","host":"MKT-SVR-1","entity_country":"IN","raw":"smtp 550 mailbox full"}
]
Save this reusable structured-output helper as triage.py. It asks the model for a disposition and forces it to separate grounded evidence from unsupported claims — the verification gate lives in the schema itself. The --dry-run flag validates the wiring with no API key so you are never blocked.
"""Alert-triage copilot with a built-in verification gate. --dry-run needs no key."""
import argparse, json
from pydantic import BaseModel
class Verdict(BaseModel):
alert_id: str
disposition: str # true_positive | false_positive | needs_more_data
confidence: float # 0.0-1.0
supporting_evidence: list[str] # each item must quote a field value from the alert
unsupported_claims: list[str] # anything asserted but NOT present in the data
def triage(alert: dict) -> Verdict:
import anthropic # lazy import: --dry-run does not need the SDK
client = anthropic.Anthropic()
resp = client.messages.parse(
model="claude-opus-4-8",
max_tokens=1024,
thinking={"type": "adaptive"},
system=("You are a SOC L1/L2 triage assistant. Ground every statement in a field "
"value present in the alert JSON. Any assertion you cannot quote from the "
"alert MUST be listed in unsupported_claims, never in supporting_evidence. "
"Prefer needs_more_data over guessing."),
messages=[{"role": "user", "content": json.dumps(alert)}],
output_format=Verdict,
)
return resp.parsed_output
if __name__ == "__main__":
ap = argparse.ArgumentParser(); ap.add_argument("--dry-run", action="store_true")
a = ap.parse_args()
if a.dry_run:
print(Verdict(alert_id="A-1001", disposition="needs_more_data", confidence=0.0,
supporting_evidence=[], unsupported_claims=[]).model_dump_json())
else:
for alert in json.load(open("alerts.json")):
print(triage(alert).model_dump_json(indent=2))
Confirm the wiring before spending any tokens: python triage.py --dry-run should print a valid empty verdict. If the schema loads, your gate is in place.
python -c "import duckdb; duckdb.sql(\"SELECT ts, rule, severity, user, entity_country FROM read_json_auto('alerts.json') ORDER BY ts\").show()". You should see four alerts on HR-LAP-12 clustered 02:14–03:02 and one unrelated marketing bounce. Form your own hypothesis now — it is your control against the AI's.catch-log.md with columns: alert · AI claim · was it in the data? · human override · fix to the prompt/gate. You will add a row every time the gate or your own eyes catch an unsupported claim.python triage.py. Without one, hand each alert to a local model via Ollama using the same system prompt, or reason through the rubric manually. Capture each Verdict.supporting_evidence rather than unsupported_claims, the gate has failed and you catch it. Log the exact sentence, note that the source field never mentioned it, and override the disposition to needs_more_data pending IP reputation you have not yet pulled.raw field. Confirm the copilot cites the literal field values and does not inflate them into a full attribution ("APT-29", "ransomware pre-cursor") that the data cannot bear.HR-LAP-12 alerts into one narrative, citing host, user, and ts for each hop. The legitimate chain is: suspicious sign-in → brute-force success → PowerShell execution → credential access. Verify each arrow points at a real field. If it invents an exfiltration step, log it: there is no network-egress alert in this export.The "almost-right" failure mode — a summary that is 90% correct with one confident fabricated detail — is the most dangerous, because it reads as competent. Scenario questions test whether you catch the 10%.
python triage.py --dry-run prints a schema-valid verdict (gate wiring proven).Verdict for the five alerts separates supporting_evidence (field-quoted) from unsupported_claims; no claim appears in the wrong bucket after your review.catch-log.md contains at least one row for the A-1001 over-claim and at least one rejected false correlation (A-1005 or an invented exfil step).HR-LAP-12 cites host, user, and timestamps for all four alerts and asserts nothing absent from the data.A triage-lab/ folder containing the verdicts, your escalation narrative, and catch-log.md showing where the human overrode the AI. Self-grading rubric (pass = 8/10):
read_json_auto errors: your JSON has a trailing comma or the file did not save; re-paste the block exactly.--dry-run to prove wiring, then run the rubric against a local Ollama model or by hand — the verification discipline is the lesson, not the API call.raw field and confirm the gate routes it to unsupported_claims.On a services engagement you often cannot paste client alerts into a public tool. Run this lab unchanged against the client-approved tenancy (Bedrock or Azure OpenAI) or a fully local model via Ollama; DuckDB and the rubric never leave your laptop. Before prompting, redact IOCs, hostnames, and usernames to placeholders (HOST-1, USER-A, IP-1) — the verification gate works identically on redacted data because it checks structure, not identity. The catch-log is your audit trail proving no ungrounded AI claim reached the client's ticket.
needs_more_data.raw; citing them is fine, but attribution to a named actor is not.host+user+ts; an exfiltration step is not, because no egress alert exists in the data.