DEV Community

Cover image for What BrassCoders Catches in OWASP PyGoat
CopperSunDev
CopperSunDev

Posted on Originally published at coppersun.dev

What BrassCoders Catches in OWASP PyGoat

Four months ago, BrassCoders published a gap list for OWASP PyGoat: four documented vulnerabilities it didn't yet catch. A SQL injection. An insecure deserialization. A command injection reachable through tainted data. A call to eval() that PyGoat exists specifically to demonstrate. Running the same scan today, against the same pinned commit, catches all four.

What PyGoat Is, and Why It's a Fair Test

PyGoat is a Django application OWASP contributors built to demonstrate the OWASP Top 10 vulnerability classes in real, runnable Python code. Not snippets in a slide deck. A working app with the vulnerabilities wired into actual request handlers, the kind a scanner has to trace through real control flow to find. The project's own README states it plainly: an intentionally vulnerable web application, with the vulnerabilities based on the OWASP Top 10.

That's what makes it a fair scanner test. Every finding has a known, documented right answer, published by the same people who wrote the vulnerable code. BrassCoders publishes a benchmark page for PyGoat pinned to a specific commit, c11e8429349cc05ff38564d3bf7ef09fb2411874, the v2.0.1 release tag, so the results below are reproducible by anyone. Not claimed. Checkable.

Running the Scan

A single brasscoders --offline scan . against PyGoat's 203 Python files surfaces 766 raw findings, cuts that to 403 through the OSS core's heuristic pass, and flags 52 as critical or high severity, no account, no network call, all before anything leaves the local machine.

$ brasscoders --offline scan .
๐ŸŽบ BrassCoders - Scanning pygoat
โšก Running 12 scanners in parallel (max_workers=6)...
๐Ÿงน Running intelligent optimization...
   Intelligent optimization: 766 โ†’ 403 findings (47.4% reduction)
โœจ Running AI enrichment...
   Enriched: 403 โ†’ 242 findings (161 duplicates dropped)

โœ… Analysis complete!
๐Ÿ“Š Found 242 total issues
๐Ÿšจ 52 critical/high severity issues require attention
Enter fullscreen mode Exit fullscreen mode

Most of what survives the heuristic pass is noise a Django app carries by default: dead code, style nits, a handful of low-confidence privacy matches. Underneath that noise sit the findings that matter.

What BrassCoders Caught

Eleven vulnerability classes, all traced to the exact file and line PyGoat's maintainers documented:

Critical Findings:

  1. Possible hardcoded credential (critical) โ€” introduction/views.py:857
  2. Subprocess call with shell=True, security issue (critical) โ€” introduction/views.py:423
  3. Use of weak MD5 hash for security (critical) โ€” introduction/views.py:1017
  4. Possible hardcoded credential (critical) โ€” introduction/views.py:859
  5. Possible hardcoded credential (critical) โ€” introduction/views.py:861

Four hardcoded credentials, all in the same file, all caught by BrassCoders's auth_pattern_analyzer. A fifth, at line 529, comes from a different detector entirely, the SecretsScanner, matching a hardcoded secret keyword rather than a credential pattern. Two detectors, two different signals, the same underlying bug.

The command injection at line 423 is the textbook CWE-78 shape: subprocess called with shell=True against a string built from request input. Bandit catches it directly. A second command-injection path, at line 421, needs more than pattern matching. BrassCoders's Semgrep-based taint scanner traces the tainted value from where it enters the request through to where it reaches the shell, and flags it separately, at critical severity, distinct from the line-423 finding.

The weak-hash finding is CWE-327: PyGoat hashes something security-relevant with MD5, a function with known collision attacks, at line 1017. Bandit's rule ID for this is B324.

The full set, all eleven, all at introduction/views.py:

Line Category Detector CWE / Bandit ID
857 hardcoded_credential auth_pattern_analyzer CWE-798
859 hardcoded_credential auth_pattern_analyzer CWE-798
861 hardcoded_credential auth_pattern_analyzer CWE-798
863 hardcoded_credential auth_pattern_analyzer CWE-798
529 hardcoded_credential SecretsScanner CWE-798
423 command_injection bandit (B602) CWE-78
421 command_injection SemgrepTaintScanner CWE-78
1017 weak_crypto bandit (B324) CWE-327
155 sql_injection bandit CWE-89
211 deserialization bandit CWE-502
451 code_injection bandit CWE-95

