| name | vuln-scanner |
| description | 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 |
| metadata | {"title":"Vuln Scanner","category":"dev","var":"","tags":["dev","security","meta"],"depends_on":["github-trending"],"requires":["GH_GLOBAL?","RESEND_API_KEY?","RESEND_FROM?","RESEND_REPLY_TO?"]} |
${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:
SEL="${var}"
ACTION="${SEL%%:*}"
TARGET="${SEL#*:}"; [ "$TARGET" = "$SEL" ] && TARGET=""
case "$ACTION" in
resubmit|watchlist|pvr) ARM="resubmit" ;;
disclose|email) ARM="disclose" ;;
poc-smoke|verify-gate) ARM="poc-smoke" ;;
""|scan) ARM="scan" ;;
*/*) ARM="scan"; TARGET="$SEL" ;;
*) ARM="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:
if [ -s output/.chains/github-trending.md ]; then
:
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
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"
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
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
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=$?
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
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=$?; }
if [ -s /tmp/vuln-scan/osv.json ]; then OSV_STATUS=ok
elif [ "${OSV_RC:-}" = 128 ]; then OSV_STATUS=none
else OSV_STATUS=fail; fi
else
OSV_STATUS=skipped
echo "VULN_SCANNER_SKIPPED: osv-scanner not available"
fi
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
echo "semgrep=$([ -s /tmp/vuln-scan/semgrep.json ] && echo ok || echo fail)" > /tmp/vuln-scan/sources.txt
echo "trufflehog=$([ "${TRUFFLEHOG_RC:-1}" = 0 ] && echo ok || echo fail)" >> /tmp/vuln-scan/sources.txt
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
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
n=0
for target in $(cargo fuzz list 2>/dev/null); do
[ "$n" -ge 8 ] && break