${var} — Action selector, shaped
[<action>][:<owner/repo>]. Empty or a bareowner/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/
- `` → scan, auto-select from trending
openai/whisper→ scanopenai/whisperresubmit→ probe the whole watchlist and re-submit what flippedresubmit:vercel/next.js→ probe just that repo (one-off)disclose(aliaspoc-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"inmemory/vuln-scanned.jsonand 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/withstatus: 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:
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$TARGETif 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:
# 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.mdANDsecurity_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
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.
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.
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:
- 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.
- 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'sxlsxtarget surfaced a crash insidecalamine, 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. - 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:
# 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/evalsinks + 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:
[
{"file":"src/api/user.ts","line":88,"severity":"high","category":"idor",
"claim":"unauthenticated GET /user/:id returns any user's record - no owner check"}
]
# 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:
- Open the file at the reported line and read the surrounding 30–50 lines.
- Write one sentence describing what an attacker controls and what they achieve. If you can't, discard it.
- Check the call path — is the vulnerable function reachable from external input in production code (not tests, docs, examples)?
- 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.
- 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-verifiedsecrets — 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 reviewedchains.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:
{
"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:
# 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:
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 auditedtarget_commit, and a matchingfinding_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)
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)
# 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 (classicreposcope is enough). Critically: the GitHub advisory web form (/security/advisories/new) is the SAME PVR backend — it returns404to external reporters when PVR is off. Do NOT stage that URL as the channel even ifSECURITY.mdrecommends it (aSECURITY.mdthat 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.mdemail / portal / vendor PSIRT; (2) README contact (email / Discord / X); (3) package metadata —pyproject.toml/setup.pyauthor,package.jsonauthor+bugs; (4) the maintainer/owner's git commit email or GitHub profile. Stage a maintainer-ready report atmemory/pending-disclosures/<repo>-<timestamp>.mdin the auto-send-ready format (see below) so the disclose arm (Arm C) can send it, and add a row tomemory/security-watchlist.mdso 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):
--- 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, nobacktickcode 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 theThanks,/ signature — on their own lines.auto_sendrule (this is the only safeguard before a real send):auto_send: trueonly when a validcontact_emailresolved AND the repo does not ban AI-generated security reports (check SECURITY.md — many do).auto_send: falsewhen the only contact is non-email (X/Discord), the email couldn't be validated, or the repo bans AI reports. Afalsedraft waits for the operator to send manually (sethuman_only: truetoo 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 thevulnerabilitiesarray was missing/empty (the create handler crashes instead of returning a clean422; 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 went500 → 201purely by adding the array. Only if a report with a valid non-emptyvulnerabilitiesarray still5xxs is the endpoint genuinely broken for this repo: then (and only then) stage the report inmemory/pending-disclosures/and have the operator file it via the web formhttps://github.com/<repo>/security/advisories/new(a different frontend to the same PVR backend), without retry-spamming. (Contrast the403PVR-disabled case above, where the form404s 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:
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:
{"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)