From 0cb736b338e93222d099882c527c4795baacf226 Mon Sep 17 00:00:00 2001 From: svector-anu Date: Mon, 10 Aug 2026 09:37:43 +0100 Subject: [PATCH] feat(vuln-scanner): fuzz targets a repo already ships (cargo-fuzz) Run the target's own cargo-fuzz harness (when it ships one) as part of arm A: seed the corpus from tests/fixtures where they exist, run each target for ~90s (capped at 8 targets), and route a crash through the same triage as any scanner hit before it counts as a finding. Crash routing depends on where it lands: target's own code -> PVR, a dependency -> public PR against that dependency, harness lying -> drop. Verified end to end against firecrawl/anydoc: its xlsx fuzz target crashed on an integer-overflow panic inside calamine (a dependency), root-caused, fixed, and filed upstream (tafia/calamine#705). Secrets are scrubbed from the fuzz env (env -u GH_TOKEN -u GH_GLOBAL -u RESEND_*) immediately before compiling/running the target's own code, since a malicious target's build.rs could otherwise exfiltrate them. Split out of #863 per triage: this PR is skill-scope only (skills/vuln-scanner/SKILL.md + eyebrowlock.json). The paired runtime-infrastructure changes (.github/workflows/aeon.yml, scripts/stage-vuln-scanner.sh, scripts/skill_mode.sh) are in a separate follow-up PR for a maintainer to review, since they touch the fork's trust boundary (Bash(cargo:*) grant, toolchain staging). --- eyebrowlock.json | 10 ++-- skills/vuln-scanner/SKILL.md | 103 +++++++++++++++++++++++++++++++++-- 2 files changed, 104 insertions(+), 9 deletions(-) diff --git a/eyebrowlock.json b/eyebrowlock.json index 4bb37e1da8e..79ca9bf3fba 100644 --- a/eyebrowlock.json +++ b/eyebrowlock.json @@ -1,6 +1,6 @@ { "version": 1, - "generatedAt": "2026-08-08T13:13:45.846218Z", + "generatedAt": "2026-08-10T08:39:05.159387Z", "generator": "eyebrow/0.4.1", "artifacts": [ { @@ -115,7 +115,7 @@ "files": [ { "path": "SKILL.md", - "hash": "b332ee1851dde9b5777f8db5be9d3920bb948a58cea1e6c4d1b4c1885f3074d8" + "hash": "0c1988170a65a2f66fda78abffb38cbdbd295feed8d1cf67f1d4cef41bf64cb0" } ], "findings": [ @@ -142,12 +142,12 @@ "severity": "critical", "owasp": "ASK-01", "file": "SKILL.md", - "line": 704, + "line": 796, "snippet": "1. **Install** — the binaries (`semgrep`, `trufflehog`, `osv-scanner`, `slither`) are **not pre-installed**. Stage the…", "explanation": "downloads and executes remote code via a pipe to a shell" } ], - "contentHash": "sha256-750229f015bc2368ff351ec7929e50c0caecf6b1c40795535295525caec8eb27", + "contentHash": "sha256-73f662c990722830f89065d46c534c9a505c67052e96bd3ce11c734413b57512", "discoveredFrom": "skills/vuln-scanner/SKILL.md" }, { @@ -1495,7 +1495,7 @@ "owasp": "ASK-06", "file": "hook-deploy.sh", "line": 175, - "snippet": "[ -f hook.env ] \u0026\u0026 . ./hook.env", + "snippet": "[ -f hook.env ] && . ./hook.env", "explanation": "references sensitive credential or secret paths" }, { diff --git a/skills/vuln-scanner/SKILL.md b/skills/vuln-scanner/SKILL.md index 18ab9c5ae32..5ed17b78265 100644 --- a/skills/vuln-scanner/SKILL.md +++ b/skills/vuln-scanner/SKILL.md @@ -165,6 +165,96 @@ echo "trufflehog=$([ -s /tmp/vuln-scan/trufflehog.json ] && echo ok || echo fail echo "osv=$([ -s /tmp/vuln-scan/osv.json ] && echo ok || echo fail)" >> /tmp/vuln-scan/sources.txt ``` +### A3.5. Dynamic testing: fuzz it if it already ships a harness + +Static tools never execute the target's code, so they can't catch a bug that only +shows up on a specific malformed input. Some repos already carry their own fuzz +harnesses (`cargo fuzz`) for exactly this. If the clone has one, run it — this is +a different technique from A3, not a better version of it, and it finds a +different class of bug. + +**Scope, on purpose:** Rust + `cargo fuzz` only, for this pass. `stage-vuln-scanner.sh` +installs a nightly toolchain and `cargo-fuzz` for every run (bounded, ~1-2 min: +the runner already has stable Rust) so this step never needs an in-run install — +it degrades to a skip exactly like a missing scanner does. Other ecosystems have +their own fuzzers (libFuzzer/AFL for C/C++, go-fuzz, atheris for Python, Trident +for Solana/Anchor) — worth adding the same way later, each gated on its own +`command -v` guard, but out of scope here. + +**This is a real trade-off, not a free scanner:** semgrep/trufflehog/osv-scanner +only read the target's files. This *compiles and runs* the target's own code +(and whatever it pulls in) inside the sandboxed run. That's the same trust +boundary any CI system already accepts when it builds a repo's test suite — the +runner is ephemeral and only holds this skill's own scoped secrets — but it's a +step up from A3, so it only activates when the repo hands you a harness rather +than probing for one, and it never touches the network beyond what cloning the +repo already did. + +```bash +if [ -d fuzz/fuzz_targets ] && command -v cargo-fuzz >/dev/null 2>&1; then + mkdir -p /tmp/vuln-scan/fuzz + # Seed real inputs where they exist — an empty corpus rarely gets a mutator + # past a magic-byte header, so this is the difference between a shallow run + # and one that reaches real parsing logic. + for target in $(cargo fuzz list 2>/dev/null); do + if [ -d "tests/fixtures" ]; then + mkdir -p "fuzz/corpus/$target" + find tests/fixtures -iname "*.${target}" -exec cp {} "fuzz/corpus/$target/" \; 2>/dev/null + fi + done + + # Bounded: a handful of targets, ~90s each. This is a smoke test for "does + # anything crash immediately," not a real fuzzing campaign — a real one runs + # for hours and belongs to the maintainer's own CI, not a weekly scan. + # + # This step compiles and runs the target's own code (and every dependency's + # build.rs) with network open. Scrub this skill's own secrets from the + # env first — a malicious target could otherwise exfiltrate them at compile + # time via a build script: + n=0 + for target in $(cargo fuzz list 2>/dev/null); do + [ "$n" -ge 8 ] && break + n=$((n + 1)) + env -u GH_TOKEN -u GH_GLOBAL -u RESEND_API_KEY -u RESEND_FROM -u RESEND_REPLY_TO \ + cargo +nightly fuzz run "$target" -- -max_total_time=90 \ + > "/tmp/vuln-scan/fuzz/${target}.log" 2>&1 || true + done + echo "fuzz=$([ -n "$(ls /tmp/vuln-scan/fuzz 2>/dev/null)" ] && echo ok || echo fail)" >> /tmp/vuln-scan/sources.txt +else + echo "VULN_SCANNER_SKIPPED: no fuzz/fuzz_targets or cargo-fuzz unavailable" +fi +``` + +A crash artifact lands at `fuzz/artifacts//crash-*`. Reproduce it clean +before it counts as anything: `env -u GH_TOKEN -u GH_GLOBAL -u RESEND_API_KEY -u RESEND_FROM -u RESEND_REPLY_TO cargo fuzz run fuzz/artifacts//crash-*` +(same secret-scrubbing as the run above — this also compiles and executes the +target's code) and read the actual panic message and call stack, not just the +"deadly signal" summary line. + +**Root-cause it before routing it** — a crash under `fuzz/` can mean three +different things, and they route differently: + +1. **The panic is in the target's own code.** Route it exactly like any other + code vulnerability (A5 table) — PVR if the repo has a channel, out-of-band + contact otherwise. +2. **The panic is in a dependency**, reached through the target's own call path + (check the stack trace — if the crashing frame's crate isn't the one you + cloned, this is it). This happened on the one real run so far: fuzzing + `firecrawl/anydoc`'s `xlsx` target surfaced a crash inside `calamine`, not in + anydoc's own code. Report and, if the fix is small and matches the + dependency's own existing conventions, fix it **in the dependency's repo**, + not the original target — the target's only actionable next step is bumping + a version once one exists. A dependency panic is DoS-only (Rust panics + safely; this is not a memory-safety finding) and, absent a published CVE + already covering it, the fix usually is the disclosure: small, obvious, + reviewable, no exploit chain to redact. A PR is the appropriate channel for + that case even without PVR on the dependency's repo — same logic as A5's + dependency-CVE row, just for a bug you found instead of one already public. +3. **The panic is in the harness itself**, not the parser (an assertion the + fuzz target's own author wrote, a fixture format mismatch, an `unwrap()` on + setup code outside the code path being fuzzed). Not a finding — drop it, + same as a scanner false positive. + ### A4. Triage — read every finding before trusting it A scanner hit is a candidate, not a vulnerability. For each candidate: @@ -193,6 +283,8 @@ This is the core of the scan arm. Pick the channel by finding type: | **Code vulnerability** (Semgrep ERROR/WARNING, verified exploitable) | **PVR** (GitHub private advisory) | Unpatched code flaw — public disclosure creates a zero-day | | **Verified leaked secret** (TruffleHog verified) | **PVR** + tell maintainer to rotate | Publishing the file/line in a public PR tells attackers where to look | | **Smart-contract issue** (Slither high/medium) | **PVR** | On-chain exploitation is often immediate and irreversible | +| **Fuzz crash in the target's own code** | **PVR** | Same as any other code vulnerability — see A3.5 | +| **Fuzz crash in a dependency** | **Public PR to the dependency's repo** (fix, not just a report, if it's small and matches their conventions) | DoS-only, no exploit chain to redact — see A3.5 case 2 | | **No PVR enabled AND no SECURITY.md** | **Private issue** to maintainer if possible, else skip and log | No safe channel = do no harm | #### A5a. Public PR (dependency CVEs only) @@ -345,13 +437,13 @@ Use `./notify`. One paragraph. Lead with the verdict. *Vuln Scanner — * confirmed findings (). Disclosed via: -Scanners: semgrep=, trufflehog=, osv=. +Scanners: semgrep=, trufflehog=, osv=, fuzz=. ``` If the audit was clean: ``` *Vuln Scanner — * -Clean audit. candidates reviewed, 0 confirmed. Scanners: semgrep=ok, trufflehog=ok, osv=ok. +Clean audit. candidates reviewed, 0 confirmed. Scanners: semgrep=ok, trufflehog=ok, osv=ok, fuzz=skip. ``` Then log per the **Log** section below with `Mode: scan`. @@ -674,7 +766,7 @@ specific bullets. - Target: owner/repo (stars, language) - Candidates: N | Confirmed: M - Channels used: PVR (x), public PR (y), skipped (z) -- Scanner status: semgrep=ok trufflehog=ok osv=ok +- Scanner status: semgrep=ok trufflehog=ok osv=ok fuzz=ok|fail|skip - Advisory/PR links: [...] ``` @@ -706,6 +798,8 @@ specific bullets. This two-part fix resolves ISS-001 (binaries installed *and* runnable). If any scanner binary is still missing at runtime, log `VULN_SCANNER_SKIPPED: not available`, record `tool=fail` in `sources.txt`, and continue with the remaining scanners rather than aborting the whole run. An all-scanners-fail run must report **error**, not **clean**. +**A3.5 (fuzz)** follows the same two-part shape, but staging happens in the workflow step, not in-run: `scripts/stage-vuln-scanner.sh` installs a nightly Rust toolchain and `cargo-fuzz` before `claude -p` starts (the sandbox denies toolchain installs in-run, same reason `deploy-uni-hook` stages Foundry the same way — see `scripts/stage-deploy-uni-hook.sh`). Staging is unconditional (same tolerance as slither above — the target isn't known yet at staging time), so it always installs; only *use* is conditional on the clone actually shipping `fuzz/fuzz_targets`. Execution needs `Bash(cargo:*)` in the write tier of `scripts/skill_mode.sh` — broader than the single-purpose scanner grants, because `cargo fuzz` dispatches through the `cargo` binary itself, which also compiles the target's own code (and its build scripts). **This means the target's build scripts run with this skill's own secrets (`GH_GLOBAL`/`GH_TOKEN`, `RESEND_API_KEY`, `RESEND_FROM`, `RESEND_REPLY_TO`) live in the environment and network open — A3.5 scrubs them with `env -u` immediately before both the fuzz run and the crash-reproduction command, and nowhere else in this skill needs that scrub**, since only this step executes the target's own code. If either staging half is missing, the `command -v cargo-fuzz` guard in A3.5 skips cleanly. + **Arm B (re-submit).** `gh api` uses the `GH_TOKEN` env var internally (the workflow wires `GH_GLOBAL` in). If `gh api` fails, use the `curl` fallback in step B2. No outbound auth-required calls except `gh api`. **Arm C (disclose).** The send is an **irreversible** outbound call (a disclosure email), so it runs **in-run as the arm's final action, behind the C4 fail-closed caps**. Make the Resend POST with `./secretcurl` and the `{RESEND_API_KEY}` placeholder — a bare `$RESEND_API_KEY` on the command line is refused by the Bash permission layer. `RESEND_API_KEY` / `RESEND_FROM` / `RESEND_REPLY_TO` are injected in-run via this skill's `requires:`; `RESEND_CC` + the `DISCLOSURE_EMAIL_*` caps are read from the run env. There is no deferred/postprocess step — a failed send is logged (`email-failed` after the attempt cap), not queued to a later runner. @@ -742,8 +836,9 @@ General network rules: `curl` works, with **WebFetch** as the fallback for a pla - **Don't scan the same repo twice in 30 days** (`memory/vuln-scanned.json`). - **Never post exploit chains publicly.** PoCs go in the private advisory, not in a GitHub comment. - **Be deferential in disclosure language** — you're offering help, not grading homework. -- **Public PRs are only for dependency bumps** addressing already-disclosed CVEs. Everything else is private. +- **Public PRs are only for dependency bumps** addressing already-disclosed CVEs, or a fuzz-found bug fixed directly in the dependency that owns it (A3.5 case 2) — everything else is private. - **All-scanners-failed ≠ clean.** Report it as an error and do not publish anything. +- **Fuzzing only activates when the repo already ships a harness.** This skill doesn't write fuzz targets from scratch — that's real engineering work specific to the target's parsing logic, not something to improvise inside a weekly scan. If `fuzz/fuzz_targets` isn't there, `A3.5` skips, same as a missing scanner. **Disclose (Arm C):** - **The arming flag is sacred.** Never queue a draft without `auto_send: true`. If a