- 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?"]}
<!-- 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)
> - `riva:owner/repo` → scan with the Riva research kernel (shadow/proposal only)
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
riva|research) ARM="scan"; KERNEL="riva" ;; # → Arm A, Riva kernel
shadow|compare) ARM="scan"; KERNEL="shadow" ;; # → Arm A, private comparison
""|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
# Legacy remains the safe default while Riva is evaluated. `shadow` produces a
# private comparison artifact; only an explicit `riva` selector or a reviewed
# environment override selects it for a scan. Neither mode changes disclosure
# authority or bypasses A4/A4.5.
KERNEL="${KERNEL:-${VULN_RESEARCH_KERNEL:-legacy}}"
case "$KERNEL" in legacy|shadow|riva) ;; *) KERNEL="legacy" ;; 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
CANDS=""
if [ -s output/.chains/github-trending.md ]; then
# Parse owner/repo targets. Accept BOTH the markdown form [owner/repo](url) and a
# bare github.com/owner/repo permalink (a feeder degraded under read-only can emit
# the latter). A repo name with no owner is NOT a candidate. Pick the first CANDS
# entry that matches the criteria below.
CANDS=$(grep -oE '\[[^]]+/[^]]+\]\(https?://[^)]+\)|https?://github\.com/[^/ )]+/[^/ )]+' output/.chains/github-trending.md \
| sed -E 's#.*github\.com/##; s#\).*##; s#^\[##; s#\].*##' | grep -E '^[^/ ]+/[^/ ]+$' | sort -u)
fi
# If the feed was absent OR present-but-unparseable (zero owner/repo lines, e.g. a
# header-only / prose-only notify body), do NOT stop here - that starves the scan.
if [ -z "$CANDS" ]; then
# Shadow runs have no GitHub credentials by design. A bare shadow selector
# may consume the chained trending output above, but otherwise must fail
# closed and be retried as shadow:owner/repo.
if [ "$KERNEL" = shadow ]; then
echo "VULN_SCANNER_SKIPPED shadow-target-required: use shadow:owner/repo or provide github-trending chain output"
exit 0
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
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"
if [ "$KERNEL" = shadow ]; then
git clone --depth 200 --quiet "https://github.com/${REPO}.git"
else
gh repo fork "$REPO" --clone --default-branch-only -- --depth 200 --quiet
fi
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` / a curl-piped-to-shell install / `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` / a curl-piped-to-shell install / `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
# BOTH passes are bounded. Run these verbatim; if you invoke trufflehog (or any
# scanner) yourself, it still needs the `timeout 300` wrapper and must not be
# backgrounded with `&` — a scan still running when you write the report is a
# scan you did not finish, and a one-shot workflow_dispatch run cannot resume it.
TRUFFLEHOG_RC=0
timeout 300 trufflehog filesystem . --only-verified --json \
> /tmp/vuln-scan/trufflehog.json 2>/dev/null || TRUFFLEHOG_RC=$?
[ "$TRUFFLEHOG_RC" = 124 ] && echo "VULN_SCANNER_TIMEOUT: trufflehog filesystem scan exceeded 300s on a very large tree — recorded as fail, not retried, not left unfinished"
# 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.
if [ "${TRUFFLEHOG_RC:-1}" = 124 ]; then
echo "trufflehog=timeout" >> /tmp/vuln-scan/sources.txt
else
Ver no GitHub