await fetch(userUrl);
A linter flags this line in about a millisecond. Here is what it did not do in that millisecond: it did not trace where userUrl came from, and it did not prove that a request-forgery path exists. It flagged the line because a user-ish name reached a network sink. That is the entire analysis.
Whether that is scandalous or sensible depends on a distinction most tool marketing blurs: impact-if-real and confidence-it's-real are different axes. The impact axis is brutal — server-side request forgery is CWE-918, and a serious vector scores CVSS 9.1, a tenth of a point past the line where the scale turns Critical (push every judgment to its worst and it tops out at a perfect 10.0). The confidence axis, for a single name-match flag, is modest: a variable name is circumstantial evidence. Print one axis in the other's vocabulary and you get 9.1 and LOW four characters apart — and that drift is not hypothetical: a rule-metadata audit caught exactly that mislabel in the wild. CVSS scores what a weakness could do, never how sure a detector is that it found one — so this page explains the two detection families that sit on opposite ends of the confidence axis.
Taint analysis answers one question with evidence: can a value from an untrusted source reach a dangerous sink without passing through a sanitizer?
The model has three parts. Sources produce attacker-influenced data: req.query, req.body, CLI arguments, file contents. Sinks are operations where untrusted data causes damage: fetch, child_process.exec, a SQL driver call. Sanitizers are the functions that clear the taint: parameterization, allowlist validation, encoding. The engine builds a data-flow graph of the whole program, propagates "tainted" labels from sources, and reports only when a complete source-to-sink path exists with no sanitizer on it. The lineage runs straight back to Dorothy Denning's 1976 lattice model of secure information flow; Livshits and Lam made it practical for real web vulnerabilities in 2005.
What makes this expensive is that real paths are indirect. A two-hop flow:
const target = req.query.url; // source
const opts = { url: target }; // taint flows into a property
await fetch(opts.url); // sink — three statements, one path
Reporting this requires following the value through an assignment, a property write, and a property read — and production paths cross function and file boundaries, which demands a call graph. Aliasing is worse:
const a = { url: DEFAULT };
const b = a; // two names, one object
b.url = req.query.url; // tainting b.url taints a.url
await fetch(a.url); // sink reached through the alias
Sound alias analysis is where most of the computational cost lives, and in JavaScript — eval, dynamic property access, framework glue — the graph is never complete. The payoff for all that machinery: when a taint engine reports, it hands you a path you can read as a demonstration.
A heuristic rule answers a much cheaper question: does this code's shape resemble a known-vulnerable pattern? A non-literal argument reaching fetch. A template literal inside exec(). A computed property write. No data-flow proof — a resemblance, computed from a single file's AST in milliseconds.
The failure profile is the mirror image of taint analysis: strong catch-rate on patterns it matches, false positives wherever the pattern's premise fails. Picture the classic offender — a rule that flags every computed property access, obj[key], as possible object injection. It fires because it cannot know whether key is attacker-controlled, so in a codebase where bracket access is mostly over trusted keys it drowns you: a rule like that can sink to 25% precision — three false alarms for every real finding — all on its own. It is not broken. It is a heuristic priced honestly, and the price is only visible once you count TP and FP properly.
The two families fail in opposite directions, and the direction is the useful part.
Taint engines make FN-shaped errors. Wherever the model can't follow — dynamic dispatch, reflection, a framework's dependency injection — the path silently disappears, and with it the finding. What a taint engine says is trustworthy; what it doesn't say is unknown.
Heuristic rules make FP-shaped errors. They over-flag whenever a name or shape misleads, and they miss the flows that never look dangerous at the sink line. Look back at the two-hop example: a single-file heuristic staring at fetch(opts.url) sees a property read with nothing user-ish in the name. The flow is invisible without the graph.
Now put the two axes from the hook back together. A taint finding carries high confidence — a demonstrated path. A heuristic finding carries an invitation to look. The impact axis is untouched by any of this: SSRF is CWE-918 at CVSS 9.1 whether CodeQL proved it or a name-match suggested it. Severity belongs to the weakness; confidence belongs to the detection method. Print one axis in the other's vocabulary and you get 9.1 and LOW four characters apart.
Editor-grade security linters are heuristic by design, not by a limitation someone forgot to fix. The design constraints are a single file's AST and a millisecond budget, because the rule runs on every keystroke and every commit. Interprocedural taint tracking under those constraints is not difficult — it is excluded by the specification. Some rules track values within one file's scopes, which is useful, but it is not taint analysis, and the most common misreading in this vocabulary — "ESLint security rules do taint tracking" — is simply false. They pattern-match. Knowing this changes how you read a flag: it is a screen, not a verdict.
The right mental model comes from value investing: a stock screener filtering on price-to-earnings does not tell you a company is undervalued — it tells you which filings are worth reading. Heuristic lint is the screener: cheap, immediate, over-inclusive on purpose, run on everything. Taint analysis is the due diligence: expensive, slower, run where the stakes justify it. In a real pipeline that means heuristic rules in the editor and pre-commit, and an interprocedural engine — CodeQL, Semgrep in taint mode — on the pull-request gate or nightly. Where each tool class sits in the broader taxonomy is its own article; the short version is that they answer different questions, so replacing one with the other means a question goes unanswered.
One honest disclosure follows. A single-file benchmark corpus can only encode heuristically-detectable patterns — single-file shapes a lint rule could conceivably match — because someone had to decide what counts as vulnerable, and a corpus of one-file fixtures can only hold one-file bugs. So a linter that scores 100% on such a corpus has told you one specific thing: complete coverage of the pattern class the corpus was designed for. It has not told you a taint engine is unnecessary, because the multi-file flows a taint engine exists to find are exactly what a single-file corpus cannot encode.
A perfect score on a corpus you built yourself is a tautology wearing a medal — you wrote the test, you wrote the tool, and the number just confirms they agree. The honest reading is the scope statement hiding underneath it: complete coverage of the one pattern class you chose to encode, and silence about everything you left out.
So the one question to ask any static analysis tool — before the demo, before the leaderboard — is: what evidence does a finding carry? A path means taint. A resemblance means heuristic. Both are worth paying for; neither substitutes for the other; and a tool that names which one it is doing has already told you how to triage its output.
| Taint analysis | Heuristic detection | |
|---|---|---|
| Core question | Does untrusted data reach a sink unsanitized? | Does this code shape resemble a vulnerable pattern? |
| Evidence per finding | A demonstrated source→sink path | A pattern match |
| Scope | Whole program, cross-file | Single file (one AST) |
| Cost | Minutes; call graph + alias analysis | Milliseconds per file |
| Characteristic error | False negatives (model gaps: eval, dynamic dispatch, framework glue) | False positives (premise fails: names and shapes mislead) |
| Where it belongs | PR gate, nightly runs | Editor, pre-commit, CI |
| Examples | CodeQL, Semgrep (taint mode) | ESLint security plugins, single-file lint rules |
- Denning, D. E. (1976). A lattice model of secure information flow. Communications of the ACM, 19(5). The founding citation — every source/sink/label system in use today descends from this model.
- Livshits, V. B., & Lam, M. S. (2005). Finding security vulnerabilities in Java applications with static analysis. USENIX Security Symposium. The paper that made taint-based static analysis practical against real web-application vulnerability classes.
- Chess, B., & West, J. (2007). Secure Programming with Static Analysis. Addison-Wesley. The standard industrial treatment of both detection families, including why commercial SAST engines mix them.
- OWASP Community: Static Code Analysis. Practitioner-level survey of the technique, with an unusually honest limitations list.
This is a vocabulary page — bookmark it for the next time a vendor deck says "taint-aware linting," and follow me on Dev.to for the rest of the foundations series.
Foundations series: ← OWASP Top 10, Explained · hub · Static Analysis vs. SAST vs. Linting →
Part of the Interlace ESLint ecosystem. Source on GitHub · npm: @interlace · Follow: Dev.to/ofri-peretz · ofriperetz.dev