Vuln Scan — Systematic Vulnerability Scanner
Iron Law
Code you haven't traced from input to sink has vulnerabilities you haven't found. A grep hit is a lead, not a finding.
Process
Step 0: Load Engagement Context
Check for .harness/engagement.md. If present, load scope constraints — only scan in-scope paths and respect exclusions.
Check for THREAT_MODEL.md from a previous /threat-model run. If present, use its threat scenarios as scan targets. If absent, run full-surface scan.
Step 1: Map Entry Points to Trust Boundaries
Enumerate every place untrusted data enters the system, by reading the code:
- External → app: HTTP handlers/routers, GraphQL resolvers, webhooks, file uploads, form fields, query params, headers
- Process boundary: CLI args, environment variables, IPC, deserialization of stored data (cache, DB rows written earlier, config files)
- Client-supplied state: cookies, JWT claims, API keys, referer/origin
For each entry point, note: what validation exists (yes/no/partial), and where the data flows next. This list is the scan's backbone — a scan that cannot name its entry points has not started.
Step 2: Trace Data Flow to Sinks
From each entry point, follow the data by reading code until it reaches a dangerous operation, or dies (validated, parameterized, dropped). Dangerous sink classes:
| Sink class |
Examples (non-exhaustive) |
| Command/code execution |
shell invocation, eval-family, template engines with code modes, deserialization to live objects |
| Query construction |
string-built SQL, raw-query escapes in any ORM (Knex raw, Prisma $queryRaw, SQLAlchemy text(), Sequelize literal, Django extra/raw, Rails where("...") with interpolation) |
| Filesystem |
path joins with user input, upload destinations, archive extraction (zip-slip), file reads driven by request params |
| Web output |
template rendering with non-auto-escaping engines, innerHTML-family, redirect targets, header values (CRLF) |
| Auth decisions |
IDOR — object lookups keyed only on user-supplied ids without ownership checks; role checks done client-side or per-endpoint instead of per-object |
Read the surrounding code for every candidate sink. A sink that only receives validated/parameterized data is not a finding; a sink reachable from an entry point with no validation is.
Step 3: Reason About Encoding and Context at Each Sink
For each reachable sink, ask what context the data lands in and whether the encoding matches:
- Interpolated into a SQL string? → parameterize, don't escape
- Interpolated into a shell string? → arg-array execution, don't quote
- Rendered into HTML/JS/CSS/URL? → each needs context-specific output encoding; auto-escaping templates cover HTML only when actually enabled for that template
- Joined into a path? → canonicalize, then verify containment inside the intended root
- Deserialized? → prefer format + type that cannot instantiate attacker-chosen types
Step 4: Auth & Data-Exposure Pass
- Default-deny: routes/actions missing an auth middleware or check entirely — enumerate handlers and look for the gaps, not the checks
- Object-level authorization: for each handler that reads/writes a user-owned object, is ownership verified on THIS request?
- Secrets: hardcoded credentials vs. config/env references; secrets in logs, error messages, client bundles, API responses
- Error leakage: verbose errors/stack traces/config in production paths; error messages that distinguish "no such user" from "wrong password"
Step 5: Dependency Pass
cargo audit 2>/dev/null || echo "cargo-audit not installed" # Rust
npm audit 2>/dev/null || echo "npm audit not available" # Node.js
For each advisory: is a fix available, and is the vulnerable code path actually reachable from this codebase's usage? Unreachable advisories are LOW/INFO, not silent drops. Also flag actively-used dependencies that are unmaintained (no release in ~2+ years, archived repo) even without a CVE.
Grep as Accelerator, Not Oracle
Grep is fine for candidate sink locations (raw(, exec, innerHTML, eval, secret-ish identifiers) — use it to shortlist where to read. It misses framework-specific sinks, aliases, and data built across files. Never report a finding from a grep hit alone; never conclude "clean" because grep found nothing.
Step 6: Produce Output
Write VULN-FINDINGS.json:
{
"scan_date": "ISO-8601",
"scope": "full | incremental",
"threat_model_ref": "THREAT_MODEL.md | null",
"entry_points_traced": ["list every entry point examined — required for clean scans"],
"findings": [
{
"id": "V1",
"dimension": "injection | auth | exposure | dependency",
"severity": "CRITICAL | HIGH | MEDIUM | LOW | INFO",
"file": "path/to/file",
"line": 42,
"source": "untrusted-input origin (entry point)",
"sink_chain": ["entry function", "...", "sink function"],
"description": "what was found",
"validated": true,
"false_positive": false,
"reachable": true,
"mitigated": false,
"threat_scenario": "T1 | null",
"remediation": "one-line fix hint"
}
],
"summary": {
"total": 10,
"critical": 1,
"high": 3,
"medium": 4,
"low": 2,
"false_positives": 0
}
}
Step 7: Feed into Triage
After producing findings, suggest:
"Run /triage to validate findings with adversarial review."
Anti-Rationalization
| Excuse |
Rebuttal |
What to do instead |
| "It's an internal tool / not user-facing" |
Internal boundaries are attack surfaces — lateral movement starts inside. |
Trace internal entry points with the same rigor. |
| "The framework sanitizes automatically" |
Auto-escaping and parameterization have opt-outs, raw escapes, and edges; business logic is framework-agnostic. |
Find the raw/dangerous escapes and read them. |
| "We'll add validation later" |
Later never arrives for paths that already work. |
Finding = now; remediation line goes in the output. |
| "Grep found nothing, so we're clean" |
Grep misses aliased and framework-specific sinks by construction. |
A clean claim requires traced entry points, not an empty grep. |
| "Dependencies are vetted" |
Transitive dependencies aren't. |
Run the dependency pass every time the lockfile changes. |
Evidence Required
Red Flags
- Reporting grep hits without reading the surrounding code
- Skipping framework-specific sink types (ORM raw escapes, non-auto-escaping templates)
- Claiming clean without naming which entry points were traced
- Scanning only changed files when full-surface scan was requested
- Marking findings as false positives without a traced reason
- VULN-FINDINGS.json with zero findings on a non-trivial codebase — the scan was likely incomplete, not the codebase clean
1---2name: vuln-scan3description: Systematic vulnerability scan across injection, auth, data exposure, and dependencies — traced by reading code, not grepping for keywords. Use when scanning for vulnerabilities, reviewing security, or validating threat models.4---56# Vuln Scan — Systematic Vulnerability Scanner78## Iron Law910Code you haven't traced from input to sink has vulnerabilities you haven't found. A grep hit is a lead, not a finding.1112## Process1314### Step 0: Load Engagement Context1516Check for `.harness/engagement.md`. If present, load scope constraints — only scan in-scope paths and respect exclusions.1718Check for `THREAT_MODEL.md` from a previous `/threat-model` run. If present, use its threat scenarios as scan targets. If absent, run full-surface scan.1920### Step 1: Map Entry Points to Trust Boundaries2122Enumerate every place untrusted data enters the system, by reading the code:2324- **External → app**: HTTP handlers/routers, GraphQL resolvers, webhooks, file uploads, form fields, query params, headers25- **Process boundary**: CLI args, environment variables, IPC, deserialization of stored data (cache, DB rows written earlier, config files)26- **Client-supplied state**: cookies, JWT claims, API keys, referer/origin2728For each entry point, note: what validation exists (yes/no/partial), and where the data flows next. This list is the scan's backbone — a scan that cannot name its entry points has not started.2930### Step 2: Trace Data Flow to Sinks3132From each entry point, follow the data by reading code until it reaches a dangerous operation, or dies (validated, parameterized, dropped). Dangerous sink classes:3334| Sink class | Examples (non-exhaustive) |35|-----------|--------------------------|36| Command/code execution | shell invocation, `eval`-family, template engines with code modes, deserialization to live objects |37| Query construction | string-built SQL, raw-query escapes in any ORM (Knex `raw`, Prisma `$queryRaw`, SQLAlchemy `text()`, Sequelize `literal`, Django `extra`/`raw`, Rails `where("...")` with interpolation) |38| Filesystem | path joins with user input, upload destinations, archive extraction (zip-slip), file reads driven by request params |39| Web output | template rendering with non-auto-escaping engines, `innerHTML`-family, redirect targets, header values (CRLF) |40| Auth decisions | IDOR — object lookups keyed only on user-supplied ids without ownership checks; role checks done client-side or per-endpoint instead of per-object |4142Read the surrounding code for every candidate sink. A sink that only receives validated/parameterized data is not a finding; a sink reachable from an entry point with no validation is.4344### Step 3: Reason About Encoding and Context at Each Sink4546For each reachable sink, ask what context the data lands in and whether the encoding matches:4748- Interpolated into a SQL string? → parameterize, don't escape49- Interpolated into a shell string? → arg-array execution, don't quote50- Rendered into HTML/JS/CSS/URL? → each needs context-specific output encoding; auto-escaping templates cover HTML only when actually enabled for that template51- Joined into a path? → canonicalize, then verify containment inside the intended root52- Deserialized? → prefer format + type that cannot instantiate attacker-chosen types5354### Step 4: Auth & Data-Exposure Pass5556- **Default-deny**: routes/actions missing an auth middleware or check entirely — enumerate handlers and look for the gaps, not the checks57- **Object-level authorization**: for each handler that reads/writes a user-owned object, is ownership verified on THIS request?58- **Secrets**: hardcoded credentials vs. config/env references; secrets in logs, error messages, client bundles, API responses59- **Error leakage**: verbose errors/stack traces/config in production paths; error messages that distinguish "no such user" from "wrong password"6061### Step 5: Dependency Pass6263```bash64cargo audit 2>/dev/null || echo "cargo-audit not installed" # Rust65npm audit 2>/dev/null || echo "npm audit not available" # Node.js66```6768For each advisory: is a fix available, and is the vulnerable code path actually reachable from this codebase's usage? Unreachable advisories are LOW/INFO, not silent drops. Also flag actively-used dependencies that are unmaintained (no release in ~2+ years, archived repo) even without a CVE.6970### Grep as Accelerator, Not Oracle7172Grep is fine for *candidate* sink locations (`raw(`, `exec`, `innerHTML`, `eval`, secret-ish identifiers) — use it to shortlist where to read. It misses framework-specific sinks, aliases, and data built across files. Never report a finding from a grep hit alone; never conclude "clean" because grep found nothing.7374### Step 6: Produce Output7576Write `VULN-FINDINGS.json`:7778```json79{80 "scan_date": "ISO-8601",81 "scope": "full | incremental",82 "threat_model_ref": "THREAT_MODEL.md | null",83 "entry_points_traced": ["list every entry point examined — required for clean scans"],84 "findings": [85 {86 "id": "V1",87 "dimension": "injection | auth | exposure | dependency",88 "severity": "CRITICAL | HIGH | MEDIUM | LOW | INFO",89 "file": "path/to/file",90 "line": 42,91 "source": "untrusted-input origin (entry point)",92 "sink_chain": ["entry function", "...", "sink function"],93 "description": "what was found",94 "validated": true,95 "false_positive": false,96 "reachable": true,97 "mitigated": false,98 "threat_scenario": "T1 | null",99 "remediation": "one-line fix hint"100 }101 ],102 "summary": {103 "total": 10,104 "critical": 1,105 "high": 3,106 "medium": 4,107 "low": 2,108 "false_positives": 0109 }110}111```112113### Step 7: Feed into Triage114115After producing findings, suggest:116**"Run `/triage` to validate findings with adversarial review."**117118## Anti-Rationalization119120| Excuse | Rebuttal | What to do instead |121|--------|----------|-------------------|122| "It's an internal tool / not user-facing" | Internal boundaries are attack surfaces — lateral movement starts inside. | Trace internal entry points with the same rigor. |123| "The framework sanitizes automatically" | Auto-escaping and parameterization have opt-outs, raw escapes, and edges; business logic is framework-agnostic. | Find the raw/dangerous escapes and read them. |124| "We'll add validation later" | Later never arrives for paths that already work. | Finding = now; remediation line goes in the output. |125| "Grep found nothing, so we're clean" | Grep misses aliased and framework-specific sinks by construction. | A clean claim requires traced entry points, not an empty grep. |126| "Dependencies are vetted" | Transitive dependencies aren't. | Run the dependency pass every time the lockfile changes. |127128## Evidence Required129130- [ ] Every finding cites file:line + the untrusted-input origin + the sink call chain131- [ ] Each finding validated: reachable, not mitigated, severity confirmed132- [ ] A clean (or low-finding) scan lists `entry_points_traced` — absence of evidence is not evidence of absence133- [ ] All 4 dimensions completed (injection, auth, exposure, dependency)134- [ ] VULN-FINDINGS.json written with summary135- [ ] If THREAT_MODEL.md exists: each threat scenario mapped to findings136- [ ] No CRITICAL/HIGH finding dismissed without explicit justification137138## Red Flags139140- Reporting grep hits without reading the surrounding code141- Skipping framework-specific sink types (ORM raw escapes, non-auto-escaping templates)142- Claiming clean without naming which entry points were traced143- Scanning only changed files when full-surface scan was requested144- Marking findings as false positives without a traced reason145- VULN-FINDINGS.json with zero findings on a non-trivial codebase — the scan was likely incomplete, not the codebase clean