As foundOn an independent benchmark 0.4.1 scored +0.21, and Bandit leads over all 14 categories
The first reading is on code the engine was not built around. OWASP's BenchmarkPython v0.1 (commit f129148) is 1,230 small Flask test cases, each labeled vulnerable or safe in a published file. Ten of its 14 categories are source-to-sink flows, which is what sinkline is for: 240 vulnerable cases and 437 safe ones.
sinkline 0.4.1 reported 99 of the 240 vulnerable cases (TPR 0.41) and flagged 89 of the 437 safe ones (FPR 0.20), a score of +0.21 (TPR minus FPR). Two categories were close to blank: LDAP injection, 0 of 16, and XPath injection, 5 of 51. Command injection scored below zero (-0.26): it flagged 4 of 7 safe cases and only 4 of 13 vulnerable ones. Bandit 1.9.4 scored +0.07 and Semgrep OSS 1.185.0 +0.04 on the same ten categories, mostly because neither has rules for most of them.
Over all 14 categories the order flips. Bandit scores +0.36 against sinkline 0.4.1's +0.10, because Bandit checks weak hashes (71 of 71) and weak random numbers (77 of 99), and sinkline has nothing for weak hashes, weak random numbers, cookie flags or what is stored in the session. It scores 0 on those four categories. Bandit still leads over all 14 with 0.5.0.
- vulnerable cases reported
- vulnerable cases missed
- safe cases flagged
- safe cases left alone
- Each tick is about 20 test cases; the counts are in the labels.
TP 99, FN 353, FP 89, TN 689. Score +0.10
TP 179, FN 273, FP 25, TN 753. Score +0.36
TP 72, FN 380, FP 15, TN 763. Score +0.14
Separately, a flaw was found in sinkline's own GitHub Action. Its inputs (path, fail-on, min-confidence) were interpolated straight into a shell line with ${{ inputs.* }}, so a workflow that passed an attacker-influenced value could run arbitrary commands on the runner. Version 0.4.1 passes them through env:, quoted, and tests/test_action.py runs the script with hostile values.
One more analysis bug is in the changelog: a try/except lost taint, so host = request.args["h"] followed by except: host = "localhost" reported nothing (fixed in 0.4.1; the command-injection example on Plate 1 has that shape).
Table, 6 rows: Reading ledger
| Check | Expected | Observed | Verdict | Evidence |
|---|---|---|---|---|
| LDAP and XPath injection, 0.4.1 | Most vulnerable cases reported | 5 of 67 reported (LDAP 0 of 16, XPath 5 of 51) | As found: fail | results/v0.4.1 |
| Command injection, 0.4.1 | More vulnerable cases reported than safe ones | 4 of 13 vulnerable, 4 of 7 safe: score -0.26 | As found: fail | results/v0.4.1 |
| Weak hash, weak PRNG, cookie flags, trust boundary | Out of scope, reported anyway | 0 of 212 vulnerable cases reported | No check | bench/external/README.md |
| All 14 categories against Bandit, 0.5.1 | sinkline ahead | +0.27 against Bandit +0.36 | Bandit ahead | results/v0.5.0 (0.5.1 rows are identical) |
| GitHub Action input handling, before 0.4.1 | Inputs reach the shell only as quoted data | Inputs interpolated into a shell line | As found: fail | CHANGELOG 0.4.1, Security |
| The same Action, 0.4.1 | Hostile values stay data | Tested with hostile values | As left: pass | tests/test_action.py |
As left0.5.1 scores +0.52 on the taint categories; the fixes were chosen from the failures
sinkline 0.5.1 (tag v0.5.1, commit f879e88, 2026-11-11) scores TPR 0.73 and FPR 0.21 on the same ten categories, +0.52 against 0.4.1's +0.21 (175 of 240 vulnerable cases reported, 93 of 437 safe ones flagged). The changes were more sink coverage (codecs.open, elementpath.select, lxml XPath, pathlib reads and writes), pruning of statically decided branches, correct handling of match, receiver types through helper functions, and treating a returned call result as XSS when it is the tainted string itself. Those changes were made in 0.5.0; 0.5.1 fixes other things (lambdas, get_json() taint, exit codes) and produces the same per-test table, which I re-ran for this page and compared with the committed 0.5.0 result.
This is not a held-out score. The fixes were chosen by reading 0.4.1's failures on this same suite, so the 0.5.0 number, unchanged in 0.5.1, is what the fixes recover on the suite that motivated them. Treat 0.4.1 as the independent measurement. The false-positive rate did not fall (0.20 to 0.21): lists tracked as a whole container are the largest remaining source. The scorer is the repository's own script, not the benchmark's official one (BenchmarkUtils, not run).
Table, 16 rows: Per category: TPR / FPR / TPR minus FPR, with real and safe case counts
| Category | Real / safe | sinkline 0.4.1 | sinkline 0.5.1 | Bandit 1.9.4 | Semgrep OSS 1.185.0 |
|---|---|---|---|---|---|
| sqli | 5 / 11 | 0.80 / 0.00 / +0.80 | 0.80 / 0.00 / +0.80 | 1.00 / 1.00 / +0.00 | 0.00 / 0.00 / +0.00 |
| cmdi | 13 / 7 | 0.31 / 0.57 / -0.26 | 0.38 / 0.14 / +0.24 | 1.00 / 1.00 / +0.00 | 0.54 / 1.00 / -0.46 |
| codeinj | 20 / 33 | 0.65 / 0.52 / +0.13 | 0.75 / 0.39 / +0.36 | 0.00 / 0.00 / +0.00 | 0.00 / 0.00 / +0.00 |
| xss | 31 / 58 | 0.74 / 0.50 / +0.24 | 0.77 / 0.31 / +0.46 | 0.00 / 0.00 / +0.00 | 0.00 / 0.00 / +0.00 |
| pathtraver | 65 / 103 | 0.31 / 0.08 / +0.23 | 0.66 / 0.09 / +0.57 | 0.00 / 0.00 / +0.00 | 0.03 / 0.02 / +0.01 |
| redirect | 13 / 21 | 0.69 / 0.38 / +0.31 | 0.69 / 0.38 / +0.31 | 0.00 / 0.00 / +0.00 | 0.00 / 0.00 / +0.00 |
| deserialization | 18 / 36 | 0.83 / 0.14 / +0.69 | 0.89 / 0.06 / +0.83 | 0.72 / 0.19 / +0.53 | 0.50 / 0.17 / +0.33 |
| ldapi | 16 / 13 | 0.00 / 0.00 / +0.00 | 0.69 / 0.15 / +0.53 | 0.00 / 0.00 / +0.00 | 0.00 / 0.00 / +0.00 |
| xpathi | 51 / 135 | 0.10 / 0.04 / +0.06 | 0.80 / 0.22 / +0.58 | 0.00 / 0.00 / +0.00 | 0.00 / 0.00 / +0.00 |
| xxe | 8 / 20 | 0.75 / 0.65 / +0.10 | 0.88 / 0.50 / +0.38 | 0.00 / 0.00 / +0.00 | 0.00 / 0.00 / +0.00 |
| hash (no check in sinkline) | 71 / 80 | 0.00 / 0.00 / +0.00 | 0.00 / 0.00 / +0.00 | 1.00 / 0.00 / +1.00 | 0.76 / 0.00 / +0.76 |
| weakrand (no check in sinkline) | 99 / 227 | 0.00 / 0.00 / +0.00 | 0.00 / 0.00 / +0.00 | 0.78 / 0.00 / +0.78 | 0.00 / 0.00 / +0.00 |
| securecookie (no check in sinkline) | 24 / 15 | 0.00 / 0.00 / +0.00 | 0.00 / 0.00 / +0.00 | 0.00 / 0.00 / +0.00 | 0.00 / 0.00 / +0.00 |
| trustbound (no check in sinkline) | 18 / 19 | 0.00 / 0.00 / +0.00 | 0.00 / 0.00 / +0.00 | 0.00 / 0.00 / +0.00 | 0.00 / 0.00 / +0.00 |
| taint categories (10) | 240 / 437 | 0.41 / 0.20 / +0.21 | 0.73 / 0.21 / +0.52 | 0.13 / 0.06 / +0.07 | 0.07 / 0.03 / +0.04 |
| all categories (14) | 452 / 778 | 0.22 / 0.11 / +0.10 | 0.39 / 0.12 / +0.27 | 0.40 / 0.03 / +0.36 | 0.16 / 0.02 / +0.14 |
The regression gate
The repository also keeps a small self-labeled corpus: 109 lines (61 vulnerable, 48 safe) labeled alongside the engine. It is not independent, so it says little about accuracy on new code. It runs in CI to catch regressions, and it did not move between 0.4.1 and 0.5.1. Run on 2026-11-12 from a fresh clone of v0.5.1 (commit f879e88), Python 3.12.2, bandit 1.9.4.
- vulnerable lines reported
- vulnerable lines missed
- safe lines left alone
- safe lines flagged
- Ticks are grouped by outcome, not in file order.
TP 60, FP 0, FN 1, TN 48. F1 0.992
TP 33, FP 20, FN 28, TN 28. F1 0.579
$ python bench/score.py
corpus: 109 labeled lines (61 vuln, 48 safe)
TP 60 FP 0 FN 1 TN 48
precision 1.000 recall 0.984 F1 0.992
of which known-limitation: FN 1, FP 0
-> every miss is a documented design limit
$ python bench/compare_bandit.py
bandit: 84 raw issues on the corpus
bandit on labeled lines: TP 33 FP 20 FN 28 TN 28
precision 0.623 recall 0.541 F1 0.579 (lenient: any bandit hit on a VULN line counts)
Read the Bandit row as scope, not a verdict: Bandit is a pattern linter with no data-flow model, and its false positives are mostly rules that fire on any string-built SQL or any subprocess call, including the labeled-safe variants. The figure also depends on the Bandit version (1.8.0 gives F1 0.561).
A cross-file finding on the corpus
The source is in one file and the sink in another, reached through a method call on a locally constructed object.
$ sinkline scan bench/corpus
...
[CRITICAL] SL-CMDI-003 A03:2021 CWE-78 hard_app.py:34:5
subprocess with shell=True and tainted command
flask.request.args reaches subprocess.call (inside Shell.run, hard_helpers.py:17) without a sanitizer for CWE-78.
┌ source hard_app.py:34 Shell().run(request.args["cmd"]) # flask.request.args
│ call hard_app.py:34 Shell().run(request.args["cmd"]) # passed as `cmd` to Shell.run()
└ sink hard_helpers.py:17 subprocess.call(cmd, shell=True) # subprocess.call inside Shell.run()
Trimmed: one finding of 73 shown, the corpus's # VULN:CWE-78 label comments and padding whitespace removed from the source lines.
Table, 11 rows: Results
| Measure | Value | Source |
|---|---|---|
| Tests | 184 (includes the corpus gate, the Action tests, the external-benchmark fixes and the 0.5.1 exit-code and lambda tests) | CHANGELOG 0.5.1; run 43136290658 passed 2026-11-12 |
| OWASP BenchmarkPython v0.1, 10 taint categories | sinkline 0.5.1: TPR 0.73, FPR 0.21, +0.52 (same per-test table as 0.5.0; 0.4.1: 0.41, 0.20, +0.21); Bandit 1.9.4: 0.13, 0.06, +0.07; Semgrep OSS 1.185.0: 0.07, 0.03, +0.04 | bench/external/README.md; sinkline 0.5.1 re-run for this page |
| OWASP BenchmarkPython v0.1, all 14 categories | sinkline 0.5.1: 0.39, 0.12, +0.27; Bandit 1.9.4: 0.40, 0.03, +0.36; Semgrep OSS 1.185.0: 0.16, 0.02, +0.14 | results/v0.5.0 (0.5.1 rows are identical) |
| Regression gate, self-labeled corpus | TP 60, FP 0, FN 1, TN 48; P 1.000, R 0.984, F1 0.992 (not independent) | bench/score.py |
| Same corpus, Bandit 1.9.4 | TP 33, FP 20, FN 28, TN 28; P 0.623, R 0.541, F1 0.579 | bench/compare_bandit.py |
| vulpy @ 5249cc8 | 5 findings (4 CWE-89, 1 CWE-22), all in bad/; 0 in good/. 0.5.0 reported 3; the two new ones are request.get_json() data in bad/mod_api.py | docs/examples; counts re-run for this page |
| Vulnerable-Flask-App @ b6a4f97 | 5 findings: SSTI and XSS on the same call, SQLi, XSS, MD5 (0.4.1 reported 4; the XSS finding on the template call is new in 0.5.0) | docs/examples; counts re-run for this page |
| dvpwa @ a1d8f89 | 2 findings (CWE-89, CWE-117) | docs/examples; counts re-run for this page |
| microblog @ a975ef6 | 1 finding (CWE-918), new in 0.5.1: request.get_json() fields reach the query string of a fixed-host URL in app/translate.py. Parameter injection into a fixed host, not a proven SSRF | counts re-run for this page |
| Rules | 43 rules, 151 sink signatures | README; sinkline rules |
| Release | v0.5.1 | GitHub release |
Technical detailsHow it works
design, implementation points and diagram
The problem
A pattern linter reports every cursor.execute whether the argument came from a config file or from request.args, so reviewers learn to ignore it. The useful question is whether attacker-controlled data can reach the sink without passing through something that neutralizes it for this bug class.
In web apps that answer usually crosses a function or file boundary: a view reads request.form and a helper two files away builds the SQL.
What it does
Pure Python on the standard ast module, with no dependencies. It knows Flask, FastAPI, Django and aiohttp sources, and has 43 rules with 151 sink signatures mapped to OWASP 2021 and CWE. Output is text, JSON, SARIF 2.1.0 (with code flows) or a self-contained HTML report. It also ships as a GitHub Action and a pre-commit hook, and supports --baseline so an existing codebase can fail only on new findings.
Describe this diagram
- Parse and resolve names. ast, imports, aliases, receiver types.
- Pass 1: function summaries. parameters as labels, repeated to a fixpoint.
- Pass 2: real sources. request data enters; summaries used at call sites.
- Taint with CWE-scoped sanitizers. join: union of labels, intersect cleared CWEs.
- Sink match. keyword gates, receiver types, CWE not cleared.
- Report with the source-to-sink path. text, JSON, SARIF 2.1.0, HTML.
Three implementation points
Two-pass interprocedural summaries
Pass 1 analyzes each function with its parameters bound to symbolic labels (param:0, param:1, ...) and records which parameters reach the return value, which reach sinks, and whether the function is a thin sanitizer. It repeats until no summary changes, so a sink four helpers deep is found whatever order the helpers are defined in. Pass 2 re-runs with real sources and consults the summary at each call site. The cost is that summaries are context-insensitive: one per function, not per call site. Summary keys are module-qualified, so vulpy's bad/ and good/ trees, which define the same function names, give different results.
CWE-scoped sanitizers
A tainted value carries the set of CWEs it has been cleaned for. shlex.quote clears command injection only, html.escape clears XSS only, and int() clears everything. A single global "sanitized" flag makes a shell-safe string look SQL-safe. At a branch join, taint labels are unioned and cleared CWEs are intersected, so a value stays clean only if every path cleaned it. Validation guards such as if not x.isdigit(): abort() work the same way and are scoped too: a .. check is a path guard, not a shell guard. Plate 1 shows this with recorded output: the sanitizer step appears in the path, with the CWE it clears.
try/except needs a real join
Version 0.4.1 fixed a false negative where host = request.args["h"] followed by except: host = "localhost" lost the taint, because the handler body was visited in sequence after the try body. Taint after a try is now the join of the body (plus else) and each handler that does not exit, with handlers entered from the join of every state the body passed through.