Spot extraction and runaway cost in the query log
Task
Read a day of synthetic gateway traffic for the two patterns the lesson says leave no single malicious request to point at: an account querying at a steady, templated volume, which is the evidence of extraction through the interface, and an account whose token cost spikes, which is denial of wallet. Compute the per-user measures that expose them, record the findings, then confirm the gateway controls that answer both -- a per-user rate limit, a token budget, and output that carries no scores -- and prove each one fires.
Steps
- Write
lab/make-theft-log.py. It writes about 2,000 records covering 24 hours tolab/evidence-theft.jsonl.
import json, random
from datetime import datetime, timedelta
random.seed(19)
START = datetime(2026, 9, 14, 0, 0, 0)
END = START + timedelta(hours=24)
USERS = ["alice", "bob", "carol", "dave", "erin", "frank", "grace", "heidi", "ivan", "judy"]
SPIKE_USER = "erin"
QUESTIONS = [
"How many days of annual leave do staff get?", "Who is on call this weekend?",
"What warranty does the Model S kettle have?", "Can I carry leave over into next year?",
"Where do I send an expenses claim?", "Is the office open on bank holidays?",
"How do I book a meeting room?", "What is the returns window for online orders?",
"Does the kettle work on 110 volts?", "Who approves overtime?",
"When is the next payroll date?", "How do I report a lost badge?",
"Can contractors use the staff canteen?", "What counts as a working-from-home day?",
"How long is the probation period?", "Which form do I use for parental leave?",
"Can a customer exchange a faulty toaster?", "What are the on-call handover steps?",
"How do I reset my voicemail PIN?", "Is there a cycle-to-work scheme?",
"Summarise the travel policy in three lines.", "What should I do if a delivery is late?",
"Who do I ask about a payslip error?", "Are pets allowed in the office?",
"How much notice do I give before leaving?", "What is the dress code for site visits?",
]
LONG = [
"Write a full, detailed report on every policy in the handbook.",
"Rewrite the complete product FAQ as a long training manual.",
"Produce a detailed, section-by-section commentary on the leave policy.",
"Draft an exhaustive onboarding guide covering every document you can see.",
]
recs = []
def add(t, user, prompt, response_tokens, params):
recs.append({"ts": t.strftime("%Y-%m-%dT%H:%M:%S+0000"), "user": user, "prompt": prompt,
"retrieved_ids": ["hr-holiday-policy.md#0"], "response": "(synthetic answer)",
"prompt_tokens": len(prompt) // 4 + 180, "response_tokens": response_tokens,
"latency_ms": 400 + response_tokens * 9, "decision": "allow", "params": params})
ordinary = {"temperature": 0.2, "max_tokens": 512}
for u in USERS:
for _ in range(random.randint(95, 125)):
t = START + timedelta(hours=random.uniform(7.5, 18.5))
add(t, u, random.choice(QUESTIONS), random.randint(40, 380), ordinary)
for _ in range(30):
t = START + timedelta(hours=14 + random.uniform(0, 2))
add(t, SPIKE_USER, random.choice(LONG), random.randint(3200, 4096),
{"temperature": 0.2, "max_tokens": 4096})
t, n = START, 0
while t < END:
n += 1
add(t, "svc-reporting", "[TEST-PATTERN:systematic-probe-%04d]" % n, random.randint(90, 140),
{"temperature": 0.0, "max_tokens": 128, "logprobs": True})
t += timedelta(seconds=random.randint(95, 105))
recs.sort(key=lambda r: r["ts"])
with open("lab/evidence-theft.jsonl", "w") as f:
for i, r in enumerate(recs, start=1):
print(json.dumps(dict(id="t%05d" % i, **r)), file=f)
print(len(recs), "records written to lab/evidence-theft.jsonl")
Run it with python lab/make-theft-log.py. 2. Write lab/theft-stats.py, which computes four measures per user: requests; peak_per_hour, the most requests in any one clock hour (the first 13 characters of ts name the hour); template_share, the share of the user's prompts whose first 20 characters equal that user's most common 20-character prefix, rounded to three places; and tokens, the sum of response_tokens. It writes lab/theft-findings.csv with header user,requests,peak_per_hour,template_share,tokens,finding,control and one row per user. 3. Judge each user against the others, not against a fixed number. Set finding to extraction for an account whose volume is far above everyone's and steady around the clock with templated prompts, to cost-spike for an account whose token total is far above everyone's without that volume pattern, and to none otherwise. Fill control for each flagged account from the lesson's map, using semicolons rather than commas: a per-user rate limit, detection of the same behaviour across accounts and no scores returned for extraction; a token budget and an output token cap for a cost spike. Leave it empty for none. 4. Look at what else the seeded account did. Compare the params field across users and the hours each user was active, and write lab/theft-notes.md with at least three lines starting - : what the account asked for that the application never displays, when it was active, and why a limit on requests per account alone would not stop the same behaviour spread across many accounts. 5. Confirm the rate limit. Lesson 10's short lab added RATE_LIMIT = 5 and RATE_WINDOW_S = 600 with block:rate; check they are still in gateway/gateway.py, or add them back as that lab describes. 6. Confirm a token budget. Lesson 10's applied gateway looks each user's daily_tokens up by name in gateway/keys.json and refuses with 429 and block:budget once that user's prompt_tokens plus response_tokens for today reach it. Issue budget-probe an entry with daily_tokens set to 50; the Verify requests send no key, and the budget still applies because it is looked up by user. If your build has no budget, add it as that lab describes. 7. Apply the lesson's output minimisation. The gateway must build the runner request from its own fixed fields only, so a client cannot ask for per-token probabilities, and its reply must carry only response, decision and retrieved_ids. If your runner returns probabilities or scores, drop them before logging and replying. Restart the gateway.
Verify
Run from ~/secai-lab, at least ten minutes after any request as the three test users. The first script checks your findings against the evidence log; the rest test the live gateway and count what it logged.
python lab/theft-stats.py
python - <<'EOF'
import csv, json
from collections import Counter, defaultdict
by = defaultdict(list)
for line in open("lab/evidence-theft.jsonl"):
r = json.loads(line)
by[r["user"]].append(r)
rows = {r["user"]: r for r in csv.DictReader(open("lab/theft-findings.csv", newline=""))}
bad = []
for u, rs in by.items():
row = rows.get(u)
peak = max(Counter(r["ts"][:13] for r in rs).values())
tokens = sum(r["response_tokens"] for r in rs)
if row is None:
bad.append(u + ": no row")
elif (int(row["requests"]), int(row["peak_per_hour"]), int(row["tokens"])) != (len(rs), peak, tokens):
bad.append(u + ": counts differ from the log")
flagged = {u: r["finding"] for u, r in rows.items() if r["finding"] != "none"}
share = {u: float(r["template_share"]) for u, r in rows.items()}
print(len(by), "users in the log,", len(rows), "rows; problems:", bad or "none")
print("flagged:", flagged)
print("template share, svc-reporting:", share.get("svc-reporting"), "| highest of the rest:", max(v for u, v in share.items() if u != "svc-reporting"))
ok = (not bad and flagged == {"svc-reporting": "extraction", "erin": "cost-spike"}
and share.get("svc-reporting", 0) >= 0.9 and all(v < 0.5 for u, v in share.items() if u != "svc-reporting")
and all(rows[u]["control"].strip() for u in flagged))
print("PASS" if ok else "FAIL")
EOF
grep -c '^- ' lab/theft-notes.md
wc -l < logs/gateway.jsonl > lab/theft-start.txt
python - <<'EOF'
import json, os, urllib.request, urllib.error
GW = os.environ.get("GW", "http://127.0.0.1:8088") + "/ask"
def ask(body):
req = urllib.request.Request(GW, data=json.dumps(body).encode(), headers={"Content-Type": "application/json"})
try:
with urllib.request.urlopen(req, timeout=600) as r:
return r.status, json.load(r)
except urllib.error.HTTPError as e:
return e.code, {}
q = "How many days of annual leave do staff get?"
codes = [ask({"user": "rate-probe", "question": q})[0] for _ in range(50)]
print("rate-probe: allowed", codes.count(200), "| refused", codes.count(429))
print("budget-probe:", [ask({"user": "budget-probe", "question": q})[0] for _ in range(2)])
status, reply = ask({"user": "score-probe", "question": "What warranty does the Model S kettle have?",
"logprobs": True, "top_logprobs": 5})
extra = [k for k in reply if any(w in k.lower() for w in ("prob", "score", "confidence", "logit"))]
print("reply fields:", sorted(reply), "| score fields:", extra or "none")
print("PASS" if status == 200 and reply.get("response") and not extra else "FAIL")
EOF
python - <<'EOF'
import json, collections
start = int(open("lab/theft-start.txt").read())
recs = [json.loads(l) for l in open("logs/gateway.jsonl").readlines()[start:]]
for (user, decision), n in sorted(collections.Counter((r["user"], r["decision"]) for r in recs).items()):
print(user, decision, n)
EOF
The first script reports 11 users with no problems, flags exactly svc-reporting as extraction and erin as cost-spike, shows a template share of 1.0 against a highest of the rest well under 0.5, and prints PASS; the notes have three or more lines. Live: rate-probe allowed as many times as your RATE_LIMIT (5) with the rest refused, budget-probe [200, 429], reply fields decision, response and retrieved_ids only with no score fields, and PASS. The log counts show rate-probe block:rate 45 times and budget-probe block:budget once. If budget-probe shows two 200s, the budget is counted after the request instead of before it.
Notes
The two accounts were caught by different measures, and neither measure alone finds both: svc-reporting's tokens per request were ordinary, and erin's request rate was only somewhat high. That is the lesson's point about patterns rather than single requests. The rate limit answers svc-reporting only while the behaviour stays in one account; spread across fifty accounts it needs the cross-account detection in your notes. The score check is the inversion and membership-inference half of the control: an attacker who sees only a label learns far less per query than one who sees a probability. Consider whether retrieved_ids belongs in the reply as well -- it tells every caller which documents exist, which is output detail too.
This is an independent study companion for CompTIA SecAI+ CY0-001 and is not produced by or endorsed by CompTIA.