Taint analysis
New in 2.0Release candidate
taint() tests m sources against n sinks in one traversal. For each pair it reports one of three outcomes: a flow was found, no flow was found, or the search cannot finish.
The call
Section titled “The call”from cldk import CLDKfrom cldk.analysis import AnalysisLevel
analysis = CLDK.python( project_path="odoo", analysis_level=AnalysisLevel.system_dependency_graph,)
result = analysis.taint( sources=[("invoice_id", "PaymentPortal.invoice_transaction")], sinks=[("query", "AccountMove._execute")], sanitizers=["PaymentPortal._sanitize_id"],)
for path in result.paths: # the witnesses print(" -> ".join(hop.to.name for hop in path.hops))
for src, sink in result.exhausted: # searched, nothing found print(src, "does not reach", sink)
for d in result.unresolved: # neither: read this first print(d.code, d.message)| Argument | Type | Meaning |
|---|---|---|
sources | Sequence[Tuple[str, str]] | Where taint enters, each (name, within) |
sinks | Sequence[Tuple[str, str]] | Where it must not reach, addressed the same way |
sanitizers | Sequence[Tuple[str, str] | str] | The cuts, empty by default. See below |
depth | int | None | Most hops a path can take. None by default |
max_paths | int | Most witnesses for each pair, not for the call. 10 by default |
max_paths counts per pair on purpose. With forty sources and one sink, a flat cap lets one prolific source starve the other thirty-nine.
You supply the sources and sinks
Section titled “You supply the sources and sinks”CLDK ships no framework catalog and derives no default set. A per-language list of taint sources is soon out of date. You supply the source and sink names for your application.
Two kinds of sanitizer
Section titled “Two kinds of sanitizer”The word sanitizer covers two different things. The shape of the argument tells them apart.
| Shape | Cuts | Use for |
|---|---|---|
"PaymentPortal._sanitize_id" | A callable on the path | A transforming sanitizer |
("checked_id", "PaymentPortal.invoice_transaction") | A variable inside that callable | A validating guard |
A bare string names the wrapper in this application that calls something like html.escape. CLDK resolves it with resolve_callable.
A (name, within) pair is the only shape that cuts a validating guard, because a guard never sits on the data path at all. It tests a value and raises, so a cut on the callable does not cut the flow.
The search applies both cuts. What comes back is the shortest unsanitized route, not a filtered list of sanitized ones.
What the result says
Section titled “What the result says”taint() returns a TaintResult, which extends FlowPaths.
| Field | Type | Meaning |
|---|---|---|
paths | list[FlowPath] | The witnesses, shortest first |
complete | bool | Whether the whole batch answered cleanly |
exhausted | list[tuple[str, str]] | Pairs searched to exhaustion with nothing found |
unresolved | list[Diagnostic] | The ledger of what stopped a pair short |
roots | list[SliceNode] | What each selector matched |
resolved | str | The human-readable form of roots |
exhausted is the reason to call this
Section titled “exhausted is the reason to call this”paths_between returns an empty paths list both when no flow exists and when the flow left the resolved graph. exhausted is the refutation that an empty list cannot give you.
CLDK lists a pair in exhausted only when all three of these conditions hold:
- The call passed
depth=None. - The search found no path for that pair.
- No diagnostic in
unresolvedimplicates that pair.
An explicit depth always empties exhausted. A bound turns a real long flow into an empty result, and a wrong refutation closes a live alert.
complete is the batch flag, not the pair flag
Section titled “complete is the batch flag, not the pair flag”complete is True only when no cap truncated paths and unresolved is empty. One skipped or blocked pair makes it False, however cleanly the other pairs answered.
While complete is False, no absence claim stands on any pair in the result. The ledger voids the whole batch, not just the pair it names.
A False here does not mean “raise max_paths and ask again”. If max_paths did not truncate paths, a bigger cap returns the same flag. Read unresolved to find out why.
The ledger
Section titled “The ledger”Two diagnostic codes reach unresolved:
| Code | Meaning |
|---|---|
unresolved_dispatch | The walk met a frontier that it cannot follow |
degenerate_pair | A requested pair whose source and sink resolved to the same position |
CLDK skips a degenerate pair and does not search it. Both codes name the affected pair in the message prose.
Errors
Section titled “Errors”| Exception | Cause |
|---|---|
AmbiguousName | A name, or the within of a sanitizer, matched more than one thing |
SelectorNotInGraph | A name matched nothing, or a sanitizer shape disagrees with what it resolves to |
CLDK raises both exceptions, because a selector that silently matches nothing produces an empty result that reads like a refutation.
How certain a witness is
Section titled “How certain a witness is”Each hop carries prov, and the weakest hop caps the whole path. The order runs ssa > reaching-defs > points-to. See How certain a path is.
for path in result.paths: print(path.weakest.via, path.weakest.prov)Language support
Section titled “Language support”taint() is available on the Java, Python, and TypeScript analysis objects, with the same signature on each.