Five of the top ten weaknesses in the 2025 CWE Top 25 are the same bug wearing different clothes. Cross-site scripting (CWE-79) is first, SQL injection (CWE-89) second, path traversal (CWE-22) sixth, OS command injection (CWE-78) ninth and code injection (CWE-94) tenth. In each one, a value an attacker controls reaches a place where it is interpreted as something other than data: HTML, SQL, a filesystem path, a shell command, source code.
That shared shape is why taint analysis exists. Rather than teaching a scanner what SQL injection looks like in every framework, you teach it where untrusted data comes from, where data becomes dangerous, and what makes it safe in between. Then you let it follow the data.
This post explains the four parts of that model, shows what a traced flow looks like, and spends its last section on the distinction that matters most in practice: the difference between a finding the scanner found and a finding the scanner proved.
The four parts
Source. A point where data enters from outside the trust boundary: query parameters, path segments, headers, the body, cookies, a message payload, CLI arguments. Anything an attacker can set is a source.
Propagation. The steps by which a tainted value moves through the program. Assignment carries taint. String concatenation and formatting carry it into the result. Passing the value as an argument carries it into the callee; returning it carries it back out. Storing it in an object field and reading the field later carries it across time. Each of these is a hop, and the scanner records every one.
Sanitizer. A function that removes taint for a particular sink. HTML escaping makes a value safe for a template but not for a query. Parameter binding makes it safe for SQL but not for a shell. An allow-list check makes it safe for the one sink the list was written for. A sanitizer is always relative to a sink type, which is why "I validated it" is not an answer until you say what you validated it for.
Sink. A point where the value is interpreted rather than stored. cursor.execute, res.send with HTML, child_process.exec, fs.readFile with a path, eval. A sink is dangerous only when tainted data reaches it unsanitized, and every sink has its own notion of sanitized.
A finding is a path from a source to a sink with no matching sanitizer on it. That is the whole model. Everything else is engineering: how far the scanner can follow a hop, how it handles a library it cannot see into, and how honestly it reports what it could not resolve.
What a traced flow looks like
Take an Express handler that reads a record by id.
app.get("/invoices/:id", async (req, res) => {
const id = req.params.id;
const row = await db.query(`SELECT * FROM invoices WHERE id = ${id}`);
res.json(row);
});The trace is short: req.params.id (source) is assigned to id (hop), interpolated into a template literal (hop), passed to db.query (sink). Three hops, no sanitizer, SQL injection. The fix is parameter binding, the sanitizer for this sink.
const row = await db.query("SELECT * FROM invoices WHERE id = $1", [id]);Now the same value through a helper in another file.
// routes/invoices.ts
app.get("/invoices", async (req, res) => {
res.json(await findInvoices(req.query));
});
// lib/invoices.ts
export async function findInvoices(filter: Record<string, string>) {
const where = buildWhere(filter);
return db.query(`SELECT * FROM invoices WHERE ${where}`);
}The scanner has to follow req.query into findInvoices, into buildWhere, back out as where, and into the query. Whether that is a finding depends on buildWhere. If it is in the repository and concatenates keys and values, the flow is proven across three files. If it comes from a package the scanner cannot read, there is a sink fed by something derived from the request and no way to show what happened in between.
vulkro dataflow prints this per REST endpoint or event: the functions the data passes through, the sinks and stores it reaches, the sanitizers applied along the way, and whether the data carries PII. Output is available as a table, JSON, Mermaid, DOT or Markdown. Documentation: vulkro dataflow.
vulkro dataflow . --reaching-sink --format mermaidThe --reaching-sink filter keeps only flows that end at a security-relevant sink, which is the view a reviewer wants.
Why "found" and "proven" are different words
In the first example the scanner saw every hop. In the second it may not have. Both are findings; one is proven.
Most scanners collapse that difference into a single severity. Vulkro keeps the distinction in the output. Every finding has a disposition: admitted when the path is fully traced (proven), demoted when the pattern matched but a hop could not be resolved (unproven), and not-examined when the class cannot be decided from source at all (not checked). vulkro scan --disposition admitted shows only the proven tier; demoted and not-examined show the others. The filter changes the table, not the findings, the exit code or the JSON.
The not-checked tier exists because some of the most important classes are not taint problems. The OWASP API Security Top 10 lists broken object level authorization as API1, broken object property level authorization as API3 and server-side request forgery as API7. SSRF is a taint flow (a URL from the request reaches an HTTP client) and can be proven. Whether Invoice.findById(req.params.id) is safe depends on a permission check that may live in a middleware, a base class or a configuration file the rule never read. The scanner can flag the shape. It cannot prove the gap, and it says so.
The same idea applies within one language. Vulkro's Apex confidence is High when the source reaches the sink in the same expression, Medium when it is an instance field set elsewhere, and Low when it is a static field set at class load. Distance between source and sink lowers certainty, and the confidence says so instead of rounding up.
Turning a proof into a test
A traced flow is a claim a machine can check, so it can also be turned into a test a machine can run. vulkro prove emits an executable harness for each eligible finding: a fast-check property test for JavaScript and TypeScript, a Hypothesis test for Python, an @isTest class for Apex. The eligible classes are the ones where the harness can drive the source and observe the sink: SSRF, prompt injection, broken authorization, and SQL, SOQL, NoSQL and command injection. Documentation: vulkro prove.
vulkro prove . --format emit --out proofsThe exit code is 1 when at least one harness was emitted, which makes it usable in a pipeline that wants to fail until the proof no longer holds. vulkro explain <finding-id> prints the full explainer for the rule behind a finding, including the sanitizer for that sink.
An empty proof is not a clean bill
The last point is the one most often misread. The MCP server that AI coding agents use has a prove tool that returns the hop-by-hop chain for a finding. Its documentation says, in those words, that a result with an empty proof chain means no proven data-flow was found, never that the code is safe.
The sentence is there because the opposite reading is tempting. An agent, or a person, asks for proof, gets none, and closes the finding as a false positive. But "no proven flow" has three causes: the code is safe, the flow passes through something the scanner cannot see, or the class is not decidable from source. Only the first means safe, and the scanner cannot tell you which applies. It can say it did not prove the path and leave the finding at the severity that matches what is known.
Taint analysis is a strong tool for the classes at the top of the CWE list. The measured result for Vulkro 0.36.0 on unseen code is 181 of 229 planted vulnerabilities found and 39 of 152 safe controls flagged. The 48 misses and the 39 false alarms are the honest boundary of the method. Knowing where the boundary is matters more than pretending it is not there.
What to do this week
- Run
vulkro dataflow . --reaching-sink --format mermaidon one service and read the flows that reach a sink without a sanitizer. - Run
vulkro scan --disposition admittedand fix the proven findings first. Then read thedemotedrows and decide each one by hand. - Emit a harness with
vulkro prove . --format emit --out proofsfor one SQL injection finding and run it, so the team has seen a proof execute. - Write down what "sanitized" means per sink type in your codebase. Most false-positive arguments are about this.
The sample workspace shows traced flows on a fictional application, no sign-up needed. The prove docs cover the harness formats. Vulkro Core comes with a 14-day trial; start it here.