Four detectors, six vulnerability categories, one file. views.py in PyGoat's introduction app is doing the job it was built for: giving a scanner every OWASP Top 10 pattern in one place, so a single case study can walk through the whole set instead of chasing findings across a dozen files.

The Confidence Nuance

Not every finding above carries the same weight, and pretending otherwise would undercut the point of publishing real numbers instead of marketing copy. The SQL-injection finding, CWE-89, at views.py:155, comes through with Bandit confidence 0.65: low to medium. Bandit flagged a string-based query pattern that looks like the injection shape, but string-built SQL isn't automatically exploitable if the interpolated value never reaches user input.

BrassCoders surfaces the confidence score precisely so a reviewer doesn't take the severity label at face value. A critical tag on a low-confidence finding means "worth checking first," not "confirmed." The two remaining new catches read differently: the deserialization finding (CWE-502, unsafe pickle use at line 211) and the eval-injection finding (CWE-95, a bare eval() call at line 451) both carry Bandit confidence 0.9, high enough to triage first.

Four Gaps, Closed

Here's the part worth dwelling on. On 2026-05-17, BrassCoders published a baseline scan of this same pinned PyGoat commit, and alongside the findings, a list of what it missed: the SQL injection at line 155, the deserialization at line 211, the eval injection at line 451, and the taint-tracked command injection at line 421. Four documented vulnerabilities, published as gaps, not hidden.

That baseline is four months old. Re-running the identical scan today, against the identical commit, on BrassCoders 2.0.16, catches all four. Nothing about PyGoat changed; the pinned commit is frozen. What changed is the scanner: a new taint-tracking path for command injection, broader Bandit rule coverage on the deserialization and eval cases, and sharper confidence scoring on the SQL-injection pattern. Four gaps a customer could have read about in May are closed in September, checkable by re-running the same command against the same public page.

Publishing gaps only means something if the gap list gets shorter over time. This is the first time BrassCoders has a before-and-after on one of its own published gap lists, and the direction is the one that matters.

What This Doesn't Prove

PyGoat is built to be caught. Every line in it exists because OWASP wanted a scanner to find it, which makes an 11-of-11 result a floor, not a ceiling, for what a scanner should catch here, not a claim about performance on code nobody designed to be vulnerable. BrassCoders publishes separate noise-floor numbers against maintained, non-vulnerable projects, Django, FastAPI, Flask, at coppersun.dev/benchmarks, because a benchmark against an intentionally-broken app and a benchmark against a real production codebase answer different questions.

This scan also isn't a false-positive study. Every finding above is a true positive against PyGoat's documented vulnerability list, but that says nothing about how often BrassCoders flags something that isn't actually a problem on a codebase that wasn't built as an answer key. Django's own real-world scan, published at the same benchmarks page, surfaces 1,608 findings, none of them planted, all of them ordinary code carrying the ordinary weight of a mature framework. That number and this one measure different things. Conflating them would misrepresent both.

And confidence scores aren't guarantees. The low-confidence SQL-injection catch is real signal worth investigating, not a confirmed exploit. BrassCoders's own interpretation guidance treats false_positive_likelihood as a triage heuristic, never as ground truth on its own.

Reproduce This Yourself

Four commands reproduce every finding in this post, against the same pinned commit, on your own machine.

git clone https://github.com/adeyosemanputra/pygoat.git project
cd project && git checkout c11e8429349cc05ff38564d3bf7ef09fb2411874
pip install -r requirements.txt
brasscoders --offline scan .
Enter fullscreen mode Exit fullscreen mode

Check .brass/security_report.yaml for the findings above, or .brass/ai_instructions.yaml for the ranked, AI-consumable version. The full, current benchmark page, including the closed-gap history, is at coppersun.dev/benchmarks/pygoat. Install BrassCoders itself with pip install brasscoders. The OSS core that ran this entire scan is free, Apache 2.0 licensed, and made zero outbound network calls.

Top comments (0)