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:
- Intra-procedural - within a single function.
- Inter-procedural, single-module - across functions in one file.
- 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:
| Language | Dataflow taint | Cross-file | Framework sources modelled |
|---|---|---|---|
| JavaScript / TypeScript | Yes | Yes | Express, Fastify, Koa, NestJS, Hapi |
| Python | Yes | Yes | Flask, Django, FastAPI, Starlette, aiohttp, Tornado, Litestar, DRF |
| Go | Yes | Yes | net/http, Gin, Echo, Chi, Fiber, Gorilla mux |
| Java | Yes | No (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 exampleJS-TAINT-SQL-001). - Python:
PY-TAINT-*(for examplePY-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
dbis 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, andObject.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.*, Djangorequest.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.quotefor 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.