Interbolt
Reference

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 Tainted is the left or right operand: + (__add__/__radd__), % (__mod__/__rmod__), * (__mul__/__rmul__).
  • Slicing and indexing on a Tainted receiver (__getitem__).
  • str methods called on a Tainted receiver 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 methods split/rsplit/splitlines/ partition/rpartition (every returned part is individually re-wrapped, carrying the same label).
  • encode() on a Tainted (returns a TaintedBytes with the same label) and decode() on a TaintedBytes (returns a Tainted with the same label): the str/bytes I/O boundary tool output typically crosses.
  • template.format(*args, **kwargs), template.format_map(mapping), and template % arg where the template (the receiver or left operand) is Tainted. Any Tainted passed 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 returns self when the format spec is empty. A format spec defeats it: f"{x:>20}" falls through to str.__format__ and produces a plain str.
  • copy.copy/copy.deepcopy on a Tainted, TaintedBytes, or LabeledValue preserve the label. Tainted/TaintedBytes return self from __copy__/__deepcopy__, since value and label are both immutable. LabeledValue.__copy__ shares .value, and __deepcopy__ deep-copies .value while 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 a BUILD_STRING opcode that produces an exact str regardless of its parts, and there is no hook. Treat f-strings as a laundering point.
  • "{}".format(x) and str.format_map(...) where the template is a plain str. __format__ runs on the argument, but str.format assembles an exact str.
  • " ".join(chunks) where the separator (the receiver) is a plain str. The join builds an exact str; joining on a Tainted separator propagates instead, merging every tainted item's label.
  • plain_template % (tainted, ...): a plain template with a tuple right operand. The operation is str.__mod__ on the plain template, and the right operand is a tuple rather than a Tainted, so Tainted.__rmod__ never fires. The single-argument plain % tainted and the Tainted-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:

WalkUsed byDepth
Labeling (map_leaves)taint()RECURSION_DEPTH
Label collection (collect_labels)check()/guardRECURSION_DEPTH
Carrier stripping (unwrap)the CEL context builderunbounded

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 tuple subclass 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 Mapping and the builtin container types are traversed. Tainted, TaintedBytes, and LabeledValue are 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.

On this page