# Pre-scan precision adjudication — labeled corpus: OWASP NodeGoat

Target: NodeGoat master snapshot (2026-09-30) · scanner: repo_prescan_bundle.py v23 ·
companion to `nodegoat-recall.md` (recall side) · full finding list: `nodegoat-report.md`.

**Method.** All 62 scored findings (15 HIGH / 29 MED / 18 LOW — INFO rows excluded by
design) were adjudicated one-by-one against the repo snapshot. Verdicts:

- **TP** — a real pattern worth a human's review time (matches the class's contract,
  whether or not it maps to a documented planted vuln).
- **CTX** — a real pattern whose value is bounded by context (test fixture,
  borderline state-change).
- **NOISE** — a finding a reviewer would dismiss (documentation prose, non-secret
  `Math.random`, heuristic firing on code that already carries the control).

Adjudicated 2026-10-01 by reading each flagged line and its surrounding context in
`repo/`. Disagreements are possible — the point is that every row has a stated verdict,
so precision is auditable, not asserted.

## Per-class rollup

| class | n | TP | CTX | NOISE | noise driver |
|---|---|---|---|---|---|
| secret-prescan | 11 | 9 | 1 | 1 | tutorial `<pre>` example |
| dep-prescan | 2 | 2 | 0 | 0 | — |
| ci-prescan | 6 | 6 | 0 | 0 | — |
| docker-prescan | 2 | 2 | 0 | 0 | — |
| crypto-prescan | 7 | 3 | 0 | 4 | `Math.random` on game/seed data, not secrets |
| auth-prescan | 2 | 2 | 0 | 0 | — |
| csrf-prescan | 9 | 5 | 1 | 3 | doc pages + test fixture |
| nosql-prescan | 2 | 2 | 0 | 0 | — |
| xss-prescan | 6 | 3 | 0 | 3 | tutorial `<pre>` example |
| cmdi-prescan | 3 | 3 | 0 | 0 | — |
| ssti-prescan | 2 | 2 | 0 | 0 | — |
| redirect-prescan | 2 | 1 | 0 | 1 | ternary of two literals |
| redos-prescan | 1 | 1 | 0 | 0 | — |
| authz-prescan | 4 | 0 | 0 | 4 | heuristic fired on routes carrying `isLoggedIn` |
| headers-prescan | 1 | 0 | 0 | 1 | tutorial prose |
| timing-prescan | 1 | 1 | 0 | 0 | — |
| ssrfsink-prescan | 1 | 1 | 0 | 0 | — |
| **total** | **62** | **43** | **2** | **17** | |

**Adjudicated precision: 43/62 = 69.4% confirmed real-pattern hits; 45/62 = 72.6% if
contextual pointers count as signal; 17/62 = 27.4% noise.**

## Where the noise lives — it is clustered, not random

| noise cluster | count | findings |
|---|---|---|
| Documentation `<pre>`/prose blocks in `app/views/tutorial/*` (the corpus documents its own vulns in HTML) | 7 | xss a1.html:157-159 x3 · secret a1.html:220 · csrf-url a1.html:351 · csrf-no-token a7.html:54 · hdr a5.html:88 |
| `Math.random` on non-secret data (game allocations, seed fixtures) | 4 | session.js:16-17 · db-reset.js:113-114 |
| Weak authz heuristic — "mutating route in file with auth vocab" fired on routes that do carry `isLoggedIn` | 4 | index.js:48,52,56,67 |
| Literal-ternary redirect (`user.isAdmin ? "/benefits" : "/dashboard"`) — non-literal but not request-controlled | 1 | session.js:117 |
| Test-fixture token-in-URL | 1 | profile-test.js:218 |

The largest single class (7/17) is an artifact of this corpus: NodeGoat teaches its
vulnerabilities in HTML `<pre>` blocks, so the scanner flags the textbook examples it
was meant to find. On a repo that does not document itself, this noise class
disappears. The authz cluster is a known-disclosed weak heuristic (LOW tier).

## What this means for a buyer

- ~7 in 10 findings point at a real pattern; sev tier and per-class rollup let a
  reviewer triage, not chase.
- The scanner does not know a `<pre>` block from live code — that is the honest
  disclosed boundary, same side as the recall misses (absences and relationships,
  not line shapes).
- Finding density correlates with **hygiene debt in the scanned codebase** — it is
  a structural triage signal, not an economic or market signal. It does not predict
  exploit prices, bounty values, or any "price movement."

## Row-level table (all 62)

Verdict legend: TP / CTX / NOISE. Locations relative to `repo/`.

