The WAF did its job. The request never hit the app. Then I copied the block log into a coding model and asked a reasonable question: is this SQLi, or a noisy false positive?
The model answered. It also received a Cookie header, a Bearer token, and a session id hanging off the query string. The attacker never reached origin. I handed the session to whatever sits behind the prompt API anyway. Sound familiar?
This is a lab drill, not a production write-up. No live tenant was involved. The failure mode is still real: a control that blocks a request does not automatically produce a sanitized artifact. If you paste the artifact, you moved the trust boundary.
The paste is the privileged action
A WAF sits on the client-to-origin path. Your clipboard sits on the engineer-to-model path. Those are not the same boundary. Confusing them is how a blocked cookie becomes a prompt.
flowchart LR
Client -->|HTTPS request| WAF
WAF -->|allow / block| Origin
WAF -->|block event| LogStore
LogStore -->|export / copy| Engineer
Engineer -->|prompt pack| ModelRuntime
Ask the ugly question out loud. Who is allowed to see Cookie? The WAF process, maybe the SIEM, maybe an on-call who already has prod access. A remote completion API is not on that list. A shared GPU box is not on that list either, even when nobody invoices you for it.
I treat three facts as non-negotiable in this drill:
- Block logs are hostile input. They contain attacker payloads and whatever the browser attached.
- Query strings are headers in disguise. Tokens show up there because someone was lazy in 2019 and the client never got patched.
- "I only pasted one line" is not a control. One line is enough.
What a review pack is allowed to contain
I want a model to help classify a rule hit. I do not want it to reconstruct a session. So the prompt pack gets an allowlist, not a speech about being careful.
Allowed fields in this fixture:
-
ts,method,pathwith no query string,status,rule_id,action,latency_ms,proto
Denied on sight:
-
Cookie/Set-Cookie Authorization-
X-Api-Key/X-Auth-Token - JWT-shaped strings
-
access_token,id_token,refresh_token,sessionid,sidin a query - raw request bodies
- the original URL with
?still attached - extra JSON keys the allowlist never named
Why deny extra keys? Because WAF exporters love "helpful" fields. raw_request. header_map. decoded_body. One extra key and your allowlist was theater.
Is source IP sensitive? Sometimes. I left it out of the allowlist on purpose. You can add it back if your IR runbook needs it and the model runtime is inside the same trust zone as the SIEM. That is a policy choice. It is not the default.
Lab fixture, pinned and labeled
Pinned runtime: Python 3.12. This template was not executed against a live WAF. It is a regression gate for a prompt pack. If you point it at production logs, you own the redaction policy, not this article.
Positive fixture — must fail. Structured event plus a raw combined-style line. Both leak.
# fixtures/positive.log
{"ts":"2026-09-11T02:14:08Z","method":"POST","path":"/login?access_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJsYWIifQ.labfixturetoken","status":403,"rule_id":"sql-1","action":"block","latency_ms":12,"cookie":"session=abc"}
02:14:08 POST /admin HTTP/1.1 403 Authorization: Bearer lab-token-32chars Cookie: sid=1
Negative fixture — must pass. Same attack class. No credentials in the pack.
# fixtures/negative.log
{"ts":"2026-09-11T02:14:08Z","method":"POST","path":"/login","status":403,"rule_id":"sql-1","action":"block","latency_ms":12,"proto":"HTTP/1.1"}
Expected failure evidence on the positive file: non-zero exit, at least one finding named json_keys_outside_allowlist, path_contains_query, access_token_qs, cookie_header, authorization_header, or jwt_like. Expected evidence on the negative file: exit 0 and ok.
#!/usr/bin/env python3
"""Refuse to pack WAF/block logs that still contain secrets. Lab fixture."""
from __future__ import annotations
import argparse
import json
import re
import sys
from pathlib import Path
DENY_PATTERNS = [
("authorization_header", re.compile(r"(?i)authorization\s*[:=]\s*bearer\s+\S+")),
("cookie_header", re.compile(r"(?i)\bcookie\s*[:=]\s*\S+")),
("set_cookie", re.compile(r"(?i)set-cookie\s*[:=]")),
("jwt_like", re.compile(
r"\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b"
)),
("access_token_qs", re.compile(
r"(?i)(access_token|id_token|refresh_token|sessionid|sid)="
)),
("api_key_header", re.compile(r"(?i)(x-api-key|x-auth-token)\s*[:=]\s*\S+")),
]
ALLOW_KEYS = {
"ts", "method", "path", "status", "rule_id", "action", "latency_ms", "proto",
}
def findings_in_text(blob: str) -> list[tuple[str, str]]:
hits: list[tuple[str, str]] = []
for name, cre in DENY_PATTERNS:
match = cre.search(blob)
if match:
hits.append((name, match.group(0)[:80]))
return hits
def findings_in_line(line: str) -> list[tuple[str, str]]:
try:
obj = json.loads(line)
except json.JSONDecodeError:
return findings_in_text(line)
if not isinstance(obj, dict):
return findings_in_text(line)
hits: list[tuple[str, str]] = []
extra = sorted(set(obj) - ALLOW_KEYS)
if extra:
hits.append(("json_keys_outside_allowlist", ",".join(extra)))
packed = " ".join(str(obj.get(k, "")) for k in ALLOW_KEYS if k in obj)
hits.extend(findings_in_text(packed))
path = obj.get("path")
if isinstance(path, str) and "?" in path:
hits.append(("path_contains_query", path[:80]))
return hits
def scan(path: Path) -> list[tuple[int, str, str]]:
out: list[tuple[int, str, str]] = []
for i, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
if not line.strip() or line.startswith("#"):
continue
for name, snippet in findings_in_line(line):
out.append((i, name, snippet))
return out
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--input", type=Path, required=True)
parser.add_argument("--expect", choices=("pass", "fail"), required=True)
args = parser.parse_args()
hits = scan(args.input)
if hits:
for line_no, name, snippet in hits:
print(f"FAIL line={line_no} rule={name} snippet={snippet!r}")
failed = True
else:
print("ok")
failed = False
if args.expect == "fail" and not failed:
print("expected findings, got a clean pack")
return 2
if args.expect == "pass" and failed:
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
Numbered run
Do not start by arguing about model vendors. Start by proving the gate fails closed.
- Save the script as
waf_log_prompt_gate.pyand the two fixtures next to it. - Run the negative path first. If this fails, your allowlist is wrong, not the model.
- Run the positive path. If this passes, the gate is lying and you will paste cookies again.
- Only then export a real block event, strip it to the allowlist, and re-run with
--expect pass. - Keep the original event in the SIEM. The model never needs the original.
python3 --version # expect Python 3.12.x
python3 waf_log_prompt_gate.py --input fixtures/negative.log --expect pass
python3 waf_log_prompt_gate.py --input fixtures/positive.log --expect fail
If step 3 prints expected findings, got a clean pack, stop. You do not have a detector. You have a comment in a wiki.
Prevent, detect, recover
| Layer | Prevent | Detect | Recover |
|---|---|---|---|
| WAF export | Disable raw_request / full header dumps on the default copy action |
Alert when an export includes cookie or authorization keys |
Rotate the session family in that log line, do not debate whether the model "used" it |
| Engineer laptop | Gate the prompt pack with the fixture above | Fail CI or a pre-commit hook on prompt-pack.json
|
Treat clipboard history as contaminated; clear it |
| Model runtime | Send allowlisted fields only | Log prompt hashes and field names, never values | Assume vendor or disk retention; rotate anyway |
| App session | Short-lived cookies, sender-constrained tokens where you can | WAF rule on token-in-query | Invalidate sid / refresh tokens from the timestamp of the paste |
Notice what is missing: "ask the model to ignore secrets." That is not a control. Models do not enforce your privacy policy. They complete text.
Self-hosted inference moves the boundary. It does not delete it.
Remote model APIs are the obvious leak. People then switch to a box under their desk and relax. Why? Because "it is our GPU" feels like a security property. It is not. It is a location.
Disk snapshots, shared operator access, prompt caches, and the next intern's tmux session still see the pack. If you review WAF events with a coding assistant, redact before the bytes leave the log store. The runtime should receive the allowlisted JSON, nothing else.
Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode is an open-source AI development platform with free model access and a free server option. I care about that here for one reason: you can keep the review of a sanitized block event on infrastructure you operate. That does not authorize a raw Cookie header on the prompt. If the gate in this article fails, do not send the file, including to a server you like.
A self-hosted option is a placement decision. The invariant is still "no session material in the prompt pack."
Limitations, and who should skip this
Regex is not a HTTP parser. Encoded cookies, split headers, chunked bodies, gzip, and protobuf exporters will walk around these patterns. Multipart uploads will too. Homoglyphs in header names will too. If your WAF writes binary objects into object storage, this script will not save you.
The allowlist also drops forensic detail on purpose. Path-plus-query can be the whole incident. If you are in active IR and you need the original bytes, do not use a coding model as the viewer. Use the SIEM, a ticket with access control, and people who already hold prod credentials.
Skip this approach if:
- you do not own the log pipeline (you will fight the export format forever)
- you need full PCAP or request-body reconstruction in the same window as model review
- your "WAF" is a CDN dashboard you cannot strip before download
- you want a magic prompt that makes leaking safe
This fixture does not prove a vendor trained on your data. It proves you were about to hand them the session. Those are different claims. Keep them separate.
Which layer owns the invariant?
I want the export button to be the enforcement point. CI on a prompt-pack file is the regression test. The model runtime is the last chance, and last chances fail.
So here is the boundary question I actually want answered in a review: does the WAF console refuse to copy denied fields, or do we keep relying on an engineer to run a Python gate after the clipboard already lost?
If you already evaluate self-hosted coding workspaces, put the gate in front of the prompt pack before the first completion — including on a free server. The interesting part is not the product. It is whether the cookie you blocked is still sitting in the JSON you were about to paste.
Top comments (0)