# Vuln Scanner

> Audit trending repos for real security vulnerabilities and disclose responsibly - scan and route findings (PVR / dependency PR), re-submit queued advisories, and send armed email disclosures

- Skill: `aeonfun/vuln-scanner` (Agent Skill)
- Install (CLI): `npx skillmds@latest add aeonfun/vuln-scanner`
- Raw SKILL.md: https://api.skillmd.com/api/skills/aeonfun/vuln-scanner/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Security
- Author: aeonfun (https://skillmd.com/u/aeonfun)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/aeonfun/vuln-scanner

---

<!-- autoresearch: variation B — responsible-disclosure-first: private reports for code vulns, public PRs only for already-disclosed dep CVEs -->

> **${var}** — Action selector, shaped `[<action>][:<owner/repo>]`. Empty or a bare `owner/repo` → **scan** arm (audit that repo, or auto-select a trending one). `resubmit` / `resubmit:owner/repo` → **re-submit** arm (probe the security watchlist for repos that just enabled PVR and submit any queued advisory). `disclose` / `email` → **disclose** arm (queue armed out-of-band email disclosures for sending). Examples:
> - `` → scan, auto-select from trending
> - `openai/whisper` → scan `openai/whisper`
> - `resubmit` → probe the whole watchlist and re-submit what flipped
> - `resubmit:vercel/next.js` → probe just that repo (one-off)
> - `disclose` (alias `email`) → arm & queue eligible disclosure emails
> - `poc-smoke` → exercise the PoC gate against a benign real Base fork (no audit or disclosure)

Today is ${today}. Read `memory/MEMORY.md` and the last 30 days of `memory/logs/` before starting.

## Why this skill exists

This is the **write / action arm of the vuln-disclosure loop** — one skill covering the full responsible-disclosure lifecycle:

- **Scan** — a security scanner that dumps unpatched vulnerabilities into public PRs is a zero-day publisher, not a helper. This skill matches industry practice: **Private Vulnerability Reporting (PVR) for code flaws, public PRs only for dependency CVEs that are already public**. Bad disclosure burns credibility and puts users at risk.
- **Re-submit** — when a scan finds a HIGH/CRITICAL issue in a repo with no PVR, no `SECURITY.md`, and no reachable contact, it has no safe channel — so it logs the finding as `"channel": "skipped"` in `memory/vuln-scanned.json` and stages a watchlist row. Without a weekly probe those findings silently age until the responsible-disclosure window closes. The re-submit arm closes that loop.
- **Disclose** — when the only responsible path is a private email to the maintainer, drafts sit in `memory/pending-disclosures/` with `status: pending-operator-send`, waiting for a human. The disclose arm finds drafts **explicitly armed for auto-send**, composes the email, and **sends it in-run** (Resend via `./secretcurl`) behind a set of fail-closed caps — the send is the arm's final action.

## Dispatch — parse `${var}`, then run one arm

Parse the selector once, then jump to the matching arm below:

```bash
SEL="${var}"                     # the raw selector
ACTION="${SEL%%:*}"              # token before ':' (or the whole thing)
TARGET="${SEL#*:}"; [ "$TARGET" = "$SEL" ] && TARGET=""   # token after ':' (empty if no ':')

case "$ACTION" in
  resubmit|watchlist|pvr)   ARM="resubmit" ;;            # → Arm B
  disclose|email)           ARM="disclose" ;;            # → Arm C
  poc-smoke|verify-gate)    ARM="poc-smoke" ;;           # → Arm D
  ""|scan)                  ARM="scan" ;;                # → Arm A (auto-select if TARGET empty)
  */*)                      ARM="scan"; TARGET="$SEL" ;; # bare owner/repo → scan that repo
  *)                        ARM="scan" ;;                # unknown → default to scan
esac
```

- `ARM=scan` → **Arm A — SCAN** (target = `$TARGET`, or auto-select if empty).
- `ARM=resubmit` → **Arm B — RE-SUBMIT** (probe `$TARGET` if set, else the whole watchlist).
- `ARM=disclose` → **Arm C — DISCLOSE** (queue armed email drafts).
- `ARM=poc-smoke` → **Arm D — PoC GATE SMOKE** (benign live-fork verification only).

Each arm is independently executable. The operational arms share the same GitHub token and the same `memory/` state (`vuln-scanned.json`, `security-watchlist.md`, `pending-disclosures/`, `email-log.json`) — that shared state is exactly how the arms hand off to each other. The smoke arm does not read or modify that state.

---

## Arm A — SCAN

Find one trending repo, run purpose-built scanners (not raw grep), triage to real exploitable findings, and route each finding to the correct disclosure channel — PVR, `SECURITY.md` contact, or dependency-bump PR.

### A1. Pick a target

If `$TARGET` is set, use it. Otherwise:

```bash
# Prefer chained output from github-trending skill
if [ -s output/.chains/github-trending.md ]; then
  # parse owner/repo lines; pick first that matches criteria below
  :
else
  gh api "search/repositories?q=created:>$(date -u -d '14 days ago' +%Y-%m-%d)&sort=stars&order=desc&per_page=25" \
    --jq '.items[] | select(.fork==false) | select(.stargazers_count>=50) | {full_name, language, description, security_and_analysis}'
fi
```

Selection criteria:
- Language you can reason about (JS/TS, Python, Go, Rust, Solidity)
- ≥50 stars, not a fork, active in last 6 months
- Handles untrusted input: auth, crypto, network, file I/O, templating
- **Skip** if scanned in last 30 days (grep `memory/logs/` for the repo name)
- **Skip** deliberately vulnerable teaching repos (DVWA, juice-shop, webgoat, vulnerable-*, *-ctf, hackme-*)
- **Skip** repos with no `SECURITY.md` AND `security_and_analysis.private_vulnerability_reporting.status != "enabled"` — you have no safe channel to report code flaws (you can still run a dep-scan and skip code audit; see step A5)

### A2. Fork and clone

```bash
REPO="owner/repo"
gh repo fork "$REPO" --clone --default-branch-only -- --depth 200 --quiet
cd "$(basename "$REPO")"
```

### A3. Run purpose-built scanners

Raw grep produces too many false positives. Use tools with dataflow reachability and verified-secret matching.

Stage the scanners **in-run** into `/tmp/bin` (see the install preamble below). The
network is open, but `pip install` / `curl | sh` / `tar` are **not** on the in-run
capability allowlist — use the ones that are: `python3 -m pip install …` for the Python
tools (semgrep, slither) and `curl -o … && chmod +x` for the Go binaries (osv-scanner,
trufflehog). Put `/tmp/bin` on `PATH` and invoke
each tool by **bare name** — the bare names (`semgrep`, `trufflehog`,
`osv-scanner`, `slither`) are exactly what the capability allowlist
(`scripts/skill_mode.sh`) grants, so `claude -p` is permitted to execute them. If
a binary is missing, log `VULN_SCANNER_SKIPPED` and continue (it records `fail`
in `sources.txt` below) — never abort the whole run for one tool.

```bash
mkdir -p /tmp/vuln-scan /tmp/bin
export PATH="/tmp/bin:$PATH"
# Stage the scanners IN-RUN, best-effort, using ONLY allow-listed commands (network is
# open, but `pip install` / `curl | sh` / `tar` are NOT allow-listed — `python3 -m pip`,
# `curl -o`, `chmod`, `npm`/`npx`, `node` ARE). Wrap each in `|| true`; any tool that fails
# to stage is skipped by the `command -v` guards below (records fail), never fatal:
python3 -m pip install --quiet --disable-pip-version-check semgrep slither-analyzer 2>/dev/null || true
curl -sSL -o /tmp/bin/osv-scanner "https://github.com/google/osv-scanner/releases/latest/download/osv-scanner_linux_amd64" 2>/dev/null && chmod +x /tmp/bin/osv-scanner || true
# trufflehog: stage its release binary the same way if a raw asset exists; else it's skipped below.

# --- SAST: Semgrep OSS ---
if command -v semgrep >/dev/null 2>&1; then
  semgrep --config=p/security-audit --config=p/owasp-top-ten --config=p/secrets \
    --severity=ERROR --severity=WARNING --json --quiet --timeout=300 \
    --exclude=test --exclude=tests --exclude=__tests__ --exclude=spec --exclude=specs \
    --exclude=fixtures --exclude=examples --exclude=example --exclude=demo \
    --exclude=vendor --exclude=node_modules --exclude=dist --exclude=build --exclude=.next \
    -o /tmp/vuln-scan/semgrep.json . 2>/dev/null || true
else
  echo "VULN_SCANNER_SKIPPED: semgrep not available"
fi

# --- Secrets: TruffleHog (only-verified = actually authenticates) ---
if command -v trufflehog >/dev/null 2>&1; then
  TRUFFLEHOG_RC=0
  trufflehog filesystem . --only-verified --json \
    > /tmp/vuln-scan/trufflehog.json 2>/dev/null || TRUFFLEHOG_RC=$?
  # Also scan full git history for secrets — BOUNDED. An unbounded `trufflehog git`
  # walks every commit's every tree, and a large packed history (measured: 200
  # commits / ~369MB on one real run) can eat the whole turn budget by itself,
  # with nothing to show it happened until the run reports "success" anyway
  # having produced no report at all. `timeout` turns that silent budget-burn
  # into an ordinary, honestly-recorded `fail` — same as an install failure,
  # never a reason to write a "still running, will resume" placeholder as the
  # final output. There is no resume: a workflow_dispatch run is one shot, and
  # a note promising to pick back up later is not truthful about what a single
  # run can actually do.
  TRUFFLEHOG_GIT_RC=0
  timeout 300 trufflehog git file://. --only-verified --json \
    > /tmp/vuln-scan/trufflehog-git.json 2>/dev/null || TRUFFLEHOG_GIT_RC=$?
  [ "$TRUFFLEHOG_GIT_RC" = 124 ] && echo "VULN_SCANNER_TIMEOUT: trufflehog git history scan exceeded 300s on a large packed history — recorded as fail, not retried, not left unfinished"
else
  echo "VULN_SCANNER_SKIPPED: trufflehog not available"
fi

# --- Dependencies: osv-scanner (unified CVE DB across ecosystems) ---
# osv-scanner v2 (what `releases/latest` now installs, 2.4.x) moved scanning under the
# `scan source` subcommand. The v1 bare form (`osv-scanner --recursive .`) still works on
# 2.x, so try v2 first and fall back to v1 only if v2 wrote NOTHING (keyed on emptiness,
# NOT exit code - osv exits 1 when it FINDS vulns, which must not read as a syntax error).
# `--no-ignore` is REQUIRED: v2 `scan source` respects .gitignore by default, so a target
# repo that gitignores its (committed) lockfile - common for libraries/tools - yields the
# misleading "No package sources found" (exit 128) and zero dependency coverage. A shipped-
# but-gitignored lockfile still describes real deps, so a security scan must read it. It is
# a no-op when lockfiles are tracked.
if command -v osv-scanner >/dev/null 2>&1; then
  osv-scanner scan source --recursive --no-ignore --format=json . > /tmp/vuln-scan/osv.json 2>/dev/null; OSV_RC=$?
  [ -s /tmp/vuln-scan/osv.json ] || { osv-scanner --format=json --recursive --no-ignore . > /tmp/vuln-scan/osv.json 2>/dev/null; OSV_RC=$?; }
  # Classify: exit 128 = "No package sources found" = the repo has no lockfiles/manifests
  # to scan. That is a clean N/A (nothing to do), NOT a scan failure - osv writes an EMPTY
  # file in that case, so `[ -s ]` alone would mislabel it `fail`. Distinguish the states:
  if [ -s /tmp/vuln-scan/osv.json ]; then OSV_STATUS=ok        # ran; results present (0 or N dep CVEs)
  elif [ "${OSV_RC:-}" = 128 ];   then OSV_STATUS=none         # ran; no dependency lockfiles -> n/a
  else                                 OSV_STATUS=fail; fi      # genuine tool error
else
  OSV_STATUS=skipped
  echo "VULN_SCANNER_SKIPPED: osv-scanner not available"
fi

# --- Smart-contract scan (if Solidity present) ---
if ls **/*.sol >/dev/null 2>&1 && command -v slither >/dev/null 2>&1; then
  slither . --json /tmp/vuln-scan/slither.json --exclude-informational --exclude-low 2>/dev/null || true
fi

# Record what succeeded (empty output ≠ clean, could be tool failure)
echo "semgrep=$([ -s /tmp/vuln-scan/semgrep.json ] && echo ok || echo fail)" >  /tmp/vuln-scan/sources.txt
# TruffleHog JSON is finding-only: an exit-0 empty stream is a clean scan.
echo "trufflehog=$([ "${TRUFFLEHOG_RC:-1}" = 0 ] && echo ok || echo fail)" >> /tmp/vuln-scan/sources.txt
# Recorded separately from the filesystem pass above: they can genuinely diverge
# (filesystem scan clean and fast, git-history scan timed out on a large packed
# repo, or vice versa) and collapsing both into one trufflehog= line hides
# whichever one actually failed.
if [ "${TRUFFLEHOG_GIT_RC:-1}" = 124 ]; then
  echo "trufflehog-git=timeout"                                                  >> /tmp/vuln-scan/sources.txt
elif [ "${TRUFFLEHOG_GIT_RC:-1}" = 0 ]; then
  echo "trufflehog-git=ok"                                                       >> /tmp/vuln-scan/sources.txt
else
  echo "trufflehog-git=fail"                                                     >> /tmp/vuln-scan/sources.txt
fi
echo "osv=${OSV_STATUS:-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/<target>/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 <target> fuzz/artifacts/<target>/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.

### A3.6. Agentic logic audit (what SAST and fuzzing both miss)

Semgrep matches syntactic patterns and has weak dataflow reachability on custom code; fuzzing (A3.5) only reaches what a harness already drives. Both are blind to **authorization, business-logic, and multi-step trust-boundary** bugs. That whole class is what an agentic reviewer catches - and here **you are the agentic scanner**. Do the source-to-sink reasoning the tools can't, over this repo's real entrypoints. This pass runs on every scan (unlike A3.5, which only fires when the repo ships a fuzz harness) and produces *candidates*, not verdicts - everything still goes through A4 triage (the model surfacing a finding is not evidence it is real).

**Bounded so it can't run away on run time.** Size the repo first, then set the entrypoint review budget `N` from it - deep-review the **top-N highest-exposure** entrypoints only, and note the rest in the A7 report as reviewed-but-not-deep so coverage stays honest:

```bash
# Cheap size probe (excludes the usual noise dirs). Drives the review budget below.
CODE_FILES=$(find . -type f \( -name '*.js' -o -name '*.ts' -o -name '*.jsx' -o -name '*.tsx' \
  -o -name '*.py' -o -name '*.go' -o -name '*.rs' -o -name '*.sol' -o -name '*.rb' \
  -o -name '*.java' -o -name '*.php' \) \
  -not -path '*/node_modules/*' -not -path '*/vendor/*' -not -path '*/dist/*' \
  -not -path '*/build/*' -not -path '*/.git/*' 2>/dev/null | wc -l | tr -d ' ')
if   [ "${CODE_FILES:-0}" -le 300 ];  then N=15   # small repo - review broadly
elif [ "${CODE_FILES:-0}" -le 1500 ]; then N=10
else                                       N=6; fi # large repo - top exposure only
echo "agentic-budget: CODE_FILES=$CODE_FILES N=$N"
```

**0. Frame the threat model first (one paragraph, before you enumerate).** State what this app is, the 2-3 things an attacker most wants from it (RCE, auth bypass / IDOR, secret/data exfil, SSRF into internal infra), and its trust boundaries (who is authenticated where, what input is server- vs user-controlled). This targets the ranking in step 2 so the top-`N` budget lands on what actually matters, not just the first entrypoints you find. Keep it to a few lines; it is the plan, not a deliverable.

**1. Build the entrypoint inventory** - every place untrusted input enters. Grep + read to enumerate; record `file:symbol` and a `kind` for each:

- HTTP / route handlers, API endpoints, GraphQL resolvers, webhook receivers
- CLI arg + env parsing
- Deserializers (JSON / YAML / pickle / XML), file uploads, path handling (traversal)
- Template rendering / HTML construction (XSS), SQL / NoSQL query building (injection)
- `exec` / `spawn` / `system` / `eval` sinks + subprocess with string interpolation (RCE)
- Auth / session / token / crypto code (authz bypass, IDOR, weak crypto)
- Outbound network clients + redirect handling (SSRF, open redirect)

**2. Rank by exposure, deep-review the top N.** Order entrypoints by attack surface **against the step-0 threat model** (unauthenticated + reachable + dangerous-sink, weighted toward what the attacker most wants, first). For the top `N`: trace source-to-sink - what the attacker controls, where it flows, the sink, and the guard (if any) between. Write the one-sentence attacker-control claim (the A4 bar). Prioritize reachable **production** paths; ignore tests/examples/docs. Note entrypoints past `N` in the A7 report as not-deep-reviewed - do not silently drop them.

**3. Emit candidates** in the same shape the tool outputs feed A4. Write one JSON array (may be `[]`) to `/tmp/vuln-scan/agentic.json` with the **Write** tool:

```json
[
  {"file":"src/api/user.ts","line":88,"severity":"high","category":"idor",
   "claim":"unauthenticated GET /user/:id returns any user's record - no owner check"}
]
```

```bash
# after writing /tmp/vuln-scan/agentic.json, record the source status:
echo "agentic=ok" >> /tmp/vuln-scan/sources.txt   # 0 candidates on a reviewed surface is still `ok`;
                                                  # use `agentic=skipped` only if the repo is unreadable/opaque
                                                  # (minified-only, generated, no source you can reason about)
```

**Optional - codex-security as an extra source.** If `OPENAI_API_KEY` is present **and** `npx` is allow-listed and the CLI is staged, `npx @openai/codex-security scan . --json` produces an independent agentic `findings.json`; merge its findings into `/tmp/vuln-scan/agentic.json` and record `codex=ok`. **Off by default** - it needs Node 22.13+, model credits, and an allow-listed `npx`; the Claude-native pass above is the baseline and needs no new infra. (Verify the subcommand/flags against the installed version first - it is early 0.1.x and churns.)

### A4. Triage — read every finding before trusting it

A scanner hit - or a candidate from the A3.6 agentic pass - is a candidate, not a vulnerability. Merge the array in `/tmp/vuln-scan/agentic.json` (if present) into the tool findings, then for each candidate:

1. **Open the file at the reported line** and read the surrounding 30–50 lines.
2. **Write one sentence** describing what an attacker controls and what they achieve. If you can't, discard it.
3. **Check the call path** — is the vulnerable function reachable from external input in production code (not tests, docs, examples)?
4. **Provisional severity**: critical (RCE, auth bypass, secret exposure), high (SQLi, stored XSS, SSRF, path traversal), medium (reflected XSS, weak crypto, missing rate limit). A model or scanner does not get to finalize HIGH/CRITICAL by classification alone — apply A4.5 first.
5. **Run the PoC-verification gate for every provisional HIGH/CRITICAL code finding**, then assign the disclosure channel per step A5. The published-advisory and verified-secret exceptions are defined in A4.5.

Drop the finding if:
- It's in `test/`, `mock/`, `fixture/`, `example/`, `demo/`, `bench/`, `docs/`
- It's behind a feature flag not enabled by default
- It requires attacker privileges equal to or greater than the attack yields
- You'd be embarrassed to defend it to the maintainer

If 0 findings survive triage → log "clean audit — N candidates reviewed, 0 confirmed" and exit cleanly.

### A4.5. PoC-verification gate — required before HIGH/CRITICAL

This gate exists because a plausible pattern plus a hand-written narrative can still
be wrong. A code finding may be called **HIGH** or **CRITICAL**, counted as confirmed,
or routed to disclosure only after an executable verifier reproduces the exact
attacker-controls → attacker-achieves claim against the audited commit. “The test
compiled,” “Foundry ran,” or “the scanner rated it high” are not sufficient.

Two evidence classes do not need a new PoC:

- **Published dependency CVEs** — quote the severity from the linked GHSA/OSV record;
  this is an already-public advisory, not an original severity claim.
- **TruffleHog `--only-verified` secrets** — the scanner has already authenticated the
  credential. Still assess blast radius honestly; “valid” does not automatically mean
  Critical.

Every other provisional HIGH/CRITICAL code finding must use
`./scripts/vuln-poc-gate.sh` from the Aeon checkout (the directory that
contains that script), not from inside the A2 clone. The write-tier
allowlist is `Bash(./scripts/vuln-poc-gate.sh:*)`. Pass the clone path as
`--repo`. The gate supports:

- **`foundry`** — mandatory for Solidity/on-chain findings that depend on live state.
  It resolves a public RPC from deploy-uni-hook's reviewed `chains.tsv`, reads the real
  chain id and current block, pins the fork to that block, stages the private test only
  for the command, and removes it afterward.
- **`command`** — a deterministic local regression/reproduction script for non-Solidity
  findings. It must exit 0 only when the claimed security boundary is actually crossed.
  Never point it at a production service or third-party live target.

Both modes run target code in a clean, allowlisted environment so GitHub,
disclosure, and harness-provider credentials are not inherited. Raw PoC source and
tool output stay under `/tmp/vuln-scan/`; the public workflow log
gets only a redacted verifier verdict. Never print or commit the PoC for an unpatched
finding.

#### 1. Bind the claim to the audited commit

Create `/tmp/vuln-scan/poc/<id>.json` (use an opaque id — no vulnerable symbol or
file name) with the Write tool:

```json
{
  "id": "finding-1",
  "target_repo": "owner/repo",
  "target_commit": "<git rev-parse HEAD>",
  "severity": "high",
  "attacker_controls": "<specific input or capability, at least 10 chars>",
  "attacker_achieves": "<specific boundary crossed, at least 10 chars>"
}
```

The runner rejects a changed commit, an underspecified claim, or any severity other
than `high`/`critical`. Its result includes the SHA-256 of this exact claim file, so do
not edit the finding after verification; re-run the gate if the claim changes.

#### 2a. Solidity: verify against a pinned fork

Write a minimal Foundry test to `/tmp/vuln-scan/poc/<id>.t.sol`. Its `test_poc_*`
function must assert the prohibited outcome — attacker profit/ownership, bypassed
authorization, invariant loss, or other concrete impact — not merely “the call did
not revert.” Then run:

```bash
# A2 left cwd inside the clone. The gate script lives in the Aeon repo.
CLONE="$PWD"
cd "${GITHUB_WORKSPACE}"
./scripts/vuln-poc-gate.sh foundry \
  --finding /tmp/vuln-scan/poc/finding-1.json \
  --repo "$CLONE" \
  --test-file /tmp/vuln-scan/poc/finding-1.t.sol \
  --chain base \
  --match-contract AeonPoC \
  --match-test '^test_poc_'
```

Use the chain the affected deployment actually lives on. A passing test on a blank
local chain does not verify a real-state claim. The runner records the observed chain
id and fork block in `/tmp/vuln-scan/poc-results/<id>.json`.

#### 2b. Other code: deterministic local verifier

Write `/tmp/vuln-scan/poc/<id>.sh` so it sets up only local fixtures, exercises the
real production entrypoint, checks the concrete prohibited outcome, and exits nonzero
when the claim is not reproduced. Then run:

```bash
CLONE="$PWD"
cd "${GITHUB_WORKSPACE}"
./scripts/vuln-poc-gate.sh command \
  --finding /tmp/vuln-scan/poc/finding-1.json \
  --repo "$CLONE" \
  --script /tmp/vuln-scan/poc/finding-1.sh
```

Do not weaken assertions until the script turns green. A failed reproduction is
evidence against the claim, not an obstacle to work around.

#### 3. Decide

- Result has `verdict: "verified"`, the audited `target_commit`, and a matching
  `finding_sha256` → the claim may retain HIGH/CRITICAL and proceed to A5.
- Missing toolchain, unavailable fork state, failed test, commit/hash mismatch, or no
  safe deterministic verifier → mark the candidate `needs-verification`; do **not**
  count it as confirmed, do not send/file anything, and surface it to the operator.
- Do not automatically relabel a failed HIGH claim as MEDIUM just to bypass the gate.
  Assign Medium only when the independently supported impact really is Medium.

### A5. Route each finding to the correct disclosure channel

This is the core of the scan arm. Pick the channel by finding type:

| Finding type | Channel | Why |
|---|---|---|
| **Dependency CVE** (osv-scanner hit) | **Public PR** bumping the dep | CVE is already public; a patch PR is net-positive |
| **Code vulnerability** (Semgrep/agentic, A4.5 verifier passed where HIGH/CRITICAL) | **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 candidate; Foundry fork verifier passed for HIGH/CRITICAL) | **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)

```bash
git checkout -b security/bump-<pkg>-<cve>
# Update lockfile/manifest
git add -A
git commit -m "fix(deps): bump <pkg> to patch <CVE-YYYY-NNNN>

Advisory: <link to GHSA or NVD>
Severity: <high/critical>
Fixed in: <version>"
git push -u origin HEAD
gh pr create --repo "$REPO" \
  --title "fix(deps): bump <pkg> to patch <CVE-YYYY-NNNN>" \
  --body "$(cat <<EOF
Automated dependency bump to address a disclosed CVE.

- **CVE:** <id>
- **Advisory:** <url>
- **Severity:** <severity>
- **Package:** \`<name>\` → \`<fixed-version>\`

Detected by [osv-scanner](https://google.github.io/osv-scanner/). No code changes outside the lockfile/manifest.

---
Filed by [Aeon](https://github.com/aeonframework/aeon).
EOF
)"
```

#### A5b. Private Vulnerability Report (code flaws, verified secrets, contract bugs)

```bash
# Private third-party reporting uses the /reports endpoint. Do NOT use the bare
# /security-advisories endpoint — that *creates* an advisory and requires
# admin/security-manager rights on the target repo, so it returns 403 on any repo
# you don't own. Classic `repo` scope is sufficient for /reports;
# `repository_advisories:write` is NOT required for third-party reporting.
#
# ⚠️ CRITICAL: the payload MUST include a non-empty `vulnerabilities` array.
# The REST docs mark it "optional", but the create handler returns **HTTP 500
# (empty body)** when it is omitted. This single bug is why every bare-API PVR
# in this project historically failed and got routed to the web form — the form
# only works because it always collects "affected products" (= vulnerabilities).
# Verified 2026-06-26: identical {summary,description} payload → 500 without the
# array, 201 with it. Always send at least one {package:{ecosystem,name}}.
#
# Write the advisory markdown to /tmp/pvr-body.md first (Summary / Impact /
# Location / Proof / Suggested fix / Detected by), then build the JSON payload
# (jq -Rs safely encodes the multi-line body) and POST it via --input:
cat > /tmp/pvr.json <<JSON
{
  "summary": "<short title>",
  "description": $(jq -Rs . < /tmp/pvr-body.md),
  "severity": "<critical|high|medium|low>",
  "cwe_ids": ["CWE-89"],
  "vulnerabilities": [
    { "package": { "ecosystem": "pip", "name": "<pkg-or-repo-name>" } }
  ]
}
JSON
# ecosystem ∈ pip|npm|go|maven|nuget|composer|rubygems|rust|erlang|actions|pub|swift|other
gh api -X POST "/repos/$REPO/security-advisories/reports" \
  -H "X-GitHub-Api-Version: 2022-11-28" --input /tmp/pvr.json
```

**Always POST via `--input <file>`, never a long inline heredoc / `-f description="$(cat …)"`** — the latter can trip Claude Code's Bash command analyzer ("Unhandled node type: string"), and `vulnerabilities` is a nested array that `-f`/`-F` can't express cleanly. Write the full JSON payload (`{summary, description, severity, cwe_ids, vulnerabilities}` — `vulnerabilities` is **mandatory**, see the ⚠️ note above) to a temp file and `gh api -X POST … --input payload.json`.

Read the HTTP response code and branch accordingly. **Never** fall back to a public issue or a code-fix PR for an *unpatched* flaw (that publishes a zero-day):
- **`201`** → reported. Record the report/advisory id and link it in the local report.
- **`403 "Repository does not have private vulnerability reporting enabled"`** → PVR is OFF on the repo. This is **not** a token-scope problem (classic `repo` scope is enough). **Critically: the GitHub advisory web form (`/security/advisories/new`) is the SAME PVR backend — it returns `404` to external reporters when PVR is off. Do NOT stage that URL as the channel even if `SECURITY.md` recommends it** (a `SECURITY.md` that only says "use the advisory form" is *not* a usable channel when PVR is disabled — confirmed on agent-reach and world-of-claudecraft, 2026-06-19). Resolve an **out-of-band** private contact instead, in this order: (1) `SECURITY.md` email / portal / vendor PSIRT; (2) README contact (email / Discord / X); (3) package metadata — `pyproject.toml` / `setup.py` author, `package.json` `author` + `bugs`; (4) the maintainer/owner's git commit email or GitHub profile. Stage a maintainer-ready report at `memory/pending-disclosures/<repo>-<timestamp>.md` in the **auto-send-ready format** (see below) so the **disclose arm** (Arm C) can send it, and add a row to `memory/security-watchlist.md` so the **re-submit arm** (Arm B) will re-check PVR status. Only if no out-of-band contact exists anywhere, log "no safe channel — skipped".

  **Auto-send-ready draft format** (consumed by Arm C's in-run send):

  ```markdown
  ---
  repo: owner/repo
  severity: <critical|high|medium|low>
  cwe: CWE-NN
  status: pending-operator-send
  auto_send: <true|false>            # ARMING GATE — see the rule below
  contact_email: maintainer@example.com
  cc: [security@example.com]         # optional — if SECURITY.md says "email X, cc Y/Z"
  email_subject: "Security: <short title>"
  detected_at: <ISO-8601>
  ---

  # Staged private disclosure — owner/repo
  <operator-facing notes: contact resolution, why private — NOT emailed>

  <!-- EMAIL-BODY-START -->
  Hi <name>,
  <the exact private message: where / the issue / why it matters / severity /
  suggested fix / offer to share a patch>
  Thanks,
  Aeon (https://github.com/aeonframework/aeon)
  <!-- EMAIL-BODY-END -->
  ```

  **Write the EMAIL-BODY as PLAIN TEXT — it is sent as a plain-text email, so any
  Markdown renders literally to the maintainer.** No `**bold**`, no `#` headings, no
  `backtick` code spans, no `[text](url)` links. Use plain prose; label sections with
  plain words and a colon (`Where:` not `**Where:**`); paste bare URLs; keep code or
  argv samples as plain indented lines (those read fine in plain text). Only the
  EMAIL-BODY block needs this — the operator-facing notes above it may use Markdown.
  Do **not** hard-wrap paragraphs mid-sentence: write each paragraph as one line,
  separated by a blank line (the sender also auto-de-wraps soft-wrapped lines, but
  authoring them unwrapped keeps the draft clean). Keep deliberate short breaks — the
  greeting and the `Thanks,` / signature — on their own lines.

  **`auto_send` rule (this is the only safeguard before a real send):**
  - `auto_send: true` **only when** a valid `contact_email` resolved **AND** the repo does **not** ban AI-generated security reports (check SECURITY.md — many do).
  - `auto_send: false` when the only contact is non-email (X/Discord), the email couldn't be validated, or the repo bans AI reports. A `false` draft waits for the operator to send manually (set `human_only: true` too if the ban is explicit). Never arm a draft you'd be uncomfortable auto-sending.
- **`500` (empty body) on a PVR-enabled repo** → in this project this has **always** meant the **`vulnerabilities` array was missing/empty** (the create handler crashes instead of returning a clean `422`; see the ⚠️ note above). This is fixable **in-band, not a reason to fall back**: ensure the payload carries at least one `{package:{ecosystem,name}}` and re-POST once. Verified 2026-06-26 — the same body went `500 → 201` purely by adding the array. Only if a report **with** a valid non-empty `vulnerabilities` array *still* `5xx`s is the endpoint genuinely broken for this repo: then (and only then) stage the report in `memory/pending-disclosures/` and have the operator file it via the web form `https://github.com/<repo>/security/advisories/new` (a different frontend to the same PVR backend), **without** retry-spamming. (Contrast the `403` PVR-*disabled* case above, where the form `404`s too — route to an out-of-band contact instead.)
- Any other failure → stage in `memory/pending-disclosures/` and surface to the operator; never publish.

**Dependency-bump PRs (step A5a) are the only public channel.** Hardening-class code findings (e.g. DNS-rebinding / Host-Origin allowlists) *may* be offered as a neutral public PR at operator discretion, but high-severity exploitable flaws (RCE, auth bypass, secret exposure, sandbox/guardrail escape) must stay on a private channel.

#### A5c. Proposed code patch (optional, paired with A5b)

If you have a minimal fix, push it to **your fork only** (not a PR to upstream) and link it in the PVR description so the maintainer can cherry-pick:

```bash
git checkout -b private/fix-<slug>
# apply fix
git commit -m "draft: proposed patch for reported advisory"
git push -u origin HEAD
# DO NOT open a PR. Link the branch in the advisory body.
```

### A6. Update dedup state

Append to `memory/vuln-scanned.json` (create if missing) so future runs skip this repo for 30 days:

```json
{"repo": "owner/repo", "scanned_at": "2026-04-20T16:00:00Z", "findings": <N>, "channel": "pvr|public-pr|skipped"}
```

### A7. Write local report

**There is no resume.** The git-history pass has a timeout; this does not bound
every other scanner or installation step. If you are running low on turns, finish
the report with whatever scanners actually completed, record the rest `fail` in
`sources.txt` (§A3's rule: unfinished is `fail`, not a pending state), and write
A7/A8 now. A single `workflow_dispatch` run is one shot with no continuation —
writing "still running, will pick this up automatically" as the final output is
not true of this run path (it was live-observed: a run reported workflow
`success` having written that sentence instead of a report, with no ledger entry
at all — the operator had to notice and re-dispatch by hand). A shorter, honest
report with some scanners marked `fail` is a completed task; a promise to
resume is not.

Save to `output/articles/vuln-scan-${today}.md` with sections for: repo metadata, scanner sources (ok/fail per tool — `trufflehog` and `trufflehog-git` are two separate rows, not one; folding a timed-out history scan into a clean filesystem-scan's `ok` is exactly the silent-masking this split exists to prevent), candidate count, confirmed findings with severity and channel, PoC gate status (`verified` with verifier/chain/block, `not-required` with reason, or `needs-verification`), and dedup note. Do **not** include exploit details for findings disclosed via PVR — redact file/line and link to the advisory ID instead.

Copy each scanner status from `sources.txt` into the report, notification and log.
Keep `trufflehog` (filesystem) 

…(truncated)
