sinkline

What it does
It follows data from a web request through functions and files. It reports a finding only if the data reaches a dangerous call without the right clean-up for that kind of bug.
Why it matters
Escaping HTML does nothing for a SQL query, so a tool has to know which clean-up fits which bug.
What was found
On ten categories of OWASP BenchmarkPython (score = true-positive rate minus false-positive rate), 0.4.1 scored +0.21 and Bandit +0.07. Version 0.5.1 scores +0.52, but those fixes were chosen by reading 0.4.1's failures, so it is not a held-out score. Across all 14 categories Bandit still leads.

Try it Read the limits

On OWASP BenchmarkPython v0.1, ten taint categories, sinkline 0.4.1 scored +0.21 (TPR 0.41, FPR 0.20). 0.5.1 scores +0.52 (0.73, 0.21), but its fixes were chosen by reading the failures of 0.4.1, so that is not a held-out score. Bandit 1.9.4 scores +0.07. Over all 14 categories Bandit leads, +0.36 to +0.27.

Not held out run 43136290658 passed 2026-11-12

  • vulnerable cases reported
  • vulnerable cases missed
  • safe cases flagged
  • safe cases left alone
  • Each tick is about 10 test cases; the counts are in the labels.

sinkline 0.4.1: TP 99, FN 141, FP 89, TN 348. Score +0.21

sinkline 0.5.1: TP 175, FN 65, FP 93, TN 344. Score +0.52

Try it

Switch one small Flask app between unsafe, right sanitizer and wrong sanitizer, and read the real scanner output.

Plate 1

Recorded, not live. All nine runs are listed here; with JavaScript on, a switch shows one at a time.

SQL injection (CWE-89)

Finding Unsafe SL-SQLI-001, CWE-89, critical, confidence high. Exit status 1.

import sqlite3
from flask import Flask, request, jsonify

app = Flask(__name__)
db = sqlite3.connect("app.db", check_same_thread=False)


@app.route("/user")
def user():
    uid = request.args.get("id", "")
    cur = db.execute("SELECT name FROM users WHERE id = " + uid)
    return jsonify(cur.fetchall())
[CRITICAL] SL-SQLI-001 A03:2021 CWE-89  sql-unsafe.py:11:11
  SQL built from tainted data
  flask.request.args reaches sqlite3.Connection.execute without a sanitizer for CWE-89.
  ┌ source     sql-unsafe.py:10  uid = request.args.get("id", "")  # flask.request.args
  │ propagate  sql-unsafe.py:10  uid = request.args.get("id", "")  # .get()
  │ propagate  sql-unsafe.py:11  cur = db.execute("SELECT name FROM users WHERE id = " + uid)  # concat
  └ sink       sql-unsafe.py:11  cur = db.execute("SELECT name FROM users WHERE id = " + uid)  # sqlite3.Connection.execute
  fix: Use parameterized queries: conn.execute('... WHERE id = ?', (user_id,))

1 finding(s) in 1 file(s), 2 function(s) summarized
by OWASP: A03:2021 Injection: 1

No finding Right-class sanitizer no rule fired. Exit status 0.

Lines that differ from the unsafe version: 11.

import sqlite3
from flask import Flask, request, jsonify

app = Flask(__name__)
db = sqlite3.connect("app.db", check_same_thread=False)


@app.route("/user")
def user():
    uid = request.args.get("id", "")
    cur = db.execute("SELECT name FROM users WHERE id = ?", (uid,))
    return jsonify(cur.fetchall())
0 finding(s) in 1 file(s), 2 function(s) summarized
by OWASP: none

Finding Wrong-class sanitizer SL-SQLI-001, CWE-89, critical, confidence high. Exit status 1.

Lines that differ from the unsafe version: 1, 11. Recorded sanitizer step: shlex.quote: clears CWE-78. The finding is CWE-89.

import shlex
import sqlite3
from flask import Flask, request, jsonify

app = Flask(__name__)
db = sqlite3.connect("app.db", check_same_thread=False)


@app.route("/user")
def user():
    uid = shlex.quote(request.args.get("id", ""))
    cur = db.execute("SELECT name FROM users WHERE id = " + uid)
    return jsonify(cur.fetchall())
