Skip to main content

Taint analysis

Taint analysis means following data that came from outside (a request parameter, a form field, a file) through the code to see where it ends up. If it reaches a risky operation such as a database query or a shell command without being checked on the way, that is a finding. The words used below (source, sink, sanitizer, call graph) are each defined in the glossary.

Vulkro runs three taint passes on every vulkro scan:

  1. Intra-procedural - within a single function.
  2. Inter-procedural, single-module - across functions in one file.
  3. Cross-file, call-graph-driven - follows the project's call graph, depth limited to 4.

Vulkro emits a finding when tainted data reaches a dangerous sink without passing through a known sanitiser. Tainted data is anything that flows from a source the engine treats as user-controlled.

Language coverage

Dataflow taint (the three passes above) is not uniform across languages. This is the honest matrix:

LanguageDataflow taintCross-fileFramework sources modelled
JavaScript / TypeScriptYesYesExpress, Fastify, Koa, NestJS, Hapi
PythonYesYesFlask, Django, FastAPI, Starlette, aiohttp, Tornado, Litestar, DRF
GoYesYesnet/http, Gin, Echo, Chi, Fiber, Gorilla mux
JavaYesNo (same-file only)Spring MVC / WebFlux, servlets, JAX-RS

Each backend emits its own family of rule IDs when tainted data reaches a sink, and the finding carries the source-to-sink hop chain (rendered as SARIF codeFlows):

  • JavaScript / TypeScript: JS-TAINT-* (for example JS-TAINT-SQL-001).
  • Python: PY-TAINT-* (for example PY-TAINT-SQL-001).
  • Go: GO-TAINT-* (SQL, command, path, SSRF, and a catch-all).
  • Java: JAVA-TAINT-* (SQL, command, path, SSRF, deserialization, reflection, and a catch-all). See Java and Spring.

Salesforce (Apex and the rest of the platform surfaces) ships in the separate Vulkro for Salesforce product with its own taint engine.

What the taint engine does not do

The limits, stated plainly:

  • No type inference. Sources and sinks are matched by surface syntax, so a variable named db is treated as a database client. tree-sitter gives the engine syntax, not resolved types.
  • No taint through container element writes. obj.x = tainted, arr[0] = tainted, and Object.assign(target, tainted) do not propagate. Reads of a deep member/subscript chain down to a tainted root are tracked (Python and JS/TS).
  • No loop-carried taint widening. Loop bodies are walked once, so a variable that only becomes tainted on a later iteration is not tracked.
  • Cross-file depth is bounded at 4 hops for JavaScript / TypeScript, Python, and Go, and the cross-file pass is skipped on very large repos (over ~6000 modules) to keep wall-clock bounded.
  • Java taint is same-file only. The Java backend follows call chains within a single file. It does not cross file boundaries, and it does not model Spring bean wiring or constructor injection as taint edges. See Java and Spring for the full limits.

Sources, sinks, sanitisers

Sources include:

  • req.body, req.query, req.params, req.headers (Express / Next.js)
  • request.json(), request.form(), request.query_params (FastAPI)
  • flask.request.*, Django request.GET / POST
  • Function parameters of route handlers
  • Reads from os.environ / process.env (configurable; off by default to keep the noise sane)

Sinks include:

  • db.query("..." + tainted), cursor.execute(...) (SQL injection)
  • child_process.exec, subprocess.run(shell=True) (command injection)
  • eval, Function(...), pickle.loads, yaml.load(..., Loader=yaml.Loader)
  • requests.get(tainted), fetch(tainted) (SSRF)
  • redirect(tainted) (open redirect)
  • Template-as-string with user data (SSTI / XSS)
  • __proto__ writes (prototype pollution)

Sanitisers include:

  • Parameterised queries (? / $1 / :name)
  • html.escape / bleach.clean
  • Allowlist-shaped enum / Pydantic / Zod schemas
  • urllib.parse.quote for URLs
  • Constant-time comparison wrappers

Cross-file propagation

A typical case the cross-file pass catches:

# routes/admin.py
@router.post("/admin/export")
def export(req):
data = req.json() # SOURCE
return run_export(data["filter"])

# services/exports.py
def run_export(filter_expr: str):
return db.execute(f"SELECT * FROM logs WHERE {filter_expr}") # SINK

The intra-procedural pass alone would miss this: req.json() and the SQL sink live in different files. The cross-file pass traces the call from export to run_export and propagates the tainted argument.

Depth limit is 4 calls deep; deeper paths are skipped to keep wall-clock bounded on large repos.

Escape hatch

If a regression in cross-file taint blocks your build, set:

VULKRO_DISABLE_INTERPROC_TAINT=1 vulkro scan .

Skips just the cross-file pass; the intra-procedural and single-module passes still run.