Domain 2 capstone: defend the estate and prove every control
Task
Pull domain 2 together on your own lab. Threat-model the estate against the OWASP Top 10 for LLM Applications (2025) and the Machine Learning Security Top 10, run one proof pass that exercises the controls you built, freeze the evidence, and write a control matrix in which every row carries a command that counts its own proof. Measure the membership-inference exposure of a toy model you train yourself, before and after a mitigation, and close with a short incident note.
Steps
- Write
lab/threat-model-d2.csvwith headerrisk_id,asset,threat,owasp_id,atlas_id,evidence_of_attackand at least twelve risks,R01onwards, across the estate: the gateway, the retrieval app and its vector store, docs/, the runner and its weights, the training set from lesson 18's lab, the toy model, the logs and the agent.owasp_idis one identifier exactly as the two lists publish it (LLM01toLLM10,ML01toML10);atlas_idis an ATLAS technique where one fits;evidence_of_attackis what the attack would leave in your logs or files, from lessons 17 to 19. - Plant the notice again for the proof pass:
sudo -u secai-docs mkdir -p docs/planted, writedocs/planted/notice.mdas lesson 17's applied lab did (as secai-docs), and runpython app/ingest.py. - Write
lab/mi-measure.py, which trains a toy model on synthetic data and measures the gap between its average confidence on its own training rows and on held-out rows: the signal a membership-inference attack exploits, measured here as an evaluation.
import json
from sklearn.datasets import make_classification
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import train_test_split
X, y = make_classification(n_samples=800, n_features=20, n_informative=6,
flip_y=0.1, random_state=19)
X_train, X_held, y_train, y_held = train_test_split(X, y, test_size=0.5, random_state=19)
def measure(model):
model.fit(X_train, y_train)
train_conf = model.predict_proba(X_train).max(axis=1).mean()
held_conf = model.predict_proba(X_held).max(axis=1).mean()
return {"train_confidence": round(float(train_conf), 3),
"held_out_confidence": round(float(held_conf), 3),
"gap": round(float(train_conf - held_conf), 3),
"held_out_accuracy": round(float(model.score(X_held, y_held)), 3)}
before = measure(RandomForestClassifier(n_estimators=100, random_state=19))
after = measure(RandomForestClassifier(n_estimators=100, min_samples_leaf=10, random_state=19))
result = {"model": "RandomForestClassifier on synthetic make_classification data",
"mitigation": "min_samples_leaf=10 (regularisation)",
"before": before, "after": after,
"before_gap": before["gap"], "after_gap": after["gap"]}
json.dump(result, open("lab/mi-gap.json", "w"), indent=1)
print(json.dumps(result, indent=1))
Run it, then try two or three other values of min_samples_leaf. Keep one that shrinks the gap without costing more than 0.05 of held-out accuracy, and record it in mitigation. Fewer training rounds and returning labels only are the other mitigations the lesson names; if you choose one of those instead, record what you measured and why. 4. Give the LLM05 Improper Output Handling row something to count. Write app/render.py with render_html(text), which returns "<p>" + html.escape(text) + "</p>", and lab/render-check.py, which renders the harmless markup <b>Leave policy</b> and prints PASS only if the output contains <b> and no <b>. Any page that shows model output should go through it. 5. Run the proof pass. Record where it starts in the log with wc -l < logs/gateway.jsonl > lab/capstone-start.txt, then write and run lab/capstone-probes.py:
import json, os, urllib.request, urllib.error
GW = os.environ.get("GW", "http://127.0.0.1:8088") + "/ask"
def ask(user, question):
body = json.dumps({"user": user, "question": question}).encode()
req = urllib.request.Request(GW, data=body, headers={"Content-Type": "application/json"})
try:
with urllib.request.urlopen(req, timeout=600) as r:
return r.status
except urllib.error.HTTPError as e:
return e.code
ordinary = [l.strip() for l in open("lab/ordinary-questions.txt") if l.strip()]
planted = [l.strip() for l in open("lab/planted-questions.txt") if l.strip()]
runs = [("alice", q) for q in ordinary[:2]] + [("bob", q) for q in ordinary[2:4]]
runs += [("tester", q) for q in planted[:2]]
runs += [("alice", "What are the salary bands for each grade?")] * 2
runs += [("rate-probe", ordinary[0])] * 7
for user, q in runs:
print(ask(user, q), user, q)
Then freeze the evidence, so the counts stop moving:
tail -n +"$(( $(cat lab/capstone-start.txt) + 1 ))" logs/gateway.jsonl > lab/evidence-d2.jsonl
cp logs/agent.jsonl lab/evidence-agent.jsonl
sha256sum lab/evidence-d2.jsonl lab/evidence-agent.jsonl > lab/evidence.sha256
- Write
lab/control-matrix.csvwith headerrisk_id,owasp_id,control,where,evidence_query,resultand at least ten rows. CoverLLM01,LLM02,LLM05,LLM06,LLM08,LLM10,ML03,ML04andML05, plus one supply-chain row (LLM03orML06). Everyrisk_idmust be in your threat model.whereis one of gateway, app, docs, runner, pipeline or os. Write the file with Python's csv module, because the commands contain quotes and commas. - Each
evidence_queryis a command, run from~/secai-lab, that prints one count from the frozen evidence or a lab file. These examples serve LLM01, LLM02, LLM10, LLM05, LLM06, ML04, ML05 and the supply chain, in that order (putsudobefore the last one if lesson 18's lab needed it):
grep -c '"decision": *"block:firewall"' lab/evidence-d2.jsonl
grep '"decision": *"allow"' lab/evidence-d2.jsonl | grep -v '"user": *"carol"' | grep -c -e CANARY-7Q4X-SECAI -e '[[]CANARY[]]'
grep -c '"decision": *"block:rate"' lab/evidence-d2.jsonl
python lab/render-check.py | grep -c '^PASS'
grep -c '"decision": *"refused:' lab/evidence-agent.jsonl
python -c "import json; d = json.load(open('lab/mi-gap.json')); print(int(d['after_gap'] < d['before_gap']))"
grep -c ',extraction,' lab/theft-findings.csv
sha256sum -c lab/manifest.sha256 | grep -c ': OK$'
Write your own for LLM08 (restricted chunks retrieved for anyone outside HR) and ML03. Run every command yourself and type the number it prints into result: reading each count is the check that the evidence says what the row claims. 8. Write lab/capstone-report.md, a short incident note with exactly these five headings: ## Scope, ## Findings, ## Controls, ## Evidence and ## Residual risk. Findings come from the evidence in lessons 17 to 19's labs; Evidence names lab/evidence-d2.jsonl and its digest from lab/evidence.sha256; Residual risk includes at least what a tag-matching retrieval filter cannot see, extraction spread across many accounts, and any matrix row whose count is zero where it should show a control firing. 9. Remove the notice again with sudo -u secai-docs rm -r docs/planted and rebuild the store as lesson 17's applied lab did (delete app/store/ and re-run ingestion, unless your ingest.py removes chunks whose source has gone).
Verify
Run from ~/secai-lab with the virtual environment active. The first command regenerates lab/mi-gap.json from your script, so the numbers checked are the ones the script produces.
python lab/mi-measure.py > /dev/null
python - <<'EOF'
import csv, re, subprocess
rows = list(csv.DictReader(open("lab/control-matrix.csv", newline="")))
threats = list(csv.DictReader(open("lab/threat-model-d2.csv", newline="")))
canon = {"LLM%02d" % n for n in range(1, 11)} | {"ML%02d" % n for n in range(1, 11)}
ids = {r["owasp_id"] for r in rows}
need = {"LLM01", "LLM02", "LLM05", "LLM06", "LLM08", "LLM10", "ML03", "ML04", "ML05"}
known = {t["risk_id"] for t in threats}
print(len(threats), "threats;", len(rows), "matrix rows; required ids missing:", sorted(need - ids) or "none",
"| supply-chain row:", "yes" if ids & {"LLM03", "ML06"} else "no")
used = ids | {t["owasp_id"] for t in threats}
print("ids outside the two lists:", sorted(used - canon) or "none")
print("matrix risks not in the threat model:", sorted({r["risk_id"] for r in rows} - known) or "none")
mismatch = []
for r in rows:
got = subprocess.run(r["evidence_query"], shell=True, capture_output=True, text=True).stdout.strip()
if got != r["result"].strip() or not re.fullmatch("[0-9]+", got):
mismatch.append((r["risk_id"], r["result"], got))
print("rows whose re-run does not give the recorded count:", mismatch or "none")
print("rows recording zero:", [r["risk_id"] for r in rows if r["result"].strip() == "0"] or "none")
ok = (len(rows) >= 10 and need <= ids and ids & {"LLM03", "ML06"} and not mismatch
and {r["risk_id"] for r in rows} <= known and used <= canon)
print("PASS" if ok else "FAIL")
EOF
python - <<'EOF'
import json
d = json.load(open("lab/mi-gap.json"))
b, a = d["before"], d["after"]
print("gap before", d["before_gap"], "after", d["after_gap"], "| held-out accuracy before",
b["held_out_accuracy"], "after", a["held_out_accuracy"])
ok = d["after_gap"] < d["before_gap"] and a["held_out_accuracy"] >= b["held_out_accuracy"] - 0.05
print("PASS" if ok else "FAIL")
EOF
sha256sum -c lab/evidence.sha256 | grep -c ': OK$'
grep -cE '^## (Scope|Findings|Controls|Evidence|Residual risk)$' lab/capstone-report.md
Twelve or more threats and ten or more matrix rows, no required id missing, a supply-chain row, no identifier outside the two lists, no unknown risk, no row whose re-run differs from its recorded count, and PASS. Zero is the right count only for a row that counts a leak, such as LLM02; a zero in a row meant to show a control firing means it never fired, and the report must say so. Then a smaller gap after the mitigation with held-out accuracy within 0.05, and PASS; 2 for the frozen evidence; and 5 headings.
Notes
The matrix is the domain in one file. Each row joins a risk to a control and the control to a count anyone can recompute, which is what turns "we have a prompt firewall" into evidence an auditor can accept. Freezing the log is not a formality: a count over a live log changes with the next request, and a matrix that cannot be re-run to the same number proves nothing. The membership-inference gap is the same lesson from the model's side -- an overfitted model is more confident on the rows it learned from, and regularisation narrows that gap at a small, measured cost in accuracy, which is the trade the report should state.
This is an independent study companion for CompTIA SecAI+ CY0-001 and is not produced by or endorsed by CompTIA.