Stop asking the agent to hack your app. Have it pick the right deterministic scanner for each layer, run it non-interactively, merge the output into one format, and route each finding to a fix agent. Scanners find the same thing every time; an agent review finds it when it happens to look.
Field evidence from a 45-app sweep in October 2026 (patch-sweep). An agent-led review eventually found all of these; a scanner would have flagged each one on the first run:
- 10 Supabase tables with RLS disabled, readable and writable with the public anon key — including 472k inbound email bodies. Supabase's own security advisor reports this as
rls_disabled_in_public. - An always-true RLS policy:
csr.collection_id = csr.collection_id. A custom semgrep rule over migrations catches it (below). - Separate
npm audit,pnpm audit, andbundle-auditruns per repo, where oneosv-scannerpass covers every lockfile.
This skill orchestrates. Interpreting a tool's findings in depth stays with the layer skill: secrets-audit, dependency-audit, container-audit, cloud-audit, owasp-audit. Active exploitation is web-pentest / red-team-engagement.
Why Write is pre-approved: this skill's main output is files — the scanner-pipeline.md plan, a .github/workflows/security-scan.yml, and scanner configs. It writes only those, and only inside the target repo. Live-app scanners (nuclei, zap) are deliberately not pre-approved; they always prompt.
Cross-references: finding-triage (ranking and disposition), secrets-audit, dependency-audit, container-audit, cloud-audit (Supabase advisors, PaaS), web-pentest (authorization for live scans), agent-supply-chain-audit (SHA-pinned Actions), patch-sweep (fleet CVE response), log-watch.
Authorization
Before running anything, confirm:
- Repos: the user owns or is authorized to scan each one. For fleet mode, the owners/orgs to enumerate.
- Live targets: only domains the user owns and names explicitly in this conversation. Not "whatever the repo deploys to" — the user says the hostnames.
- Where results go: local files only, GitHub code scanning, or issues. Secret findings never go into a public issue.
Refuse to: run live scanners (nuclei, ZAP, Burp) against hosts the user doesn't own or didn't name; scan third-party or shared hosts (a shared Supabase/Vercel/Heroku hostname, a CDN, an API you call); run ZAP active scan or nuclei intrusive templates without the web-pentest authorization step; print secret values anywhere.
1. Detect the stack and pick tools per layer
Read the repo first: lockfiles, Dockerfile, *.tf, supabase/, Gemfile, go.mod, .github/workflows/. Then pick one tool per layer, preferring multi-ecosystem tools over per-package-manager ones.
| Layer | Primary | Stack-specific add-ons | Fallback |
|---|---|---|---|
| Secrets | gitleaks git (full history) |
trufflehog git --only-verified for live verification |
— |
| Code (SAST) | semgrep scan |
Brakeman (Rails), gosec (Go), CodeQL (if GHAS), custom semgrep rules (below) | — |
| Dependencies | osv-scanner scan source -r . (npm, pnpm, yarn, Bundler, pip, Go, Cargo, Composer, Maven in one pass) |
bundle-audit (Rails), govulncheck (Go, call-graph reachability), pip-audit |
npm audit / pnpm audit --prod / yarn audit |
| Infra / IaC / containers | trivy fs --scanners vuln,misconfig,secret . |
checkov -d . (Terraform, CloudFormation, k8s, Actions), trivy image <img> |
— |
| Database (Supabase) | Supabase security advisor (supabase db advisors, or Management API GET /v1/projects/{ref}/advisors/security) |
supabase db lint (plpgsql_check: function errors, not RLS), semgrep rule over supabase/migrations/ |
pg_policies / pg_tables.rowsecurity query |
| Live app (guarded) | ZAP baseline (passive) | nuclei with -rl |
— |
Commands, non-interactive, one output file per tool under .security/out/:
mkdir -p .security/out
# Secrets — full history, values redacted
gitleaks git . --redact --report-format sarif --report-path .security/out/gitleaks.sarif
trufflehog git file://. --only-verified --json --no-update | jq -c 'del(.Raw, .RawV2)' > .security/out/trufflehog.jsonl
# Code
semgrep scan --config p/default --config .semgrep/ --metrics=off --sarif --output .security/out/semgrep.sarif
# Dependencies (all lockfiles at once)
osv-scanner scan source -r . --format sarif --output .security/out/osv.sarif
# Infra / IaC / container files
trivy fs --scanners vuln,misconfig,secret --format sarif --output .security/out/trivy.sarif .
checkov -d . -o sarif --output-file-path .security/out/
# Rails
bundle-audit check --update
brakeman --no-pager -f sarif -o .security/out/brakeman.sarif
# Go
govulncheck -format sarif ./... > .security/out/govulncheck.sarif
gosec -fmt sarif -out .security/out/gosec.sarif ./...
# Supabase (check flags with `supabase db advisors --help` for your CLI version)
supabase db advisors --linked
curl -s -H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" \
"https://api.supabase.com/v1/projects/<ref>/advisors/security" > .security/out/supabase-advisors.json
Add .security/out/ to .gitignore. Flag versions differ between tool majors (gitleaks 8.19+ uses git/dir subcommands; osv-scanner v2 uses scan source); run <tool> --help once and adjust rather than guessing.
Custom rule: always-true RLS policy
The motivating bug: using (csr.collection_id = csr.collection_id) compares a column with itself, so the policy passes for every row. The author meant the outer table's column. Semgrep has no first-class SQL parser, so use generic mode: pattern-inside: create policy ... ; plus a regex that backreferences the left-hand side. Full rule file (self-comparison, literal true, and public tables to cross-check for RLS): references/semgrep/supabase-rls.yml. Copy it into the repo's .semgrep/ so the CI workflow picks it up.
semgrep scan --metrics=off --config .semgrep/supabase-rls.yml supabase/migrations/
# quick check without semgrep (PCRE backreference)
rg -nP '(?i)\b([a-z_][a-z0-9_]*\.[a-z_][a-z0-9_]*)\s*=\s*\1\b' supabase/migrations/
# portable: BSD and GNU grep -E accept \1 in practice (ugrep aliased as grep does not; use /usr/bin/grep)
grep -rEni '([a-z_][a-z0-9_]*\.[a-z_][a-z0-9_]*)[[:space:]]*=[[:space:]]*\1([^a-z0-9_]|$)' supabase/migrations/
Pair it with the RLS-disabled check, which needs the database, not the source:
select schemaname, tablename from pg_tables
where schemaname = 'public' and rowsecurity = false;
2. Secrets, done carefully
- Scan full history, not HEAD.
gitleaks gitwalks every commit;gitleaks dironly sees the working tree. A secret deleted last year is still in every clone. - Verified means live.
trufflehog --only-verifiedchecks each candidate against the provider's API. A verified hit is P0: record it asconfirmed, and rotate before you clean history. Rewriting history first leaves a live key in every fork and clone while you work. Hand rotation tosecrets-audit. - Never print values. Use
--redactfor gitleaks; stripRaw/RawV2from trufflehog JSON. Reports, issues, PR comments, and agent hand-offs name the detector, file, commit, and a hash of the value — never the value. Fingerprint secrets assecret:<detector>:<sha256(value)[:12]>so the same key in five files dedupes to one finding. - Unverified hits are
needs_validation, not noise: the provider may not support verification, or the key may be for an internal system.
3. Live-app scanning guardrails
Live scanning sends traffic to a running system. Follow web-pentest's authorization step first; this section only sets the rate and order.
- Only hostnames the user owns and named. Never the platform's shared domain root, a CDN, an auth provider, or a third-party API the app calls. Use
-u https://app.example.com, never a wildcard or a list scraped from DNS. - Passive before active. ZAP baseline spiders and reports passively:
Active scan (docker run --rm -t ghcr.io/zaproxy/zaproxy:stable zap-baseline.py -t https://app.example.com -J zap.jsonzap-full-scan.py) only after the user confirms, ideally on a staging copy. - Low rate.
nuclei -u https://app.example.com -rl 10 -c 5 -severity medium,high,critical -exclude-tags intrusive,dos,fuzz -jsonl -o nuclei.jsonl. Raise only with the user's go-ahead. - Stop on signs of harm: 5xx spikes, rate-limit responses, account lockouts. Report and pause.
4. Normalize the output
Every tool speaks a different format. Convert all of them to the repo's findings format, then export SARIF once. Format, fields, and validator: references/findings-format.md; validate with references/validate-findings.mjs; export with references/to-sarif.mjs.
Import rules:
- A scanner hit enters as
verdict: "needs_validation", withblockers: ["scanner result, not yet triaged"]and avalidation_plannaming the exact re-run command. The tool's own severity goes inevidence[].description; the record gets a realseverityonly whenfinding-triageconfirms it. Exception: a verified live secret isconfirmedon import. skill: "scanner-pipeline", plus the tool name and rule id inevidence.cwefrom the rule's metadata (semgrep, Brakeman, gosec, checkov all carry one). For dependency advisories use the advisory's CWE if present, otherwise leave it empty — don't guess.confidence: static-only results cap atmedium. Verified secrets and reachability-confirmedgovulncheckresults can behigh.
Dedupe across tools by fingerprint — a stable root-cause id with no line numbers, severity, or verdict:
| Kind | Fingerprint | Collapses |
|---|---|---|
| Dependency | dep:<ecosystem>:<package>:<canonical-id> (CVE if one exists, else GHSA, else OSV id) |
osv-scanner + trivy + npm audit + Dependabot on the same advisory |
| Secret | secret:<detector>:<sha256(value)[:12]> |
gitleaks + trufflehog + trivy on the same key, across files and commits |
| Code | sast:<cwe>:<file>:<enclosing function or route> |
semgrep + CodeQL + Brakeman on the same sink |
| IaC | iac:<resource address>:<check family> |
trivy misconfig + checkov on the same resource |
| Database | db:<project>:<schema.table>:<lint name> |
advisor + custom semgrep rule on the same table |
Keep every tool's evidence on the merged record; the fix agent needs it.
Suppression file — .security/suppressions.yml, one entry per fingerprint:
- fingerprint: dep:npm:next:CVE-2026-XXXXX
tool: osv-scanner
reason: "Static export; no server runtime. Advisory requires Server Actions."
owner: "@bri"
added: 2026-10-26
expires: 2027-01-24 # max 90 days; expired entries resurface the finding
Rules: every entry has a reason, owner, and expiry (90 days max); expired entries are ignored and the finding comes back; the agent proposes suppressions but the user approves each one. Mirror entries into native ignore files only where the tool supports a reason and expiry — osv-scanner.toml [[IgnoredVulns]] with ignoreUntil and reason, .trivyignore with exp:YYYY-MM-DD — and keep suppressions.yml as the source of truth. Suppressed findings go into findings.json as rejected with ruled_out_by pointing at the suppression entry, so re-runs don't re-raise them.
5. Fleet mode
gh repo list <owner> --limit 1000 --source --no-archived --json nameWithOwner,defaultBranchRef,pushedAt
--source drops forks; --no-archived drops archived repos. Repeat per owner/org. For each repo: shallow-clone the production branch (from patch-sweep Step 2 if you have it; default branch otherwise) — except for secrets, which need full history (git clone --filter=blob:none is enough for gitleaks). Run the per-repo pipeline in parallel (a handful at a time), writing .security/out/<repo>/. Then roll up:
| repo | layer | tool | sev | finding | status |
|---|---|---|---|---|---|
| acme/api | db | supabase advisor | (tool: ERROR) | rls_disabled_in_public: public.inbound_emails | needs_validation |
| acme/api | secrets | trufflehog | P0 | verified Stripe live key in commit a1b2c3d (value hash 3f9e…) | confirmed — rotate |
| acme/web | deps | osv-scanner | (tool: CRITICAL) | next 15.5.10 — CVE-2026-XXXXX | needs_validation |
sev shows the tool's label in parentheses until finding-triage sets a real severity. Sort P0 secrets first, then by tool severity.
6. CI and schedule
Scaffold references/security-scan.yml into the repo as .github/workflows/security-scan.yml. It:
- runs gitleaks, osv-scanner, and semgrep on every PR (gitleaks over the PR's commits; semgrep diff-aware against the base);
- runs a weekly full scan (full git history, all rules) and on
workflow_dispatch; - can be called from other repos with
workflow_call, so one copy serves a fleet; - uploads SARIF to GitHub code scanning when available (public repos, or private with GitHub Advanced Security) and always keeps SARIF as a workflow artifact otherwise;
- pins every Action to a full commit SHA and every container image to a digest (
agent-supply-chain-audit). The SHAs in the template were looked up from the upstream release tags on 2026-10-26; update them with Dependabot or Renovate, not by switching to tags.
When code scanning isn't available, open or update one issue per fingerprint (title = fingerprint, body = evidence without secret values) instead of dumping SARIF into a comment.
7. Hand-off to fixes
- Triage first. Every
needs_validationrecord goes tofinding-triagefor verdict, severity, and disposition. P0 verified secrets skip the queue to rotation. - One finding per fix agent. Give it the merged record: fingerprint, every tool's evidence, the exact re-run command, and the owning layer skill to consult. Not the whole board.
- Confirm by re-running the specific tool on the fix branch — the same command, same config — and record the before/after. The finding closes only when that tool no longer reports the fingerprint.
- Never edit scanner config to make a finding disappear. No deleting rules, no narrowing paths, no adding a suppression from inside a fix agent. A fix agent that thinks a finding is a false positive reports it back for a suppression the user approves.
Output Format
Write scanner-pipeline.md at the repo root (or one per fleet):
# Scanner Pipeline — [repo or fleet] — [date]
## Stack detected
[languages, package managers, frameworks, IaC, Supabase y/n, live targets named by user]
## Tools per layer
| layer | tool | version | command | output |
|---|---|---|---|---|
## Schedule
- PR: gitleaks (PR commits), osv-scanner, semgrep (diff-aware)
- Weekly: full history + all layers, [day/time UTC]
- Results: code scanning / artifact + issues
## Suppression policy
`.security/suppressions.yml`; reason + owner + expiry ≤ 90 days; user approves each entry.
## First run
- findings.json: [N] records ([c] confirmed, [n] needs_validation, [r] rejected)
- Roll-up board: [table above]
## Hand-off
[fingerprints sent to finding-triage; P0 secrets sent to rotation]
Optional scaffolds: .github/workflows/security-scan.yml, .gitleaks.toml (extend the default config with org-specific rules), .semgrep/ (custom rules such as the RLS rule), osv-scanner.toml.
Boundaries
- Orchestration only. Deep interpretation lives in the layer skills.
- No active exploitation. A scanner hit that needs proving goes to
web-pentestwith its authorization step. - Live scans: owned and named hosts only, passive first, low rate.
- Never print, commit, or post secret values.
- Never weaken a scanner's config to pass CI.
References
- gitleaks, trufflehog, semgrep, osv-scanner, trivy, checkov, Brakeman, govulncheck, ZAP baseline, nuclei
- Supabase database advisors and Splinter;
supabase db lint - GitHub — uploading SARIF
- Idea source: @neilsuperduper, 2026-10-25 (paraphrased) — run real tools per layer and hand their findings to agents to fix; issue #84