Frame 02Open sourceSecurity2026
An LLM triages the secrets scan without ever seeing a secret
A one-shot security audit for a multi-repo codebase. Seven pinned scanners run one at a time on a throwaway EC2 instance, everything lands in one SARIF file, and a reasoning model via OpenRouter dedupes, drops false positives, ranks by exploitability, then hunts for the logic bugs scanners cannot express. The constraint that shaped it: gitleaks output contains live credentials, and the model must never be handed one.
- Stack
- Bash, Python 3 (stdlib only), AWS EC2, IAM, Secrets Manager, S3, SARIF 2.1.0, OpenRouter
- Scope
- Sole author
- One command provisions the box, installs the scanners, scans, merges, triages, uploads to S3 and terminates. Nothing persists on the instance.
- Secret-class findings reach the model as file, line and rule id only; every other string passes through seven redaction regexes; a self-check asserts both before each run (triage.py).
- Two commits, no CI, no real findings in the repo by design. The production run is on the Verita page.
What could go wrong, and how we would know
The failure I worried about most was not a missed vulnerability. It was the audit itself leaking. gitleaks scans full git history and reports what it matched in the result message. Forward that message to a hosted model and you have pasted a live AWS key into a third-party prompt to ask whether it is a problem. The quieter failure is an r6i.2xlarge nobody terminates, or a scan that crashes mid-run and uploads nothing.
Three layers, each cheap. gitleaks runs with --redact (run_scanners.sh). flatten() in triage.py marks a finding secret-class if tool name, rule id or message contains any of eleven hint substrings (secret, credential, token, aws, gitleaks and so on) and forwards an empty message for it. redact() runs seven compiled patterns over every string that leaves the box and truncates at 600 characters.
What makes this a check rather than a promise: _redaction_selfcheck() builds a gitleaks-style finding with an AKIA key in its message, flattens it and asserts the message is empty, then asserts that AWS, GitHub PAT and password= literals collapse to [REDACTED]. triage.py runs it before every real run, so a broken pattern fails the audit instead of the audit leaking.
For the cost leak: bootstrap.sh arms shutdown -h +360 before the first apt-get, the instance is launched with shutdown behaviour set to terminate, and both bootstrap.sh and run_scanners.sh trap EXIT and copy their logs to S3 with the exit code in the filename.
What I built
provision_ec2.sh launches an r6i.2xlarge (64 GB, 8 vCPU) Ubuntu 24.04 instance with a 150 GB gp3 root volume and an inline IAM policy that can read secrets under one prefix, write one bucket, write one log group and terminate instances. The security group is outbound-only unless a keypair is supplied. The launch retries the IAM propagation race up to seven times, ten seconds apart.
bootstrap.sh installs seven scanners at pinned versions from one block (gitleaks, OpenGrep, Bandit, Trivy, checkov, osv-scanner, CodeQL); a 404 on a pinned tarball fails loudly rather than pulling latest. run_scanners.sh clones with full history and runs the scanners one at a time, CodeQL last and alone, once for Python and once for JavaScript, because it is the only tool that wants the 64 GB. Serial is slower, and speed is explicitly not the goal.
merge_sarif.py joins the per-tool SARIF into one file and tags each result with its repo, because scanners emit repo-relative paths and settings.py in three repos is otherwise ambiguous. triage.py makes two passes. Bulk triage chunks findings by repo, 30 per call, asking for a dedup key, a false-positive verdict, exploitability from 1 to 5 and a root cause; anything the model skips is kept at exploitability 1 and labelled untriaged. Non-false-positive findings scoring 3 or more, top 40, go to a semantic pass that confirms each and hunts IDOR, broken auth ordering, SSRF and races. finish.sh syncs out/ to S3 and only then terminates.
| Chose | Over | Because | Cost |
|---|---|---|---|
| Drop the message for secret-class findings | Redaction alone | No regex list enumerates every secret format; file, line and rule id are enough | Less context; the substring match also catches every checkov CKV_AWS rule |
| Timer armed before the first install step | Timer at scan start | A bootstrap failure had already left a box running | A long CodeQL run could hit the 6-hour cap (configurable) |
| Serial scanners, CodeQL last | Parallel | Peak RAM stays bounded; a periodic manual audit, not CI | Wall-clock time |
| One ephemeral r6i.2xlarge | Laptop or persistent runner | CodeQL needs the RAM; nothing persists after teardown | Provisioning code, IAM propagation race |
| Stdlib urllib for OpenRouter | requests | Nothing to install on the runner | Hand-rolled backoff; one bug in the fallback (below) |
Constants from triage.py (CHUNK_FINDINGS = 30, SEMANTIC_SHORTLIST = 40, redact() max_len = 600) and the scanner order from run_scanners.sh. The narrowing is the mechanism, not a measured count.
Try it
The redaction gate from triage.py: the same seven patterns and the same stripping rule, running in your browser on text that never leaves the page.
Verification
No test suite. Two executable self-checks with no framework: python3 triage.py with no arguments runs the redaction assertions above plus a repo-attribution check; python3 merge_sarif.py with no arguments builds a two-repo fixture and asserts run counts and repo tags. The triage self-check is also the first thing a real run executes. After a run, the README says to grep out/logs/ for your own key prefixes.
- 7redaction patterns
- 11secret-class hint substrings
- 2executable self-checks
- 360minute hard-kill timer
What I found
Bulk triage started at 60 findings per call. With reasoning on, the output ran past the max_tokens cap (now 65,536), the JSON was cut off mid-answer, the parser returned an empty object, and every finding in the chunk silently fell to untriaged. The fix: 30 per chunk, surface finish_reason so a cut-off is detectable, and halve and retry down to five. The README still says about 60.
The fallback that drops response_format on a 400 checked for the key in the wrong dictionary and could never fire. The kind of bug a retry path hides: it only fails once the primary path already has.
Two operational scars live in the comments. A bootstrap failure left a box running, twice, so the shutdown timer is now armed at the top of bootstrap.sh as well as at scan start. A scan that crashed under set -e uploaded nothing, because finish.sh only runs on success, so both scripts now trap EXIT and ship logs with the exit code in the filename.
Outcome, and what it does not prove
This repo is the generalised pipeline, published so the mechanism can be read. The run that mattered was on Verita's codebase during SOC 2 readiness; its numbers are on the Verita page, not here. This repo publishes no findings, its only report is a synthetic sample labelled as such, and a public repo should not carry results it cannot show.
What it does prove is narrow: the redaction invariant is executable, the instance lifecycle is bounded by a timer armed before the first install step, and every scanner version is pinned in one block. It does not prove the triage is accurate; that depends on the model and the codebase, and neither is in the repo.
What I would do differently
- Tighten the secret-class hint match. It is a substring test over tool, rule and message, and "aws" and "token" are hints, so every checkov CKV_AWS and Trivy AVD-AWS rule reaches the model without its message. That fails safe, but it weakens infrastructure triage. Dropping the broad "aws" and "token" hints and matching gitleaks by tool name would keep the invariant and give the context back.
- Turn the two self-checks into a pytest file and run them in CI, so README and code cannot drift the way they already have on model name and chunk size.
- Keep the history. Two commits nine minutes apart squashed away the part where things went wrong.
- Two commits on one day (30 Aug 2026); the development history lives in code comments, not the log.
- No CI workflow and no pytest suite; verification is the two inline self-checks.
- No real findings are published, by design:
out/is git-ignored and results/SAMPLE-report.md is synthetic. - README and code disagree on the triage model and on the bulk chunk size (README about 60, triage.py 30); the cost constants describe the model the README names, not the one the code calls.
Sources README triage.py bootstrap.sh run_scanners.sh provision_ec2.sh Scope: I (sole author) Verified 3 Sep 2026