[CRITICAL] SL-SQLI-001 A03:2021 CWE-89  sql-wrong.py:12:11
  SQL built from tainted data
  flask.request.args reaches sqlite3.Connection.execute without a sanitizer for CWE-89.
  ┌ source     sql-wrong.py:11  uid = shlex.quote(request.args.get("id", ""))  # flask.request.args
  │ propagate  sql-wrong.py:11  uid = shlex.quote(request.args.get("id", ""))  # .get()
  │ sanitizer  sql-wrong.py:11  uid = shlex.quote(request.args.get("id", ""))  # shlex.quote: clears CWE-78
  │ propagate  sql-wrong.py:11  uid = shlex.quote(request.args.get("id", ""))  # assigned to `uid`
  │ propagate  sql-wrong.py:12  cur = db.execute("SELECT name FROM users WHERE id = " + uid)  # concat
  └ sink       sql-wrong.py:12  cur = db.execute("SELECT name FROM users WHERE id = " + uid)  # sqlite3.Connection.execute
  fix: Use parameterized queries: conn.execute('... WHERE id = ?', (user_id,))

1 finding(s) in 1 file(s), 2 function(s) summarized
by OWASP: A03:2021 Injection: 1

Command injection (CWE-78)

Finding Unsafe SL-CMDI-003, CWE-78, critical, confidence high. Exit status 1.

import subprocess
from flask import Flask, request

app = Flask(__name__)


@app.route("/ping")
def ping():
    try:
        host = request.args["h"]
    except KeyError:
        host = "localhost"
    subprocess.run("ping -c 1 " + host, shell=True)
    return "sent"
[CRITICAL] SL-CMDI-003 A03:2021 CWE-78  cmd-unsafe.py:13:5
  subprocess with shell=True and tainted command
  flask.request.args reaches subprocess.run without a sanitizer for CWE-78.
  ┌ source     cmd-unsafe.py:10  host = request.args["h"]  # flask.request.args
  │ propagate  cmd-unsafe.py:10  host = request.args["h"]  # assigned to `host`
  │ propagate  cmd-unsafe.py:13  subprocess.run("ping -c 1 " + host, shell=True)  # concat
  └ sink       cmd-unsafe.py:13  subprocess.run("ping -c 1 " + host, shell=True)  # subprocess.run
  fix: Drop shell=True and pass an argument list.

1 finding(s) in 1 file(s), 2 function(s) summarized
by OWASP: A03:2021 Injection: 1

No finding Right-class sanitizer no rule fired. Exit status 0.

Lines that differ from the unsafe version: 1, 14.

import shlex
import subprocess
from flask import Flask, request

app = Flask(__name__)


@app.route("/ping")
def ping():
    try:
        host = request.args["h"]
    except KeyError:
        host = "localhost"
    subprocess.run("ping -c 1 " + shlex.quote(host), shell=True)
    return "sent"
0 finding(s) in 1 file(s), 2 function(s) summarized
by OWASP: none

Finding Wrong-class sanitizer SL-CMDI-003, CWE-78, critical, confidence high. Exit status 1.

Lines that differ from the unsafe version: 1, 14. Recorded sanitizer step: html.escape: clears CWE-79. The finding is CWE-78.

import html
import subprocess
from flask import Flask, request

app = Flask(__name__)


@app.route("/ping")
def ping():
    try:
        host = request.args["h"]
    except KeyError:
        host = "localhost"
    subprocess.run("ping -c 1 " + html.escape(host), shell=True)
    return "sent"
[CRITICAL] SL-CMDI-003 A03:2021 CWE-78  cmd-wrong.py:14:5
  subprocess with shell=True and tainted command
  flask.request.args reaches subprocess.run without a sanitizer for CWE-78.
  ┌ source     cmd-wrong.py:11  host = request.args["h"]  # flask.request.args
  │ propagate  cmd-wrong.py:11  host = request.args["h"]  # assigned to `host`
  │ sanitizer  cmd-wrong.py:14  subprocess.run("ping -c 1 " + html.escape(host), shell=True)  # html.escape: clears CWE-79
  │ propagate  cmd-wrong.py:14  subprocess.run("ping -c 1 " + html.escape(host), shell=True)  # concat
  └ sink       cmd-wrong.py:14  subprocess.run("ping -c 1 " + html.escape(host), shell=True)  # subprocess.run
  fix: Drop shell=True and pass an argument list.

1 finding(s) in 1 file(s), 2 function(s) summarized
by OWASP: A03:2021 Injection: 1

Cross-site scripting (CWE-79)

Finding Unsafe SL-XSS-004, CWE-79, high, confidence high. Exit status 1.

from flask import Flask, request

app = Flask(__name__)


@app.route("/hello")
def hello():
    return request.args.get("name", "").strip()
