[2/4 defending-code] Static source-code vulnerability scan. Second step of the find-and-fix loop (/threat-model -> /vuln-scan -> /triage -> /patch), and the usual entry point when no threat model exists. Reads a target directory (and THREAT_MODEL.md if present), spawns parallel review subagents per focus area, and writes VULN-FINDINGS.json + .md for /triage to consume. Read-only — no building, running, or network. For execution-verified crashes (build + run + sanitizer), see HARNESS.md. Use w...
Scanned 9/3/2026
Install to Claude Code
npx -y skills add air-gapped/skills --skill vuln-scan --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Vuln Scan?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/air-gapped-vuln-scan)More formats (shields.io, HTML) on the badges page.
---
name: vuln-scan
description: >-
[2/4 defending-code] Static source-code vulnerability scan. Second step of
the find-and-fix loop (/threat-model -> /vuln-scan -> /triage -> /patch),
and the usual entry point when no threat model exists. Reads a target
directory (and
THREAT_MODEL.md if present), spawns parallel review subagents per focus
area, and writes VULN-FINDINGS.json + .md for /triage to consume. Read-only
— no building, running, or network. For execution-verified crashes (build +
run + sanitizer), see HARNESS.md. Use when asked to "scan for vulns", "review
this code for security issues", "find bugs in <dir>", "audit this code for
vulnerabilities", or as the step between /threat-model and /triage.
argument-hint: "<target-dir> [--focus <area>] [--single] [--extra <file>] [--no-score]"
allowed-tools:
- Read
- Glob
- Grep
- Write
- Task
- Bash(rg:*)
- Bash(grep:*)
- Bash(ls:*)
- Bash(wc:*)
- Bash(head:*)
- Bash(file:*)
- Bash(codegraph:*)
---
# /vuln-scan
Second leg of the defending-code loop (`/threat-model` → **`/vuln-scan`** →
`/triage` → `/patch`). Static vulnerability review of a source tree. Produces
`VULN-FINDINGS.json` (+ a human-readable `.md`) that `/triage` ingests
directly. Reads `THREAT_MODEL.md` if step 1 ran, and falls back to its own
recon if not — so this is a valid place to start the loop.
**This skill does not execute code.** It reads source and reasons about it.
For execution-verified findings (sanitizer crashes, reproducing PoCs), point
the user at an autonomous execution harness — see `HARNESS.md` in this skill
directory for the reference C/C++ + AddressSanitizer pipeline this group was
extracted from.
**Tool fallbacks.** Prefer the dedicated Glob and Grep tools. Some sessions
do not provision them — `allowed-tools` is a permission filter, not a loader,
so listing them here does not make them appear. When Glob/Grep are
unavailable, fall back to the read-only Bash commands whitelisted above:
`rg --files <scope>` / `ls -R` for enumeration, `rg -n` / `grep -rn` for
search, `wc` / `head` / `file` for sniffing, and `codegraph explore`
(read-only index query, Step 2). These are the ONLY permitted
Bash commands; do not write helper scripts or pipe target content into a
shell interpreter.
## Arguments
- `<target-dir>` (required) — directory to scan. Relative or absolute.
- `--focus <area>` — scan only this focus area (repeatable). Skips recon.
Six names are **reserved specialist lenses** rather than subsystems:
`crypto`, `logic-bug`, `access-control`, `deserialization`, `batch-etl`,
`iac`. Each scopes the reviewer to the whole target seen through one
vulnerability class, with a stricter cite-or-drop gate than the general
bar (the lens bodies are in the `vuln-area-reviewer` agent definition, so
they cost one cached prefix per wave, not one per spawn). Any other value
is a subsystem, as before. A lens name is only reserved when it is the
*entire* argument — `--focus "crypto (keystore.rs) — key derivation"` is
a subsystem whose name happens to start with a lens word.
- `--single` — no subagent fan-out; one sequential pass. Use on tiny targets
or when debugging the prompt.
- `--extra <file>` — pass the contents of `<file>` to every reviewer as an
`EXTRA CHECKS` section of its per-spawn prompt (Step 2). Use to add
org-specific vulnerability classes, compliance checks, or stack-specific
patterns. Plain text; same shape as the category blocks in the
`vuln-area-reviewer` agent definition.
- `--no-score` — skip the Step 3b confidence pass (saves a round of
subagents). Findings keep the scanner's self-reported confidence only.
## Step 1 — Scope
1. Resolve `<target-dir>`. If it doesn't exist or has no source files, stop
with an error.
2. Look for `<target-dir>/THREAT_MODEL.md`. If present, parse its section 3 "Entry
points & trust boundaries" table and section 4 "Threats" table for focus areas
and threat classes. This is the preferred scoping input. Also parse its
section 2 "Assets" table and section 1 system context into two bullet
lists — `assets` and `deployment_facts` — for the review brief: severity
calibration needs what the system protects, not just where input enters.
**Tag each focus area with the threat rows it came from.** A section-4
row's `surface` column names the section-3 entry point(s) it traverses;
the focus area built from that entry point carries `threat_ids: ["T1",
"T4", ...]` — every row whose `surface` names it. Keep the rows' one-line
`threat` text alongside the ids: the reviewer needs the sentence, the
output needs the id. A focus area derived from recon rather than from a
threat row carries `threat_ids: []` — legitimate, and not the same as
"no threats apply here".
3. If no THREAT_MODEL.md and no `--focus`: do a **quick recon** — list the
source tree, read entry points and dispatch code, and propose 3-10 focus
areas using the pattern `<subsystem> (<function/file>) — <key operations>`.
During recon, also read any deploy manifests in the target (Helm chart,
k8s YAML, compose file, Dockerfile env) and note as `deployment_facts`
what is actually there: mounted secrets, auth in front, sessions/cookies,
persistent state, tenancy. "Stateless, no auth, chart mounts no secret"
changes every severity downstream.
4. If `--focus` was given, use exactly those. For each value that is a
reserved lens name, resolve it to the whole target rather than a
subsystem, and **check the lens has material before spawning**: `iac`
needs infrastructure files (Terraform, Dockerfile, k8s/Helm YAML, CI
pipeline definitions, Ansible, compose, CloudFormation); `batch-etl`
needs a job entry point rather than a request handler; `deserialization`
needs a deserializer call site anywhere in the tree. Skip a lens with no
material and say so — "no IaC files under the target, skipping the iac
lens" — rather than spending a reviewer to be told nothing is there.
`crypto`, `logic-bug`, and `access-control` apply to any code.
5. **Coverage rule — no threat drops out of scope.** Only when step 2 ran
(a THREAT_MODEL.md was read) and `--focus` was not given — `--focus`
means "scan only this", and silently widening it would break that
contract. Walk the section-4 rows: any row whose `surface` matches no
section-3 entry point, or whose entry point produced no focus area,
becomes **its own focus area**, phrased from the threat text and tagged
with that row's id. Its `status`
does not exempt it: a row marked `mitigated` is the author's claim, and
confirming a control still holds in the code is review work. Only
section 5 "Deprioritized" rows are out of scope — they were ruled out
deliberately. A threat the model names and the scan never looks at is
the failure this rule exists to prevent, and it is silent without it.
Tell the user the focus areas you'll scan and the source-file count before
fanning out, and name any focus area added by rule 5 as threat-derived —
it is the one the operator did not ask for.
## Step 2 — Fan out
Unless `--single`, spawn **one review subagent per focus area** in parallel
— all Task calls in a SINGLE message, `subagent_type: "vuln-area-reviewer"`
(plugin installs: `defending-code:vuln-area-reviewer`). Cap at 10
concurrent. The full review brief is that agent definition's system prompt
— shared and prompt-cached across every reviewer in the wave — so each
spawn's prompt carries only the variable facts below. On tiny targets
(<15 source files), fall through to `--single` automatically.
**Fallback:** if neither agent name resolves, Read the agent definition at
`../../agents/vuln-area-reviewer.md` relative to this skill directory (same
layout in the repo and in a plugin install), and spawn `general-purpose`
subagents with its body pasted above the per-spawn block. In `--single`
mode, follow that same body inline, once per focus area sequentially.
### Per-spawn prompt (variable facts only)
```
FOCUS AREA: **{focus_area}**
FINDING ID PREFIX: F-{focus_idx:02d}-
TARGET: {target_dir}
TRUST BOUNDARY: {from THREAT_MODEL.md section 3, or "untrusted input → process memory"}
{if this focus area has threat_ids:
THREATS THIS AREA IS SCOPED TO (from THREAT_MODEL.md section 4 — what the
model already predicted here; confirm or refute them in the code, and
report anything else you find as well):
{one bullet per row: "T4: <threat sentence> (status: <status>, controls: <controls>)"}}
ASSETS (what is worth protecting here):
{assets bullets from THREAT_MODEL.md section 2, or "(unknown)"}
DEPLOYMENT FACTS (what is actually deployed/mounted):
{deployment_facts bullets, or "(unknown)"}
{if --extra <file> was given:
EXTRA CHECKS:
<file contents verbatim>}
{if the target has a .codegraph/ index:
CALL GRAPH CONTEXT (mechanical index — a starting point, not evidence;
trace any flow you report by reading the code):
<excerpt>}
```
When `{focus_area}` is one of the six reserved lens names, that name alone
is the whole instruction — the agent body carries the lens and its gate.
Pass the bare name; do not paste the lens text into the tail, which would
un-share the one thing the cached prefix exists to share.
### Call-graph context (only when the target is indexed)
If `<target-dir>/.codegraph/` exists, give each reviewer a head start on
reachability: run
`codegraph explore "<the focus area's functions/files from Step 1>"` from
inside `<target-dir>` (e.g. `cd <target-dir> && codegraph explore "..."`),
trim the output to a compact excerpt — entry points, direct callers and
callees of the focus area's functions, ≤ ~40 lines; drop verbatim source
bodies — and append it to that reviewer's spawn prompt as the block above.
One query per focus area. If there is no `.codegraph/` directory, or the
command fails, omit the block entirely and say nothing — never index the
target yourself.
## Step 3 — Collate
1. Collect `<finding>` blocks from all subagents. Drop `category=none`
placeholders.
2. **Light dedupe** — if two findings cite the same `file:line` with the
same category, keep the one with the longer description and note the
duplicate id. (Heavy dedupe is `/triage`'s job; don't over-engineer here.)
3. Assign stable ids `F-001`, `F-002`, ... in (severity desc, file, line)
order.
4. Attach `threat_ids` to each finding: the `threat_ids` of the focus area
whose reviewer produced it. This is **mechanical inheritance from the
spawn**, not a judgment — the reviewer is not asked which threat its
finding instantiates, and you do not infer one. Findings from an
untagged focus area get `threat_ids: []`.
## Step 3a — Deterministic pre-filter (no subagents)
A mechanical gate between collation and the confidence pass. It exists
because prompt rules alone are not enough — reviewers ignore the
DO-NOT-REPORT list a measurable fraction of the time — and a finding that
fails a check a script could run should never cost a confidence subagent.
**Nothing is dropped**: gated findings stay in the output with a
mechanically assigned confidence and reason, and skip Step 3b.
For each finding, in order:
1. **Hallucinated path.** Resolve `file` under `<target-dir>` (as-given,
then with common prefixes stripped). If it does not exist on disk:
set `confidence: 0.05`, `prefilter: "hallucinated_path"`,
`confidence_reason: "prefilter: cited file not found under target"`.
Skip 3b for it.
2. **Test/fixture path.** Match the resolved path (case-insensitive)
against:
```
(^|/)(tests?|__tests__|mocks?|examples?|fixtures?|samples?|testdata)(/|$)
|_test\.|\.test\.|\.spec\.|Test\.java$|Tests\.cs$
```
On match: set `confidence: 0.15`, `prefilter: "test_path"`,
`confidence_reason: "prefilter: test/example/fixture path"`. Skip 3b.
**Exception — committed credentials stay live:** if the category is
`hardcoded-secret` or the description evidences a committed credential
(key material, token, password), do NOT gate it — a real secret in a
fixture file is in source control and was likely real once. It proceeds
to 3b at its reported confidence.
3. **Unproven data flow.** If the finding carries neither `source_ref` nor
`sink_ref` (§ Step 4), set `prefilter: "unproven_flow"`. Unlike 1 and 2
this is an **annotation, not a gate** — the finding still goes to 3b and
is still written out. It records that the reviewer asserted a flow in
prose without naming its two ends, which is the shape a hallucinated
flow takes, and it tells `/triage` that this finding cannot be deduped
on data-flow evidence. Never synthesize the refs yourself to clear the
annotation: a ref the reviewer did not derive is not evidence.
All other findings get `prefilter: null` and proceed. Report the gate's
work in the terminal ("pre-filter: X hallucinated, Y test-path, Z
unproven-flow, W passed") — silent gating reads as "nothing was gated".
## Step 3b — Confidence pass (skip if `--no-score`)
A cheap second-opinion read that **ranks** findings by signal quality.
**Nothing is dropped** — this pass calibrates `confidence` so humans and
`/triage` see high-signal findings first. Spawn **one subagent per
finding that passed the Step 3a pre-filter** in parallel — all Task calls
in one message, `subagent_type:
"vuln-confidence-scorer"` (plugin installs:
`defending-code:vuln-confidence-scorer`). The scoring instructions are that
agent definition's cached system prompt; the fallback is as in Step 2, with
`../../agents/vuln-confidence-scorer.md`. Each spawn's prompt is only:
```
FINDING:
{the full <finding> block}
TARGET: {target_dir}
```
**Resolve:** overwrite each finding's `confidence` with the score
(normalized to 0.0-1.0) and attach `confidence_reason`. Re-sort findings
by (`confidence` desc, `severity` desc, `file`, `line`) and reassign ids
`F-001..` in that order. Compute `low_confidence_count` = findings with
confidence < 0.4, for the summary line.
## Step 4 — Write output
Write **both** files to `<target-dir>/`:
**`VULN-FINDINGS.json`** — the `/triage` ingest shape:
```json
{
"target": "<target-dir>",
"scanned_at": "<iso8601>",
"focus_areas": ["..."],
"threat_coverage": {"T1": ["F-001"], "T2": [], "T5": ["F-004", "F-009"]},
"findings": [
{
"id": "F-001",
"file": "relative/path.c",
"line": 123,
"category": "heap-buffer-overflow",
"severity": "HIGH",
"confidence": 0.9,
"title": "...",
"description": "...",
"exploit_scenario": "...",
"recommendation": "...",
"source_ref": "relative/path.c:88",
"sink_ref": "relative/path.c:123",
"threat_ids": ["T1"],
"confidence_reason": "...",
"prefilter": null
}
],
"summary": {"total": 0, "high": 0, "medium": 0, "low": 0, "low_confidence": 0, "prefiltered": 0, "unproven_flow": 0}
}
```
`source_ref` / `sink_ref` are the two ends of the data flow the reviewer
traced — where untrusted input enters, and where it is used unsafely —
copied verbatim from its `<finding>` block, `null` when it named neither.
They are the finding's **data-flow evidence**: `/triage` deduplicates and
verifies against them instead of re-reading the prose, so two reviewers
who describe one bug from different angles collapse on matching refs.
Emit them as given; do not repair, infer, or normalize a ref the reviewer
did not produce. For a context-free finding (hardcoded secret, weak
constant) both point at the same location — that is correct, not a defect.
`threat_ids` is which THREAT_MODEL.md section-4 rows the finding's focus
area was scoped to — inherited from the spawn, so it says "this finding
came from work aimed at T4", not "this finding proves T4". `threat_coverage`
inverts it across the whole run: **every** section-4 row appears as a key,
including the ones no reviewer produced a finding for. An empty list is the
useful half — it says the scan looked at that threat and found nothing,
which is a result, and it is invisible if only matched threats are listed.
A row absent from the map entirely is a bug in Step 1 rule 5, not a clean
result.
In `summary`, `prefiltered` counts only findings the Step 3a **gates**
caught (rules 1-2, which skip 3b); `unproven_flow` counts the rule-3
annotation separately, since those findings were scored and kept.
Findings are sorted by `confidence` desc (then severity, file, line), so
the top of the file is the highest-signal material.
**`VULN-FINDINGS.md`** — human-readable: a summary table (id | severity |
category | file:line | title), then one `### F-NNN` section per finding with
the full description, a `**Flow:** {source_ref} -> {sink_ref}` line (omit
when both are null), and a `**Threats:** {threat_ids}` line (omit when
empty). Close the file with a `## Threat coverage` table — one row per
section-4 threat, its finding ids or `none` — when a THREAT_MODEL.md was
read.
## Step 5 — Hand back
Tell the user:
1. Counts: N findings (H/M/L split, X low-confidence, P pre-filtered,
U without traced data flow), across K focus areas, from M source files.
2. Top 3 by confidence, one line each.
3. If a THREAT_MODEL.md was read: which section-4 threats got no findings,
by id. Say it plainly — "the scan found nothing for T2, T6" — and that
this is not the same as those threats being absent.
4. Next step: `> /triage <target-dir>/VULN-FINDINGS.json --repo <target-dir>`
5. Remind: these are **static candidates**, not verified. For
execution-verified crashes, an autonomous harness is needed (see HARNESS.md).
## Constraints
- **Never execute target code.** No builds, no `docker`, no network, and no
Bash beyond the read-only whitelist in § Tool fallbacks.
If the user asks you to "reproduce" or "confirm with a PoC," decline and
point at an autonomous execution harness (HARNESS.md).
- **Don't fabricate line numbers.** Every `file:line` you emit must be
something you Read or Grep'd. If unsure of the exact line, cite the
function and say so in the description.
- **Stay in `<target-dir>`.** Don't follow symlinks or `..` out of it.
- Findings are candidates for `/triage`, not final verdicts. **This skill
never drops a finding** — Step 3b only ranks. `/triage` does the rigorous
N-vote verification and is where false positives actually get removed.
## Provenance
Adapted (Apache-2.0) from
[`anthropics/defending-code-reference-harness`](https://github.com/anthropics/defending-code-reference-harness)
— the `vuln-scan` skill and its autonomous `find`/`recon` pipeline prompts.
The focus-area recon pattern and memory-safety quality tiers mirror the same
logic the autonomous pipeline uses, applied statically. The broader category
menu, DO-NOT-REPORT exclusions, per-finding confidence pass, and
`exploit_scenario`/`recommendation` output fields originate in
[`anthropics/claude-code-security-review`](https://github.com/anthropics/claude-code-security-review)'s
`/security-review` command. The Step 3a deterministic pre-filter — the
test-path exclusion regex, the committed-credential exception to it, and
the gate-before-the-expensive-stage ordering — is adapted (Apache-2.0) from
[`visa/visa-vulnerability-agentic-harness`](https://github.com/visa/visa-vulnerability-agentic-harness)'s
s5 prefilter stage. From the same harness: the `source_ref`/`sink_ref`
evidence fields carry the semantics of its s4 finding schema, per-focus-area
threat tagging mirrors the `threat_id` its s3 stage stamps on every chunk,
and the six `--focus` specialist lenses — with the cite-or-drop gate that
is the transferable part of each — follow its `SPECIALIST_HINTS` set.
See `HARNESS.md` for the execution-verified pipeline this static skill
complements.
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!