Skip to content

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.

from cldk import CLDK
from 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)
ArgumentTypeMeaning
sourcesSequence[Tuple[str, str]]Where taint enters, each (name, within)
sinksSequence[Tuple[str, str]]Where it must not reach, addressed the same way
sanitizersSequence[Tuple[str, str] | str]The cuts, empty by default. See below
depthint | NoneMost hops a path can take. None by default
max_pathsintMost 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.

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.

The word sanitizer covers two different things. The shape of the argument tells them apart.

ShapeCutsUse for
"PaymentPortal._sanitize_id"A callable on the pathA transforming sanitizer
("checked_id", "PaymentPortal.invoice_transaction")A variable inside that callableA 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.

taint() returns a TaintResult, which extends FlowPaths.

FieldTypeMeaning
pathslist[FlowPath]The witnesses, shortest first
completeboolWhether the whole batch answered cleanly
exhaustedlist[tuple[str, str]]Pairs searched to exhaustion with nothing found
unresolvedlist[Diagnostic]The ledger of what stopped a pair short
rootslist[SliceNode]What each selector matched
resolvedstrThe human-readable form of roots

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:

  1. The call passed depth=None.
  2. The search found no path for that pair.
  3. No diagnostic in unresolved implicates 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.

Two diagnostic codes reach unresolved:

CodeMeaning
unresolved_dispatchThe walk met a frontier that it cannot follow
degenerate_pairA 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.

ExceptionCause
AmbiguousNameA name, or the within of a sanitizer, matched more than one thing
SelectorNotInGraphA 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.

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)

taint() is available on the Java, Python, and TypeScript analysis objects, with the same signature on each.