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

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

**Method.** PyGoat's route table (`introduction/urls.py`), lab directories, and `dockerized_labs/` document 18 planted vulnerability classes. Each class was treated as a labeled positive and checked against the report — labels come from the app authors, not the scanner's author. HIT = the report flags the responsible line or its direct cause; PARTIAL = the mechanism/class was flagged adjacent to, but not at, the documented instance; MISS = no finding touches it.

## Verdict table

| # | Documented class | Location | Scanner result | Verdict |
|---|---|---|---|---|
| 1 | XSS x3 (mark_safe / raw template output) | templates Lab/XSS/xss*.html | xss `XSS-RAW-TEMPLATE` MED on all three lab templates + `XSS-DOM-SINK`/`XSS-STRING-EVAL` HIGH elsewhere | HIT |
| 2 | SQLi x2 — `"SELECT … '"+name+"'"` concat into raw query | views.py sql_lab:147, injection_sql_lab:855 | sqli-prescan: **No findings** — Django string-concat query shape not in sqli vocab | MISS |
| 3 | Command injection x2 — `os.popen(input)` / shell on POST | views.py cmd_lab:415, cmd_lab2:453 | cmdi `CI-SHELL-TRUE` HIGH :430 + `CI-EVAL-NONLIT` MED :460 | HIT |
| 4 | XXE — xml.sax parse of posted XML | views.py xxe_parse:256 | xxe `XXE-DEFAULT-PARSER` HIGH :258 | HIT |
| 5 | Insecure deserialization — pickle.loads on cookie | views.py insec_des_lab:205 | deser `PICKLE-LOAD` HIGH :214 + dockerized main.py:36 + `YAML-UNSAFE-LOAD` x2 | HIT |
| 6 | Broken access control — IDOR x4 (ba_lab, a1 labs 1-3) | views.py 342, 747, 783, 822 | authz `AUTHZ-OBJECT-NO-OWNER` at :355/:365 (ba_lab), :766/:806 (a1_1/_2), :831/:833 (a1_3) | HIT |
| 7 | Sensitive data exposure (data_exp_lab + dockerized lab) | views.py:392 + dockerized_labs/sensitive_data_exposure | dockerized sibling flagged (generic-assignment secrets, CSRF-MUTATING-GET); the main lab's record dump not directly flagged | PARTIAL |
| 8 | Security misconfig — DEBUG=True, permissive settings | settings.py:30 | debug `DBG-DEBUG-ON` HIGH x3 + docker NO-USER x3 | HIT |
| 9 | Broken auth — OTP `==` compare, weak login, auth_failure labs | views.py 493, 1122, 1179 | timing `T-SECRET-EQ` HIGH x9 (incl. :1200 inside auth_failure_lab3), auth `AUTH-PW-HARDCODED` x7, LOG-SECRET :314 inside auth_lab_login | HIT |
| 10 | A9 vulnerable components (a9_lab yaml.load, a9_lab2) | views.py 551, 574 | deser `YAML-UNSAFE-LOAD` :560 inside a9_lab + `XSS-STRING-EVAL` :588 inside a9_lab2; dep flags missing-lockfile (no CVE feed — disclosed) | HIT |
| 11 | SSRF (ssrf_lab file-read, ssrf_lab2 fetch) | views.py 918, 956 | ssrfsink `SF-REQ-SINK` HIGH :963 (lab2 request-url→fetch); lab1's `open(os.path.join(dir, user_input))` file-read variant unflagged | HIT |
| 12 | SSTI — user markup written into a template file then rendered | views.py ssti_lab:975 → ssti_view_blog:1003 | ssti `TI-NAME-NONLIT` MED :1006 — flags the render of the attacker-influenced template (detonation sink); the write-side is unflagged | HIT |
| 13 | Cryptographic failures x3 (md5 password, customHash, cookie tampering) | views.py 1018, 1034, 1049 | crypto `WEAK-HASH`+`PASSWORD-FASTHASH` HIGH :1026 (lab1); timing `T-SECRET-EQ` :1072 + log `LOG-CRED` :1054 (lab3); lab2's seeded hashes flagged in migration/template | HIT |
| 14 | A8 software/data integrity (lab2 unguarded render, lab3) | views.py 1220, 1230 | ci `ACTION-TAGPIN` MED (mutable action ref) + lab2 template xss flag; the unsigned-update mechanism itself unflagged | PARTIAL |
| 15 | Insecure design — predictable recovery ticket | views.py insec_desgine_lab:688, gentckt:679 | crypto `WEAK-RNG` LOW :680 flags the non-CSPRNG ticket primitive + `RL-REQ-LOOP` :704 inside the lab; the holistic design flaw is an absence shape | PARTIAL |
| 16 | Hardcoded Django SECRET_KEY (secret lab / A6) | pygoat/settings.py:25 | secret `generic-assignment` MED on the SECRET_KEY line | HIT |
| 17 | /debug route serving app.log as text/plain | views.py debug:636 | debug `DBG-DEBUG-ON` flags the enabling config; the log-serving route shape itself is unmodeled | PARTIAL |
| 18 | Logging/monitoring gap (log_function_checker) | views.py (auth flows) | log `LOG-SECRET` HIGH x3 + `LOG-CRED` MED — the opposite-direction defect (secrets INTO logs) on the same surface; the missing-monitoring gap is absence-shaped | PARTIAL |

## Recall

- **Class level (18 documented): 12 HIT / 5 PARTIAL / 1 MISS** — detected 67%, detected-or-signaled 94%.
- The single MISS is concrete, not absence-shaped: Python/Django SQL built by `+`-concat into `raw()`/cursor is a line shape the sqli class does not yet cover — a real vocabulary gap, disclosed here and in the report header.

## Contrast with the NodeGoat corpus

NodeGoat (Express/JS): 9 HIT / 2 PARTIAL / 5 MISS — misses were absence shapes (unwired middleware, unwritten rate-limit, client-side-only checks). PyGoat (Django/Python): 12/5/1 — Django labs express vulns as explicit line shapes, so coverage is much higher, and the only full miss is a missing vocabulary entry rather than an unmodelable absence. Both corpora confirm the printed boundary: a clean pre-scan is a cheap filter, not evidence of safety.

## Precision note

~120 findings; known noise: `hex-64` MEDIUMs on lab seed hashes (they ARE planted secrets — arguably true-positive), `AUTHZ-OBJECT-NO-OWNER` MEDIUMs that are true BOLA-shape but inside intentionally-vulnerable labs. Findings are human-review pointers; sev tiers encode confidence.
