# Pre-scan recall check — labeled corpus: OWASP NodeGoat

Target: NodeGoat master snapshot (2026-09-30, `nodegoat.tar.gz`) · scanner: repo_prescan_bundle.py v23 (42 deterministic line/manifest scans) · full finding list: `nodegoat-report.md` in this directory.

**Method.** NodeGoat's own tutorial pages (`app/views/tutorial/a1–a10.html`, `redos.html`, `ssrf.html`) document the planted vulnerabilities and name the responsible files. Each documented instance was treated as a labeled positive and checked against the report's findings — the labels come from the app authors, not from the scanner's author. A HIT means the report flags the responsible line (or the direct cause); PARTIAL means the mechanism or class was flagged but not the instance; MISS means no finding touches it. FP note: `session.js:16-17` WEAK-RNG flags land on `Math.random` used for game allocations, not secrets — counted below as precision noise, not recall.

## Verdict table

| # | Documented vuln | Location | Scanner result | Verdict |
|---|---|---|---|---|
| 1 | NoSQLi: `$where` built from request input | app/data/allocations-dao.js:78 | nosql `NQ-WHERE-NONLIT` MED | HIT |
| 2 | Injection: `eval()` on request amounts | app/routes/contributions.js:32-34 | cmdi `CI-REQ-EXEC` HIGH x3 + xss `XSS-STRING-EVAL` HIGH | HIT |
| 3 | Broken auth: password `!==` compare | app/routes/session.js:176 | timing `T-SECRET-EQ` HIGH + auth `AUTH-TIMING-COMPARE` LOW | HIT |
| 4 | Broken auth: non-CSPRNG in user data path | app/data/user-dao.js:51-53 | crypto `WEAK-RNG` LOW x3 | HIT (under-tiered) |
| 5 | Broken auth: session cookie without HttpOnly | app/routes/session.js:229 | auth `AUTH-COOKIE-FLAGS` MED | HIT |
| 6 | Broken auth: no login throttling/lockout | app/routes/session.js (route-level) | nothing — absence has no line shape | MISS |
| 7 | XSS: swig `autoescape:false` + unescaped profile fields | server.js:137 | ssti `TI-AUTOESCAPE-OFF` LOW (root cause) — per-field flow untraced | PARTIAL |
| 8 | Insecure direct object reference (userId param) | app/routes/allocations.js | NOT PRESENT — this revision already carries the documented fix (`userId` from session) | n/a |
| 9 | Misconfig: helmet/security-header stack commented out | server.js:38-66 | nothing — absence-of-middleware; the lone HDR-POWEREDBY hit lands on tutorial page text, not config | MISS |
| 10 | Sensitive data: `cryptoKey` + static `iv` literals | config/env/all.js:9-10 | nothing — placeholder-shaped literal + `cryptoKey` not in secret vocab | MISS |
| 11 | Sensitive data: static IV reuse in createCipheriv | app/data/profile-dao.js:32 | nothing — cipher-construction shape unmodeled | MISS |
| 12 | Access control: admin `updateBenefits` unguarded server-side | app/routes/benefits.js:28-35 | nothing — semantic role check, disclosed | MISS |
| 13 | CSRF: mutating forms with no token | profile.html:38 + benefits/contributions/memos/signup | csrf `CSRF-TOKEN-ABSENT-FORM` MED x5 | HIT |
| 14 | Vulnerable components (CVE-aged deps) | package.json | dep flags VCS-dep HIGH + unpinned MED — hygiene, not CVEs (no feed — disclosed) | PARTIAL |
| 15 | Open redirect `/learn?url=` | app/routes/index.js:72 | redirect `OR-REQ-URL` HIGH | HIT |
| 16 | ReDoS `firstNameSafeString` regex | app/routes/profile.js:59 | redos `REDOS-NESTED-QUANT` HIGH | HIT |
| 17 | SSRF `url` param into fetch | app/routes/research.js:16 | ssrfsink `SF-REQ-SINK` HIGH | HIT |

## Recall

- **Instance level (16 in-scope): 9 HIT / 2 PARTIAL / 5 MISS**
  - Full-hit recall: **9/16 = 56.25%**
  - Hit-or-signaled (PARTIAL counted as detected): **11/16 = 68.75%**
  - Not fully covered (PARTIAL + MISS): **7/16 = 43.75%**; explicit MISS alone: **5/16 = 31.25%**
- Category level (OWASP-2013 tutorial categories; A4/IDOR excluded — the planted instance is already fixed in this revision; standalone ReDoS and SSRF pages counted as their own categories): **8 of 11 in-scope categories produce at least one finding** — A1 injection, A2 broken auth, A3 XSS, A8 CSRF, A9 vulnerable components, A10 redirects, ReDoS, SSRF. The three that come home empty: A5 security-misconfiguration (middleware never wired), A6 sensitive-data (key literals shaped like placeholders), A7 function-level access control (semantic role check).

## What the misses share

Every MISS is an *absence or a relationship*, not a line shape: middleware that was never wired, rate-limiting that was never written, an admin check that exists only client-side, a key literal that looks like a placeholder, a cipher object built from a config sibling. That is the honest boundary of a line-level scanner and it is printed on the report itself — a clean pre-scan is not evidence of safety; it is a cheap filter that removes known-bad shapes before review time is spent.

## Precision note

62 findings total; the clearest low-value rows are the two `WEAK-RNG` hits on non-secret `Math.random` use and the `HDR-POWEREDBY` hit on tutorial prose. Findings are designed as human-review pointers, not verdicts — sev tiers encode the confidence.

Scope honesty: the numbers above measure **recall only** — coverage of labeled positives. They say nothing about signal-to-noise; that needs a finding-by-finding adjudication of all 62 rows, which this table does not contain (the noise examples above are a spot-check, not an adjudication). Paid reports ship the full finding list plus a per-class rollup (rule class → count → representative line → noise verdict) so both denominators are visible.