[HIGH] SL-XSS-004 A03:2021 CWE-79  html-unsafe.py:8:5
  Reflected XSS: tainted string returned from view
  flask.request.args reaches __return_http__ without a sanitizer for CWE-79.
  ┌ source     html-unsafe.py:8  return request.args.get("name", "").strip()  # flask.request.args
  │ propagate  html-unsafe.py:8  return request.args.get("name", "").strip()  # .get()
  └ sink       html-unsafe.py:8  return request.args.get("name", "").strip()  # __return_http__
  fix: Return a rendered template or escape the value with markupsafe.escape().

1 finding(s) in 1 file(s), 2 function(s) summarized
by OWASP: A03:2021 Injection: 1

No finding Right-class sanitizer no rule fired. Exit status 0.

Lines that differ from the unsafe version: 2, 9.

from flask import Flask, request
from markupsafe import escape

app = Flask(__name__)


@app.route("/hello")
def hello():
    return escape(request.args.get("name", "").strip())
0 finding(s) in 1 file(s), 2 function(s) summarized
by OWASP: none

Finding Wrong-class sanitizer SL-XSS-004, CWE-79, high, confidence high. Exit status 1.

Lines that differ from the unsafe version: 1, 9, 10. Recorded sanitizer step: shlex.quote: clears CWE-78. The finding is CWE-79.

import shlex
from flask import Flask, request

app = Flask(__name__)


@app.route("/hello")
def hello():
    name = shlex.quote(request.args.get("name", "").strip())
    return name
[HIGH] SL-XSS-004 A03:2021 CWE-79  html-wrong.py:10:5
  Reflected XSS: tainted string returned from view
  flask.request.args reaches __return_http__ without a sanitizer for CWE-79.
  ┌ source     html-wrong.py:9  name = shlex.quote(request.args.get("name", "").strip())  # flask.request.args
  │ propagate  html-wrong.py:9  name = shlex.quote(request.args.get("name", "").strip())  # .get()
  │ sanitizer  html-wrong.py:9  name = shlex.quote(request.args.get("name", "").strip())  # shlex.quote: clears CWE-78
  │ propagate  html-wrong.py:9  name = shlex.quote(request.args.get("name", "").strip())  # assigned to `name`
  └ sink       html-wrong.py:10  return name  # __return_http__
  fix: Return a rendered template or escape the value with markupsafe.escape().

1 finding(s) in 1 file(s), 2 function(s) summarized
by OWASP: A03:2021 Injection: 1
A sanitizer clears a finding only for its own bug class: shlex.quote clears command injection and leaves SQL injection and XSS reported, and html.escape leaves command injection reported. Recorded, not live: every output is the unedited output of sinkline scan --no-color --fail-on low FILE (sinkline 0.5.1, tag v0.5.1, commit f879e88) on the file shown, run 2026-11-12. The page runs no analysis; the switch only chooses which recorded output to show. Files, outputs and the capture script: assets/data/sinkline-bench, scripts/capture_sinkline_bench.py. Two unsafe files were missed by older versions, checked by running them: 0.4.0 reports nothing on the try/except file (join fix in 0.4.1) and 0.4.1 reports nothing on the XSS file, which returns a call result (fix in 0.5.0).
Checked against
OWASP BenchmarkPython v0.1, 10 taint categories; Bandit +0.07
Found, then left
As found 0.4.1: TPR 0.41, FPR 0.20, score +0.21
As left 0.5.1: TPR 0.73, FPR 0.21, score +0.52
Limits
Fixes chosen from the 0.4.1 failures; Bandit leads over all 14 categories Full limits
Technical description and verification record: instrument, reference standard, date, how to reproduce (self-issued, not accredited)

A static analyzer for Python web apps. It follows attacker-controlled input across functions and files and reports it only when it reaches a dangerous sink without a sanitizer for that bug class.

Instrument
sinkline v0.5.1, commit f879e88 (0.4.1, commit 9a36ba2, is the as-found reading)
Reference standard
OWASP BenchmarkPython v0.1, commit f129148: 1,230 labeled Flask test cases; Bandit 1.9.4 and Semgrep OSS 1.185.0 on the same files
Measured on
2026-11-12, Python 3.12.2; sinkline 0.5.1 re-run on the suite for this page, per-test table identical to the committed 0.5.0 result, other results from the repository (scored with its own script, not the official scorer)
Issued by
The author. Self-issued verification record, not accredited.
Reproduce
bench/external/run.sh WORKDIR v0.5.0 sinkline bandit semgrep
python bench/score.py

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.

TPR minus FPR is the benchmark's score. TPR is the share of vulnerable cases reported; FPR is the share of safe cases reported anyway. 0 means no better than guessing, 1 is perfect.

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.

