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
The 30-second version

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.

Why it holds up

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.

ChoseOverBecauseCost
Drop the message for secret-class findingsRedaction aloneNo regex list enumerates every secret format; file, line and rule id are enoughLess context; the substring match also catches every checkov CKV_AWS rule
Timer armed before the first install stepTimer at scan startA bootstrap failure had already left a box runningA long CodeQL run could hit the 6-hour cap (configurable)
Serial scanners, CodeQL lastParallelPeak RAM stays bounded; a periodic manual audit, not CIWall-clock time
One ephemeral r6i.2xlargeLaptop or persistent runnerCodeQL needs the RAM; nothing persists after teardownProvisioning code, IAM propagation race
Stdlib urllib for OpenRouterrequestsNothing to install on the runnerHand-rolled backoff; one bug in the fallback (below)
gitleaks OpenGrep Bandit Trivy checkov osv-scanner CodeQL (py, js) ONE AT A TIME, CODEQL LAST one SARIF per tool per repo merge_sarif.py one merged SARIF repo tag on every result flatten, redact secret-class: file, line, rule message dropped everything else: 7 patterns, then cut at 600 chars SELF-CHECK RUNS FIRST bulk triage chunks of 30 per repo dedup key, FP, exploitability 1 to 5 halve on cut-off semantic pass non-FP, score 3 or more, top 40 IDOR, authz, SSRF, races report.md ranked, FP list, token spend NO COUNTS SHOWN: THE REPO PUBLISHES NO REAL FINDINGS

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.

The seven SECRET_PATTERNS from triage.py ported to JavaScript verbatim, applied live to what you type (fake tokens shaped like AKIA..., ghp_..., sk-..., a PEM block, password="..."). The second control shows a gitleaks-style finding collapsing to file, line and rule id, as flatten() does. The sample finding is illustrative; the regexes and the rule are the repo's.

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.

What I found

Finding

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.

Finding

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.

Finding

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

Honest notes

Sources README triage.py bootstrap.sh run_scanners.sh provision_ec2.sh Scope: I (sole author) Verified 3 Sep 2026

Elsewhere