| # | sev | class.rule | location | verdict | note |
|---|---|---|---|---|---|
| 1 | HIGH | secret.pem-private-key | artifacts/cert/server.key:1 | TP | committed private key |
| 2 | MED | secret.generic-assignment | app/views/tutorial/a1.html:220 | NOISE | `$password` inside doc `<pre>` |
| 3-8 | MED | secret.generic-assignment | artifacts/db-reset.js:18,19,27,28,35,36 | TP x6 | hardcoded seed usernames + bcrypt hashes |
| 9 | MED | secret.generic-assignment | config/env/development.js:6 | TP | committed session secret |
| 10 | MED | secret.generic-assignment | config/env/test.js:6 | TP | committed session secret |
| 11 | MED | secret.generic-assignment | test/security/profile-test.js:37 | CTX | test fixture credential |
| 12 | HIGH | dep.vcs-dependency | grunt-if | TP | git dep, no ref |
| 13 | MED | dep.unpinned-version | grunt-env 'latest' | TP | floating version |
| 14-19 | MED | ci.ACTION-TAGPIN | workflows x6 | TP x6 | mutable action tags |
| 20-21 | LOW | docker.BASE-TAGPIN | Dockerfile:1,7 | TP x2 | mutable base tag |
| 22-24 | LOW | crypto.WEAK-RNG | app/data/user-dao.js:51-53 | TP x3 | documented vuln (A2) |
| 25-26 | LOW | crypto.WEAK-RNG | app/routes/session.js:16-17 | NOISE x2 | game allocations, not secrets |
| 27-28 | LOW | crypto.WEAK-RNG | artifacts/db-reset.js:113-114 | NOISE x2 | seed fixture data |
| 29 | MED | auth.AUTH-COOKIE-FLAGS | app/routes/session.js:229 | TP | documented (no HttpOnly) |
| 30 | LOW | auth.AUTH-TIMING-COMPARE | app/routes/session.js:176 | TP | documented (`!==` compare) |
| 31 | MED | csrf.CSRF-MUTATING-GET | app/routes/index.js:41 | CTX | logout on GET — mild real issue |
| 32-36 | MED | csrf.CSRF-TOKEN-ABSENT-FORM | benefits/contributions/memos/profile/signup .html | TP x5 | documented (A8) |
| 37 | MED | csrf.CSRF-NO-TOKEN-CHECK | app/views/tutorial/a7.html:54 | NOISE | doc page |
| 38 | LOW | csrf.CSRF-TOKEN-IN-URL | app/views/tutorial/a1.html:351 | NOISE | `_csrf=` in doc curl example |
| 39 | LOW | csrf.CSRF-TOKEN-IN-URL | test/security/profile-test.js:218 | NOISE | test fixture |
| 40 | MED | nosql.NQ-WHERE-NONLIT | app/data/allocations-dao.js:78 | TP | documented (A1 `$where`) |
| 41 | MED | nosql.NQ-FIELD-REQ | app/routes/memos.js:13 | TP | req value into update field |
| 42-44 | HIGH | xss.XSS-STRING-EVAL | app/routes/contributions.js:32-34 | TP x3 | documented (A1 eval) |
| 45-47 | HIGH | xss.XSS-STRING-EVAL | app/views/tutorial/a1.html:157-159 | NOISE x3 | doc `<pre>` example |
| 48-50 | HIGH | cmdi.CI-REQ-EXEC | app/routes/contributions.js:32-34 | TP x3 | same lines, exec class |
| 51 | MED | ssti.TI-NAME-NONLIT | app/routes/tutorial.js:33 | TP | dynamic template name (bounded — fixed page list) |
| 52 | LOW | ssti.TI-AUTOESCAPE-OFF | server.js:137 | TP | documented root cause (A3) |
| 53 | HIGH | redirect.OR-REQ-URL | app/routes/index.js:72 | TP | documented (A10 `/learn?url=`) |
| 54 | MED | redirect.OR-NONLIT-URL | app/routes/session.js:117 | NOISE | ternary of two literals |
| 55 | HIGH | redos.REDOS-NESTED-QUANT | app/routes/profile.js:59 | TP | documented ReDoS |
| 56-59 | LOW | authz.AUTHZ-MUTATING-NO-AUTH | app/routes/index.js:48,52,56,67 | NOISE x4 | routes carry `isLoggedIn` |
| 60 | LOW | headers.HDR-POWEREDBY | app/views/tutorial/a5.html:88 | NOISE | doc prose |
| 61 | HIGH | timing.T-SECRET-EQ | app/routes/session.js:176 | TP | documented (A2 compare) |
| 62 | HIGH | ssrfsink.SF-REQ-SINK | app/routes/research.js:16 | TP | documented SSRF |

*Multi-line rows (#3-8, 14-19, 20-21, 22-24, 25-26, 27-28, 32-36, 42-44, 45-47,
48-50, 56-59) share one verdict across the listed findings.*