Script injection (CWE-78) happens when untrusted text is pasted into a shell command, so the shell runs it as code. Passing it as an environment variable keeps it as data.

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
Reading ledger
CheckExpectedObservedVerdictEvidence
LDAP and XPath injection, 0.4.1Most vulnerable cases reported5 of 67 reported (LDAP 0 of 16, XPath 5 of 51)As found: failresults/v0.4.1
Command injection, 0.4.1More vulnerable cases reported than safe ones4 of 13 vulnerable, 4 of 7 safe: score -0.26As found: failresults/v0.4.1
Weak hash, weak PRNG, cookie flags, trust boundaryOut of scope, reported anyway0 of 212 vulnerable cases reportedNo checkbench/external/README.md
All 14 categories against Bandit, 0.5.1sinkline ahead+0.27 against Bandit +0.36Bandit aheadresults/v0.5.0 (0.5.1 rows are identical)
GitHub Action input handling, before 0.4.1Inputs reach the shell only as quoted dataInputs interpolated into a shell lineAs found: failCHANGELOG 0.4.1, Security
The same Action, 0.4.1Hostile values stay dataTested with hostile valuesAs left: passtests/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
Per category: TPR / FPR / TPR minus FPR, with real and safe case counts
CategoryReal / safesinkline 0.4.1sinkline 0.5.1Bandit 1.9.4Semgrep OSS 1.185.0
sqli5 / 110.80 / 0.00 / +0.800.80 / 0.00 / +0.801.00 / 1.00 / +0.000.00 / 0.00 / +0.00
cmdi13 / 70.31 / 0.57 / -0.260.38 / 0.14 / +0.241.00 / 1.00 / +0.000.54 / 1.00 / -0.46
codeinj20 / 330.65 / 0.52 / +0.130.75 / 0.39 / +0.360.00 / 0.00 / +0.000.00 / 0.00 / +0.00
xss31 / 580.74 / 0.50 / +0.240.77 / 0.31 / +0.460.00 / 0.00 / +0.000.00 / 0.00 / +0.00
pathtraver65 / 1030.31 / 0.08 / +0.230.66 / 0.09 / +0.570.00 / 0.00 / +0.000.03 / 0.02 / +0.01
redirect13 / 210.69 / 0.38 / +0.310.69 / 0.38 / +0.310.00 / 0.00 / +0.000.00 / 0.00 / +0.00
deserialization18 / 360.83 / 0.14 / +0.690.89 / 0.06 / +0.830.72 / 0.19 / +0.530.50 / 0.17 / +0.33
ldapi16 / 130.00 / 0.00 / +0.000.69 / 0.15 / +0.530.00 / 0.00 / +0.000.00 / 0.00 / +0.00
xpathi51 / 1350.10 / 0.04 / +0.060.80 / 0.22 / +0.580.00 / 0.00 / +0.000.00 / 0.00 / +0.00
xxe8 / 200.75 / 0.65 / +0.100.88 / 0.50 / +0.380.00 / 0.00 / +0.000.00 / 0.00 / +0.00
hash (no check in sinkline)71 / 800.00 / 0.00 / +0.000.00 / 0.00 / +0.001.00 / 0.00 / +1.000.76 / 0.00 / +0.76
weakrand (no check in sinkline)99 / 2270.00 / 0.00 / +0.000.00 / 0.00 / +0.000.78 / 0.00 / +0.780.00 / 0.00 / +0.00
securecookie (no check in sinkline)24 / 150.00 / 0.00 / +0.000.00 / 0.00 / +0.000.00 / 0.00 / +0.000.00 / 0.00 / +0.00
trustbound (no check in sinkline)18 / 190.00 / 0.00 / +0.000.00 / 0.00 / +0.000.00 / 0.00 / +0.000.00 / 0.00 / +0.00
taint categories (10)240 / 4370.41 / 0.20 / +0.210.73 / 0.21 / +0.520.13 / 0.06 / +0.070.07 / 0.03 / +0.04
all categories (14)452 / 7780.22 / 0.11 / +0.100.39 / 0.12 / +0.270.40 / 0.03 / +0.360.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
Results
MeasureValueSource
Tests184 (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 categoriessinkline 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.04bench/external/README.md; sinkline 0.5.1 re-run for this page
OWASP BenchmarkPython v0.1, all 14 categoriessinkline 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.14results/v0.5.0 (0.5.1 rows are identical)
Regression gate, self-labeled corpusTP 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.4TP 33, FP 20, FN 28, TN 28; P 0.623, R 0.541, F1 0.579bench/compare_bandit.py
vulpy @ 5249cc85 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.pydocs/examples; counts re-run for this page
Vulnerable-Flask-App @ b6a4f975 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 @ a1d8f892 findings (CWE-89, CWE-117)docs/examples; counts re-run for this page
microblog @ a975ef61 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 SSRFcounts re-run for this page
Rules43 rules, 151 sink signaturesREADME; sinkline rules
Releasev0.5.1GitHub release
Technical details

How 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.

Plate 2sinkline analysis passesPython files are parsed and names resolved. Pass 1 computes a summary per function to a fixpoint. Pass 2 re-runs with real sources and uses the summaries at each call site. Findings come out as text, JSON, SARIF or HTML.Parse and resolve namesast, imports, aliases, receivertypesAPass 1: function summariesparameters as labels, repeated toa fixpointBPass 2: real sourcesrequest data enters; summariesused at call sitesCTaint with CWE-scopedsanitizersjoin: union of labels, intersectcleared CWEsDSink matchkeyword gates, receiver types,CWE not clearedEReport with the source-to-sinkpathtext, JSON, SARIF 2.1.0, HTMLF
Python files are parsed and names resolved. Pass 1 computes a summary per function to a fixpoint. Pass 2 re-runs with real sources and uses the summaries at each call site. Findings come out as text, JSON, SARIF or HTML.
Describe this diagram
  1. Parse and resolve names. ast, imports, aliases, receiver types.
  2. Pass 1: function summaries. parameters as labels, repeated to a fixpoint.
  3. Pass 2: real sources. request data enters; summaries used at call sites.
  4. Taint with CWE-scoped sanitizers. join: union of labels, intersect cleared CWEs.
  5. Sink match. keyword gates, receiver types, CWE not cleared.
  6. 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.

CWE numbers name bug classes: CWE-78 is OS command injection, CWE-79 cross-site scripting, CWE-89 SQL injection.

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.

Tolerance and limitsWhat the numbers do not show

  • The 0.5.1 score is not held out. The fixes (made in 0.5.0, unchanged on this suite in 0.5.1) were chosen by reading the failures of 0.4.1 on the same benchmark. Only the 0.4.1 reading is an independent measurement, and it was +0.21 on the taint categories.
  • One synthetic suite. BenchmarkPython v0.1 is a preliminary release of templated cases (many share one shape), and the SQL, command and XXE categories have only 5 to 13 vulnerable cases. The scorer is the repository's own script, not the official one, so the benchmark's own scorecard may differ.
  • High false-positive rate. FPR is 0.21 on the taint categories. The largest remaining source is position-sensitive lists (a list is tracked as a whole), then deliberately obfuscated safe variants. Taint stored through ConfigParser or a user-defined request wrapper is lost.
  • Four categories have no checks: weak hash, weak PRNG, cookie flags and trust boundary. Over all 14 categories Bandit leads, +0.36 to +0.27.
  • The self-labeled corpus is not independent. 109 lines, labeled alongside the engine, kept as a regression gate. Its F1 of 0.992 is not an accuracy estimate.
  • The public-project scans are unlabeled. Three of the four are deliberately vulnerable training apps. The one microblog finding is parameter injection into a fixed host, which sinkline labels SSRF, not a proven SSRF, and its redirect check is still treated as a guard. Neither the new findings nor the absence of others is a measure of accuracy. No CVEs or real-world vulnerabilities are claimed.
  • Plate 1 shows recorded runs of small files, not a benchmark. One case is worth knowing: return shlex.quote(request.args.get("name", "")), is not reported in 0.5.1 (the changelog says a returned call result counts as XSS only when it is the tainted string itself, and an unknown call is not guessed at), while the same value assigned to a variable first is. The recorded run is in assets/data/sinkline-bench/html-wrong-direct.txt.
  • Known design limits. Taint passed between requests through a module global, returned closures, dynamic dispatch, and instance fields tracked per class rather than per object. Python only, summaries context-insensitive, and no coverage for OWASP A04 and A06.
Evidence

Where each number comes from

11 links

Chips show the state of each GitHub Actions run as captured when this page was built. The page does not call GitHub at runtime.

Release
v0.5.1
CI run on main (v0.5.1)
run 43136290658 passed 2026-11-12
Report workflow on main
run 43136290530 passed 2026-11-12
External benchmark, method and results
bench/external/README.md
Per-category results, 0.4.1 and 0.5.0 (0.5.1 is identical to 0.5.0)
bench/external/results
Changelog 0.5.1
CHANGELOG.md
Security fix for the Action
CHANGELOG 0.4.1, Security
Regression-gate scripts
bench/
Public-project scans
docs/examples
Sanitizer switch captures
assets/data/sinkline-bench