Taint propagation internals
The exact CPython mechanism that determines which operations preserve a taint label, and every propagates/launders case.
Taint propagation internals
The precise mechanics behind
Taint propagation. Read this when you need
to know exactly what one specific operation does, or when debugging why a
label was lost. For the practical mental model, start with the concepts
page; for the re-taint patterns that cope with a laundering point in your
own code, see
Writing a policy: know what launders a label.
Why propagation is partial: the CPython mechanism
A str subclass can intercept an operation only when the Tainted instance
is the receiver, or the right operand of a binary operator (CPython
runs the subclass's reflected dunder, for example __radd__, before the
plain str's forward dunder). Every other path assembles the result as an
exact str in C, with no Python-level hook, and the label is lost. This
single fact determines the entire contract below.
Propagates (reliable)
- Binary operators where a
Taintedis the left or right operand:+(__add__/__radd__),%(__mod__/__rmod__),*(__mul__/__rmul__). - Slicing and indexing on a
Taintedreceiver (__getitem__). strmethods called on aTaintedreceiver that return a new string: the case methods (upper,lower,casefold,capitalize,title,swapcase),strip/lstrip/rstrip,removeprefix/removesuffix, the padding methods (center,ljust,rjust,zfill,expandtabs),replace, and the part-returning methodssplit/rsplit/splitlines/partition/rpartition(every returned part is individually re-wrapped, carrying the same label).encode()on aTainted(returns aTaintedByteswith the same label) anddecode()on aTaintedBytes(returns aTaintedwith the same label): the str/bytes I/O boundary tool output typically crosses.template.format(*args, **kwargs),template.format_map(mapping), andtemplate % argwhere the template (the receiver or left operand) isTainted. AnyTaintedpassed as a substitution argument is also inspected and its label merged in, so a tainted argument's provenance is captured alongside the template's. When the right operand of%is a mapping, each value is inspected the same way; keys are not, since%-formatting only ever substitutes values.- The bare single-field f-string
f"{x}"preserves taint, because__format__is overridden and returnsselfwhen the format spec is empty. A format spec defeats it:f"{x:>20}"falls through tostr.__format__and produces a plainstr. copy.copy/copy.deepcopyon aTainted,TaintedBytes, orLabeledValuepreserve the label.Tainted/TaintedBytesreturnselffrom__copy__/__deepcopy__, since value and label are both immutable.LabeledValue.__copy__shares.value, and__deepcopy__deep-copies.valuewhile sharing the frozen label.
TaintedBytes covers the same surface minus the methods bytes has no
analog for (format, format_map, casefold).
Laundering points (re-taint required)
- f-strings with any literal text, for example
f"Summary: {x}". These compile to aBUILD_STRINGopcode that produces an exactstrregardless of its parts, and there is no hook. Treat f-strings as a laundering point. "{}".format(x)andstr.format_map(...)where the template is a plainstr.__format__runs on the argument, butstr.formatassembles an exactstr." ".join(chunks)where the separator (the receiver) is a plainstr. The join builds an exactstr; joining on aTaintedseparator propagates instead, merging every tainted item's label.plain_template % (tainted, ...): a plain template with a tuple right operand. The operation isstr.__mod__on the plain template, and the right operand is atuplerather than aTainted, soTainted.__rmod__never fires. The single-argumentplain % taintedand theTainted-template forms above do propagate.- Any path routing a tainted value through a non-overridden operation:
json.loads(tainted)then dict reconstruction,int(tainted)then arithmetic, and so on.
Depth bounds: three different walks
Three traversals have different depth semantics, which matters when debugging a label that seems to vanish inside a nested structure:
| Walk | Used by | Depth |
|---|---|---|
Labeling (map_leaves) | taint() | RECURSION_DEPTH |
Label collection (collect_labels) | check()/guard | RECURSION_DEPTH |
Carrier stripping (unwrap) | the CEL context builder | unbounded |
RECURSION_DEPTH defaults to 4 and is overridable by
INTERBOLT_RECURSION_DEPTH within [1, 10]. Labeling and collection share
it deliberately, so a leaf deep enough to escape labeling is equally out of
reach at the sink. At the cutoff, a sub-container passes through completely
unchanged rather than being rebuilt or wrapped, so it stays subscriptable
and usable exactly as the original, and a label below the cutoff is simply
not found, with the call evaluated as if that leaf were untainted.
unwrap is unbounded because it runs on values already in hand at the sink
and only has to convert them to a form CEL can read. It also does less than
its name suggests: only LabeledValue is actually unwrapped, to its
.value. Tainted/TaintedBytes pass through untouched, because they
already are str/bytes and CEL conversion reads their value directly.
So "carriers stripped" in the
policies sense means the
CEL context sees plain string content, not that the objects were replaced.
Container recursion: edge cases
- A namedtuple is handled correctly: it is a
tuplesubclass whose constructor takes positional fields, so reconstruction unpacks (type(v) (*items)) rather than passing a single iterable. - If reconstructing an exotic container subclass fails, the value passes through unchanged and untraversed rather than raising, with a DEBUG log naming the type, since the containment layer must never be the thing that crashes a guarded call.
- Only
Mappingand the builtin container types are traversed.Tainted,TaintedBytes, andLabeledValueare always leaves and are never introspected, and arbitrary objects are never introspected either.
Merge rule: the single-parent fast path
When two tainted values combine, the merged label's lineage and
ingested_by are each the de-duplicated union of the contributors', and its
endorsements is the intersection of the contributors' (see
Merge rule).
Single-parent derivations are a fast path rather than a merge. A slice,
a case change, one part of a split, or any other operation with exactly
one contributing label reuses that label object outright, including its
value_id and ingested_by, since there is nothing to merge and no new
agent is involved. A fresh value_id is minted only at ingress, at a
genuine merge of two or more labels, or at an
endorse() hop. Those same three points
are the only places ingested_by can change. _merge_labels (the function
backing this fast path) never adds the current agent, no matter how many
labels it merges. Only taint(..., derived_from=...) adds the current
agent, on top of what the merge already computes.
That fast path has a visible consequence at the sink. collect_labels
de-duplicates by value_id, so several arguments derived from one original
value contribute a single entry to the CEL taint list rather than one
each. Passing every part of summary.split("\n") into a tool call yields
one label, not N, and taint.exists(t, ...) sees exactly one element.