From 80e877913df4a277c05382ac0b8398a884d86d2e Mon Sep 17 00:00:00 2001 From: John Morrissey Date: Sun, 31 May 2026 14:31:06 +1000 Subject: [PATCH 1/4] fix: L3 callee-combination joins use least_trusted, not taint_join (MIXED_RAW FP) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the taint_join->least_trusted migration started in wardline-4d94577013 (L2 expression combiners) and wardline-4d9f840c24 (L2 control-flow merges). The four remaining taint_join call sites — the CALLEE-COMBINATION joins in the L3 call-graph propagation engine — are switched to the rank-meet least_trusted: - minimum_scope.py _refine callee aggregation - propagation.py _compute_scc_round per-round refinement - propagation.py Phase 1 external-influence aggregation - propagation.py Phase 1b multi-source seed-join Evaluated each site independently (the task was to DECIDE, not assume): all four are the SAME shape — a function-summary AGGREGATION of the influence of a *set* of callees into one taint, never a single value built by merging two provenances. taint_join's MIXED_RAW models a single-value provenance clash, which has no referent at the function-summary level; the precise single-value combination (e.g. `return a() + b()`) is already resolved at L2, which itself uses least_trusted. So a non-anchored function calling two clean-but-different- family callees (e.g. an ASSURED validator and an INTEGRAL helper) spuriously became MIXED_RAW (rank 7, in the firing RAW_ZONE); propagated up it fired PY-WL-101 on clean data — the same FP shape as the L2 merges. Fail-safe: least_trusted <= taint_join in rank always, so this only ever LOWERS a combined rank — removes over-flags, never creates under-flags. RAW_ZONE collapses MIXED_RAW with the other raw states, so a genuinely-raw callee keeps its rank and still fires; the two operators can only diverge on a firing decision when least_trusted yields a clean state and taint_join yields MIXED_RAW (the clean-but-different-family case), which is always the false positive. taint_join itself stays in core/taints.py (the operator is unchanged); the two operators remain distinct. Stale docstrings rewritten: propagation.py's module-header "combination uses taint_join / TRUST_RANK never for combination" (now false — least_trusted IS a TRUST_RANK-based meet and is the combination operator) and minimum_scope.py's "MIXED_RAW by design — the conservative direction" (the exact rationale the L2 commits refuted). Per-site comments pin the least_trusted choice so the question isn't reopened. Tests: two buggy-MIXED_RAW assertions corrected to the precise least_trusted value (test_propagation cyclic + two-anchored-callees; test_analyzer transitive propagation now UNKNOWN_RAW, floored, not MIXED_RAW); clean-direction + soundness-companion tests added for the minimum_scope and propagation sites (two clean-different-family callees stay clean; one raw callee still propagates). Verified: 936 pytest green, soundness battery 30/30 (no FN), self-host 0 PY-WL defects, ruff + mypy --strict clean. Closes wardline-17b9ce2c70 Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 16 +++- src/wardline/scanner/taint/minimum_scope.py | 28 +++++-- src/wardline/scanner/taint/propagation.py | 46 +++++++--- .../unit/scanner/taint/test_minimum_scope.py | 33 ++++++++ tests/unit/scanner/taint/test_propagation.py | 83 ++++++++++++++++--- tests/unit/scanner/test_analyzer.py | 13 ++- 6 files changed, 185 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c2276fbe..f4160a02 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 exactly one branch, so they now combine via the rank-meet weakest-link (`least_trusted`), matching the expression combiners; a raw branch still propagates and fires. This completes the `taint_join` → `least_trusted` - migration for the either-or paths (the callee-combination joins are unchanged). + migration for the L2 either-or paths. +- **L3 callee-combination over-tainting (false positives)** — the four + callee-combination joins in the call-graph propagation engine + (`minimum_scope.py`, plus `propagation.py`'s external-influence, Phase 1b + seed-join, and per-round SCC refinement) combined the taints of a function's + *set* of callees via the provenance-clash join. That is a function-summary + aggregation of callee influence, not a single value built by merging two + provenances, so a non-anchored function calling two clean-but-different-family + callees (e.g. an `ASSURED` validator and an `INTEGRAL` helper) spuriously + became `MIXED_RAW` (rank 7, in the firing raw zone) — an over-taint that, + propagated up, fired `PY-WL-101` on clean data. All four sites now aggregate + via the rank-meet weakest-link (`least_trusted`); a raw callee still + propagates at its precise rank and fires. Completes the `taint_join` → + `least_trusted` migration; the `taint_join` operator itself remains in + `core/taints.py`. ## [0.2.0] - 2026-05-31 diff --git a/src/wardline/scanner/taint/minimum_scope.py b/src/wardline/scanner/taint/minimum_scope.py index a8b76096..ed69325f 100644 --- a/src/wardline/scanner/taint/minimum_scope.py +++ b/src/wardline/scanner/taint/minimum_scope.py @@ -19,7 +19,7 @@ from functools import reduce from typing import NamedTuple -from wardline.core.taints import TRUST_RANK, TaintState, taint_join +from wardline.core.taints import TRUST_RANK, TaintState, least_trusted from wardline.scanner.ast_primitives import iter_calls_in_function_body, resolve_call_fqn from wardline.scanner.index import Entity @@ -88,12 +88,19 @@ def refine_minimum_scope_taints( A callee whose source is ``"provider"`` is *anchored*: its declared return taint is used directly (no recursion). Other callees are refined one hop - deeper. Callee taints are combined with ``taint_join`` (not ``least_trusted``): - a caller reaching callees of differing provenance becomes ``MIXED_RAW`` by - design — over-tainting on a provenance clash, the conservative direction. - The combined result is then floored so refinement never makes a function MORE - trusted than its own seed. Provenance is recorded only for functions whose - taint actually changed. + deeper. Callee taints are combined with the rank-meet ``least_trusted`` + (weakest-link), NOT ``taint_join``: this is a function-summary AGGREGATION of + the influence of a *set* of callees, not a single value built by merging two + provenances, so ``taint_join``'s provenance-clash ``MIXED_RAW`` is the wrong + label — two clean-but-different-family callees (e.g. an ``ASSURED`` validator + and an ``INTEGRAL`` literal helper) must not clash a clean caller to + ``MIXED_RAW`` (rank 7, in the firing RAW_ZONE) and manufacture a PY-WL-101 + false positive. ``least_trusted`` keeps any raw callee's rank (sound: a raw + callee still propagates and fires), without the spurious jump. Consistent + with the L2 expression/control-flow combiners (wardline-4d94577013, + wardline-4d9f840c24). The combined result is then floored so refinement never + makes a function MORE trusted than its own seed. Provenance is recorded only + for functions whose taint actually changed. """ def _seed(func: str) -> TaintState: @@ -140,7 +147,12 @@ def _refine( ) influenced.append((callee, callee_taint)) - combined = reduce(taint_join, (taint for _, taint in influenced)) + # Aggregate the influence of this function's callee SET into one summary + # taint via the rank-meet least_trusted (weakest-link), NOT taint_join: + # a clean caller of two clean-but-different-family callees must stay clean, + # not clash to MIXED_RAW (see this function's docstring). A raw callee + # still propagates at its precise rank. + combined = reduce(least_trusted, (taint for _, taint in influenced)) # Floor: refinement only ever demotes (toward less-trusted), never # promotes. This single clamp also covers the unresolved-call case — # after it, TRUST_RANK[combined] >= TRUST_RANK[seed] unconditionally, so diff --git a/src/wardline/scanner/taint/propagation.py b/src/wardline/scanner/taint/propagation.py index 34103aa9..a65459c6 100644 --- a/src/wardline/scanner/taint/propagation.py +++ b/src/wardline/scanner/taint/propagation.py @@ -7,10 +7,18 @@ Provides iterative Tarjan's SCC algorithm and the main propagation loop that refines L1 function-level taints by analysing what each function calls. -Callee taint combination uses taint_join() from the §6 join algebra. -TRUST_RANK is used only for ordering comparisons (floor clamps, -post-assertions, provenance tiebreaks) — never for taint combination. -The two operators are distinct and must never be collapsed. +Callee taint combination uses the rank-meet least_trusted() (weakest-link). +Every callee-combination site here AGGREGATES the influence of a *set* of +callees into one function-summary taint — it never models a single value built +by merging two provenances — so taint_join()'s provenance-clash MIXED_RAW is the +wrong label (two clean-but-different-family callees would clash a clean caller to +MIXED_RAW, rank 7 in the firing RAW_ZONE, a PY-WL-101 false positive). The +migration to least_trusted here mirrors the L2 expression/control-flow combiners +(wardline-4d94577013, wardline-4d9f840c24). taint_join() itself still lives in +core/taints.py; the two operators remain distinct and must never be collapsed at +the operator level — this module simply uses the aggregation-correct one. +TRUST_RANK additionally backs ordering comparisons (floor clamps, +post-assertions, provenance tiebreaks). """ from __future__ import annotations @@ -128,7 +136,7 @@ def _compute_scc_round( phase2_floor: dict[str, TaintState], ) -> tuple[dict[str, TaintState], dict[str, str | None]]: """Compute one synchronous SCC refinement round from a stable snapshot.""" - from wardline.core.taints import taint_join + from wardline.core.taints import least_trusted updates: dict[str, TaintState] = {} via_callee: dict[str, str | None] = {} @@ -155,7 +163,11 @@ def _compute_scc_round( best_rank = rank best_callee = callee - combined = reduce(taint_join, callee_taints) + # Aggregate this function's callee SET into one summary taint via the + # rank-meet least_trusted (weakest-link), NOT taint_join: clean callees of + # different families must not clash the caller to MIXED_RAW (a RAW_ZONE + # false positive); a raw callee still propagates at its precise rank. + combined = reduce(least_trusted, callee_taints) floor = phase2_floor[func] new_taint = floor if TRUST_RANK[floor] > TRUST_RANK[combined] else combined if ( @@ -212,7 +224,7 @@ def propagate_callgraph_taints( The Phase 3a resolver aggregates this dict into ``ResolverRunMetadata.convergence_iterations_{max,histogram}``. """ - from wardline.core.taints import taint_join + from wardline.core.taints import least_trusted scc_iteration_counts: dict[frozenset[str], int] = {} @@ -283,7 +295,12 @@ def propagate_callgraph_taints( # This is correct because non-anchored functions always have # body_taint == return_taint (enforced by _walk_and_assign in # function_level.py), so current[c] serves as both. - # Combine callee taints using §6 join algebra + # Aggregate the external-callee SET into one summary taint via the + # rank-meet least_trusted (weakest-link), NOT taint_join: this is a + # function-summary aggregation of a set of callees, not a single + # merged value, so clean-but-different-family externals must not + # clash f to MIXED_RAW (a RAW_ZONE false positive); a raw external + # still propagates at its precise rank. ext_taints: list[TaintState] = [] for c in ext_callees: if c in anchored: @@ -294,7 +311,7 @@ def propagate_callgraph_taints( ext_taints.append(c_return_taint) else: ext_taints.append(current[c]) - ext_combined = reduce(taint_join, ext_taints) + ext_combined = reduce(least_trusted, ext_taints) # TRUST_RANK: ordering comparison, not taint combination (see §6) ext_taint = ext_combined @@ -375,7 +392,16 @@ def propagate_callgraph_taints( if len(seed_taints) < 2: continue - seed_join = reduce(taint_join, seed_taints) + # Aggregate the multi-source seed SET into one summary taint via the + # rank-meet least_trusted (weakest-link), NOT taint_join: a node that + # draws on several SCC-local + external callees aggregates their + # influence — it does not build one value by merging provenances — so + # clean-but-different-family seeds must not clash f to MIXED_RAW (a + # RAW_ZONE false positive); a raw seed still propagates at its rank. + # least_trusted is order-independent (commutative, associative, + # idempotent), preserving the order-independence this seed pass exists + # to guarantee. + seed_join = reduce(least_trusted, seed_taints) if TRUST_RANK[seed_join] > TRUST_RANK[current[f]]: current[f] = seed_join refined.add(f) diff --git a/tests/unit/scanner/taint/test_minimum_scope.py b/tests/unit/scanner/taint/test_minimum_scope.py index 3cd9825b..da4da707 100644 --- a/tests/unit/scanner/taint/test_minimum_scope.py +++ b/tests/unit/scanner/taint/test_minimum_scope.py @@ -76,6 +76,39 @@ def test_two_hop_through_undecorated_intermediary() -> None: assert refined["m.handler"] == T.EXTERNAL_RAW +def test_clean_different_family_callees_stay_clean() -> None: + # Clean-direction (wardline-17b9ce2c70): handler (INTEGRAL) calls two + # clean-but-DIFFERENT-family provider callees — validate (ASSURED) and lit + # (INTEGRAL). The callee-set aggregation is the rank-meet least_trusted + # (weakest-link), NOT taint_join: least_trusted(ASSURED, INTEGRAL) = ASSURED + # (clean), so the handler stays clean. taint_join would clash them to + # MIXED_RAW (rank 7, in the firing RAW_ZONE) — a PY-WL-101 false positive. + refined, _prov = refine_minimum_scope_taints( + target_functions=["m.handler"], + edges={"m.handler": frozenset({"m.validate", "m.lit"})}, + seed_taints={"m.handler": T.INTEGRAL, "m.validate": T.ASSURED, "m.lit": T.INTEGRAL}, + seed_sources={"m.handler": "default", "m.validate": "provider", "m.lit": "provider"}, + return_taints={"m.validate": T.ASSURED, "m.lit": T.INTEGRAL}, + unresolved_counts={}, + ) + assert refined["m.handler"] == T.ASSURED # clean, NOT MIXED_RAW + + +def test_one_raw_callee_among_clean_still_propagates() -> None: + # Soundness companion: swap the INTEGRAL helper for a raw EXTERNAL_RAW + # provider. least_trusted keeps the raw rank, so the handler is still raw + # (would still fire) — the migration introduces no false negative. + refined, _prov = refine_minimum_scope_taints( + target_functions=["m.handler"], + edges={"m.handler": frozenset({"m.validate", "m.raw"})}, + seed_taints={"m.handler": T.INTEGRAL, "m.validate": T.ASSURED, "m.raw": T.EXTERNAL_RAW}, + seed_sources={"m.handler": "default", "m.validate": "provider", "m.raw": "provider"}, + return_taints={"m.validate": T.ASSURED, "m.raw": T.EXTERNAL_RAW}, + unresolved_counts={}, + ) + assert refined["m.handler"] == T.EXTERNAL_RAW # raw still propagates + + def test_three_hop_is_bounded_out() -> None: # handler → hop1 → hop2 → raw : one intermediary max, so handler stays ASSURED refined, prov = refine_minimum_scope_taints( diff --git a/tests/unit/scanner/taint/test_propagation.py b/tests/unit/scanner/taint/test_propagation.py index 2110221b..aa932e56 100644 --- a/tests/unit/scanner/taint/test_propagation.py +++ b/tests/unit/scanner/taint/test_propagation.py @@ -72,32 +72,93 @@ def test_module_default_never_upgrades_toward_trust() -> None: assert refined["A"] == T.UNKNOWN_RAW -def test_discriminating_join_two_anchored_callees_yields_mixed() -> None: - # A calls a GUARDED and an EXTERNAL_RAW anchored callee. Combining with - # taint_join yields MIXED_RAW — a result least_trusted (EXTERNAL_RAW) could - # NEVER produce. This guards against collapsing the two operators. +def test_two_anchored_callees_aggregate_via_least_trusted() -> None: + # A calls a GUARDED and an EXTERNAL_RAW anchored callee. The callee SET is + # aggregated via the rank-meet least_trusted (weakest-link), NOT taint_join: + # least_trusted(GUARDED, EXTERNAL_RAW) = EXTERNAL_RAW (the precise raw rank), + # NOT the spurious MIXED_RAW (rank 7) taint_join would manufacture. A is + # fallback at UNKNOWN_RAW, so its floor keeps it at UNKNOWN_RAW regardless; + # the point is the aggregation never jumps to MIXED_RAW. edges = {"A": {"B", "C"}, "B": set(), "C": set()} tm = {"A": T.UNKNOWN_RAW, "B": T.GUARDED, "C": T.EXTERNAL_RAW} src = {"A": "fallback", "B": "anchored", "C": "anchored"} refined, _prov, _diags, _it = _run(edges, tm, src) - assert refined["A"] == T.MIXED_RAW - assert refined["A"] != T.EXTERNAL_RAW + assert refined["A"] != T.MIXED_RAW + + +def test_clean_different_family_callees_stay_clean() -> None: + # Clean-direction: A is a non-anchored module_default calling two + # clean-but-DIFFERENT-family anchored callees — an ASSURED validator and an + # INTEGRAL literal helper. A multi-callee function exercises every L3 + # combination site (Phase 1 external influence, Phase 1b seed-join, and the + # Phase 2 _compute_scc_round) — all migrated to least_trusted. Both callees + # are clean (not in RAW_ZONE), so A must stay clean: least_trusted(ASSURED, + # INTEGRAL) = ASSURED — taint_join would clash to MIXED_RAW (rank 7, in + # RAW_ZONE), a PY-WL-101 false positive. This is the FP the migration removes. + edges = {"A": {"B", "C"}, "B": set(), "C": set()} + tm = {"A": T.INTEGRAL, "B": T.ASSURED, "C": T.INTEGRAL} + src = {"A": "module_default", "B": "anchored", "C": "anchored"} + refined, _prov, _diags, _it = _run(edges, tm, src) + assert refined["A"] == T.ASSURED # clean, NOT MIXED_RAW + + +def test_one_raw_callee_among_clean_still_propagates() -> None: + # Soundness companion to the clean-direction test: replace the INTEGRAL + # helper with a raw EXTERNAL_RAW callee. least_trusted keeps the raw rank, so + # A is still raw (would still fire) — no false negative introduced. + edges = {"A": {"B", "C"}, "B": set(), "C": set()} + tm = {"A": T.INTEGRAL, "B": T.ASSURED, "C": T.EXTERNAL_RAW} + src = {"A": "module_default", "B": "anchored", "C": "anchored"} + refined, _prov, _diags, _it = _run(edges, tm, src) + assert refined["A"] == T.EXTERNAL_RAW # raw still propagates def test_cyclic_scc_converges() -> None: - # A <-> B, B also calls raw C. The Phase-1b seed-join clashes the INTEGRAL - # sibling with the raw external via taint_join -> MIXED_RAW. Converges (no - # convergence-bound diagnostic). + # A <-> B, B also calls raw C. The Phase-1b seed-join (site 4) aggregates the + # INTEGRAL sibling with the raw external via least_trusted -> + # least_trusted(INTEGRAL, UNKNOWN_RAW) = UNKNOWN_RAW (the precise raw rank), + # NOT the spurious MIXED_RAW taint_join would produce. Raw still propagates + # into the cycle. Converges (no convergence-bound diagnostic). edges = {"A": {"B"}, "B": {"A", "C"}, "C": set()} tm = {"A": T.INTEGRAL, "B": T.INTEGRAL, "C": T.UNKNOWN_RAW} src = {"A": "fallback", "B": "fallback", "C": "fallback"} refined, _prov, diags, _it = _run(edges, tm, src) - assert refined["A"] == T.MIXED_RAW - assert refined["B"] == T.MIXED_RAW + assert refined["A"] == T.UNKNOWN_RAW + assert refined["B"] == T.UNKNOWN_RAW assert refined["C"] == T.UNKNOWN_RAW assert not any(code == DIAG_CONVERGENCE_BOUND for code, _ in diags) +def test_cyclic_seed_join_clean_different_family_stays_clean() -> None: + # Clean-direction through a true cycle (drives the Phase 1b seed-join's + # multi-source SCC path): A <-> B cycle, B also calls a clean ASSURED + # validator C (different family from the INTEGRAL cycle seeds). The + # combination sites aggregate two clean-but-different families; least_trusted + # keeps them clean (least_trusted(INTEGRAL, ASSURED) = ASSURED), NOT the + # MIXED_RAW (RAW_ZONE) clash taint_join would manufacture inside the cycle. + edges = {"A": {"B"}, "B": {"A", "C"}, "C": set()} + tm = {"A": T.INTEGRAL, "B": T.INTEGRAL, "C": T.ASSURED} + src = {"A": "module_default", "B": "module_default", "C": "anchored"} + refined, _prov, diags, _it = _run(edges, tm, src) + assert refined["A"] == T.ASSURED # clean, NOT MIXED_RAW + assert refined["B"] == T.ASSURED + assert not any(code == DIAG_CONVERGENCE_BOUND for code, _ in diags) + + +def test_fallback_caller_clean_callees_floor_holds_not_mixed() -> None: + # A fallback caller can't prove trust, so its UNKNOWN_RAW floor holds — but + # the combination of its two clean-different-family anchored callees must + # still aggregate via least_trusted (ASSURED), never spike to MIXED_RAW + # before the floor clamp. Distinguishes "floored to UNKNOWN_RAW (rank 6)" + # from "clashed to MIXED_RAW (rank 7)": both are raw, but only the latter is + # the spurious provenance-clash label. + edges = {"A": {"B", "C"}, "B": set(), "C": set()} + tm = {"A": T.UNKNOWN_RAW, "B": T.ASSURED, "C": T.INTEGRAL} + src = {"A": "fallback", "B": "anchored", "C": "anchored"} + refined, _prov, _diags, _it = _run(edges, tm, src) + assert refined["A"] == T.UNKNOWN_RAW # floor, NOT MIXED_RAW + + def test_long_chain_converges_without_bound_diagnostic() -> None: n = 20 edges = {f"f{i}": {f"f{i+1}"} for i in range(n)} diff --git a/tests/unit/scanner/test_analyzer.py b/tests/unit/scanner/test_analyzer.py index 964048e4..cfbdb851 100644 --- a/tests/unit/scanner/test_analyzer.py +++ b/tests/unit/scanner/test_analyzer.py @@ -141,9 +141,14 @@ def test_analyzer_default_provider_seeds_from_decorators(tmp_path) -> None: def test_analyzer_seeded_taints_drive_transitive_propagation(tmp_path) -> None: - # The real transitive demonstration: an undecorated function that joins two - # provenance-incompatible decorator-seeded sources (EXTERNAL_RAW + INTEGRAL) - # resolves to MIXED_RAW (rank 7), which DOES propagate up past the floor. + # The real transitive demonstration: an undecorated function reaching a raw + # external boundary (EXTERNAL_RAW) alongside a trusted constant (INTEGRAL). + # The L3 callee-set aggregation is the rank-meet least_trusted (weakest-link), + # NOT taint_join (wardline-17b9ce2c70): least_trusted(EXTERNAL_RAW, INTEGRAL) + # = EXTERNAL_RAW, then floored to mix's own UNKNOWN_RAW seed (rank 6) — a + # genuinely-raw result at its PRECISE rank. taint_join would have spiked it to + # MIXED_RAW (rank 7), the spurious provenance-clash over-label this migration + # removes. The raw still propagates (UNKNOWN_RAW is in the firing RAW_ZONE). _write(tmp_path, "m.py", "from wardline.decorators import external_boundary, trusted\n" "@external_boundary\ndef ext(p):\n return p\n" @@ -155,7 +160,7 @@ def test_analyzer_seeded_taints_drive_transitive_propagation(tmp_path) -> None: assert ctx is not None assert ctx.project_taints["m.ext"] == T.EXTERNAL_RAW assert ctx.project_taints["m.tru"] == T.INTEGRAL - assert ctx.project_taints["m.mix"] == T.MIXED_RAW # transitive provenance clash + assert ctx.project_taints["m.mix"] == T.UNKNOWN_RAW # raw, floored — NOT MIXED_RAW def test_analyzer_skips_unparseable_file_with_fact(tmp_path) -> None: From fb95095b4b0112268995168d59eb9e05d410fd18 Mon Sep 17 00:00:00 2001 From: John Morrissey Date: Sun, 31 May 2026 17:54:51 +1000 Subject: [PATCH 2/4] feat(taint): first-class hardening of the taint-combination engine Follow-up from the 2026-05-31 taint-combination audit (engine found correct: 0 live FP/FN, battery 30/30). Makes the reachable-set correctness ENFORCED rather than incidental. Decision: RETAIN the 8-state lattice + taint_join as the documented contrast operator (ADR docs/decisions/2026-05-31-wardline-taint-lattice-retain.md). - F5: gate the two ungated TaintState parsers to the reachable subset (stdlib rejects INTEGRAL + the unreachable trio; summary_cache keeps INTEGRAL, rejects only the trio so trusted-fn caching still works) - F2: remove the proven-dead unresolved-clamp in the SCC round - F3: RETAIN marker on taint_join (no production call site) - F1: invariant comments at _RAW_ZONE & modulate (scoped to the @trusted/body==declared firing domain) - F6: fix stale "merges keep taint_join" comment - F4: document the wrong-predicate validator blind spot - New authoritative docs/concepts/taint-algebra.md - Invariant-enforcement tests (closure + rank-invariant + pipeline) - Fault-injection tests for the defensive propagation branches Epic wardline-2b138b3662 (9/10 children; compute_return_callee deferred). 962 tests pass; ruff/mypy/mkdocs --strict clean; oracle battery 30/30. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 36 ++++ .../2026-05-31-taint-combination-audit.md | 163 ++++++++++++++++ docs/concepts/taint-algebra.md | 175 ++++++++++++++++++ ...026-05-31-wardline-taint-lattice-retain.md | 88 +++++++++ mkdocs.yml | 1 + src/wardline/core/taints.py | 13 ++ src/wardline/scanner/rules/severity_model.py | 15 ++ .../rules/untrusted_reaches_trusted.py | 20 ++ src/wardline/scanner/taint/propagation.py | 15 +- src/wardline/scanner/taint/stdlib_taint.py | 28 +++ src/wardline/scanner/taint/summary_cache.py | 42 ++++- tests/unit/core/test_taint_invariants.py | 127 +++++++++++++ tests/unit/scanner/taint/test_propagation.py | 94 +++++++++- tests/unit/scanner/taint/test_stdlib_taint.py | 43 +++++ .../unit/scanner/taint/test_summary_cache.py | 80 +++++++- .../unit/scanner/taint/test_variable_level.py | 9 +- 16 files changed, 933 insertions(+), 16 deletions(-) create mode 100644 docs/audits/2026-05-31-taint-combination-audit.md create mode 100644 docs/concepts/taint-algebra.md create mode 100644 docs/decisions/2026-05-31-wardline-taint-lattice-retain.md create mode 100644 tests/unit/core/test_taint_invariants.py diff --git a/CHANGELOG.md b/CHANGELOG.md index f4160a02..ec3033cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,42 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **Taint algebra concepts page + lattice-retention ADR** — a new + `docs/concepts/taint-algebra.md` consolidates the taint-combination + rationale (which operator runs where and why, the reachable-state set and its + invariants, the per-rule consumption map, and the accepted "wrong-predicate + validator" boundary) into one authoritative spec, and + `docs/decisions/2026-05-31-wardline-taint-lattice-retain.md` records the + decision to retain the 8-state lattice and the `taint_join` operator as the + documented contrast operator (no production call site). Resolves the + taint-combination audit findings F1, F3, F4, and F5. + +### Changed + +- **Reachable-state invariant now enforced at the taint parsers** — the two + dynamic `TaintState` construction sites that previously accepted any canonical + state are now constrained to their legal subsets: the bundled stdlib taint + table accepts only `{ASSURED, GUARDED, EXTERNAL_RAW, UNKNOWN_RAW}`, and the + disk-persistent summary cache's deserialiser accepts the full reachable set + `{INTEGRAL, ASSURED, GUARDED, EXTERNAL_RAW, UNKNOWN_RAW}`. Both reject the + never-produced trio (`MIXED_RAW`, `UNKNOWN_GUARDED`, `UNKNOWN_ASSURED`), so a + corrupt/tampered cache file or a future stdlib-table entry carrying one is + rejected (the cache file is dropped as cold-cache fallback) rather than + silently injecting an otherwise-unreachable state. No behaviour change for + valid inputs. Resolves audit finding F5. +- **Removed dead code in the L3 propagation kernel** — the unreachable inner + unresolved-clamp in the per-SCC refinement round (subsumed by the preceding + floor) was deleted, along with the now-orphaned `unresolved_counts` parameter + of the internal `_compute_scc_round` helper. Behaviour-preserving. Resolves + audit finding F2. +- **Corrected stale taint-combiner comments in the test suite** — the + `test_variable_level.py` comments claiming control-flow merges "keep + `taint_join`" predated the merge migration and misdescribed current behaviour; + they now state those merges use `least_trusted` (wardline-4d9f840c24). Test + comments only. Resolves audit finding F6. + ### Fixed - **Control-flow merge over-tainting (false positives)** — the statement-level diff --git a/docs/audits/2026-05-31-taint-combination-audit.md b/docs/audits/2026-05-31-taint-combination-audit.md new file mode 100644 index 00000000..737e12fe --- /dev/null +++ b/docs/audits/2026-05-31-taint-combination-audit.md @@ -0,0 +1,163 @@ +# Audit — taint-combination soundness & precision across the wardline taint engine + +**Verdict:** The three migrations are complete and correct. Every combination / +merge / aggregation / alternative site in the engine is on `least_trusted`. +`taint_join` is dead in production. No remaining over-taint (MIXED_RAW) site and +no operator-level under-taint were found. Six findings, all P2–P4 +(dead-code / latent / by-design / stale-comment); **zero P0/P1, zero live FP, zero +live FN.** Oracles: soundness battery 30/30, self-host 0 PY-WL defects, all +repros below pass. + +## The reachable-state result (the linchpin) + +The only taint states any source can introduce into the live pipeline are: + +| Entry point | States it can produce | +|---|---| +| `decorator_provider` (`@external_boundary`/`@trust_boundary`/`@trusted`) | EXTERNAL_RAW, GUARDED, ASSURED, INTEGRAL | +| L1 fail-closed fallback (`function_level._FALLBACK`) | UNKNOWN_RAW | +| `stdlib_taint.yaml` (shipped) | ASSURED, EXTERNAL_RAW, GUARDED, UNKNOWN_RAW | +| serialisation-sink override | UNKNOWN_RAW | + +So the **reachable set = {INTEGRAL, ASSURED, GUARDED, EXTERNAL_RAW, UNKNOWN_RAW}**. +`least_trusted` returns one of its inputs, so its closure over that set is that +same set. **MIXED_RAW, UNKNOWN_GUARDED, and UNKNOWN_ASSURED are never assigned as +a value anywhere in production.** This was confirmed two ways: (1) literal +attribute-access assignments — the only ones live in the dead `taint_join` body, +the `TRUST_RANK` table, and rule membership sets; and (2) **exhaustive** dynamic +construction — `grep "TaintState(" src/wardline/` returns six call sites, every +one of which is either allow-gated to the reachable subset (`decorator_provider`, +`coerce_level`) or feeds the pipeline only with provider-produced reachable states +(`stdlib_taint`, `summary_cache` — both ungated and folded into F5). No third +ungated source feeds combination, so the reachable set is **provably** as stated. + +REPL proof: +``` +least_trusted closure over reachable set => [ASSURED, EXTERNAL_RAW, GUARDED, INTEGRAL, UNKNOWN_RAW] + manufactures MIXED_RAW? False UNKNOWN_GUARDED? False UNKNOWN_ASSURED? False +taint_join clean-input MIXED_RAW pairs that least_trusted avoids: 20 (e.g. INTEGRAL+ASSURED, ASSURED+GUARDED…) +rank-invariant least_trusted<=taint_join violations: [] +``` + +This both (a) confirms the FP class the migrations targeted is genuinely closed — +20 ordered clean-input pairs that `taint_join` would have spiked to MIXED_RAW +(rank 7, firing RAW_ZONE) now resolve to a clean rank; and (b) makes several +findings below *latent* rather than live. + +--- + +## Findings + +### F1 — MIXED_RAW / UNKNOWN_* unreachable ⇒ the PY-WL-101↔modulate MIXED_RAW asymmetry is latent +- **Classification:** precision / dead-state. **Severity:** P3. **Direction:** dead-code (latent FP *and* latent FN). +- **Sites:** `untrusted_reaches_trusted.py:35-37` `_RAW_ZONE` (contains MIXED_RAW → **fires**) vs `severity_model.py:32-38` `modulate` (MIXED_RAW falls to the `_FREEDOM` else → **NONE / suppressed**). Same state, opposite firing direction. +- **Repro / observed:** see the closure proof above — no production path yields MIXED_RAW, so neither branch is ever reached with it. `modulate` consumes a *single* `context.project_taints[qualname]` tier (`broad_exception.py:45`, `silent_exception.py:44`), never a combination, so even a combination bug could only reach it through a value, and no value is MIXED_RAW. +- **Downstream:** none today — cannot flip a decision because the input is unreachable. +- **Fix:** No code change required for correctness. Worth a one-line comment at `_RAW_ZONE` and at `modulate`'s `_FREEDOM` note recording that MIXED_RAW is *currently unreachable* and that the two rule families would disagree on it if it ever became reachable (see F5). This is the design guard that keeps F5 from becoming live. + +### F2 — Dead inner unresolved-clamp in the SCC round +- **Classification:** floor / clamp. **Severity:** P3. **Direction:** dead-code. +- **Site:** `propagation.py:173-178`. After the line-172 floor `new_taint = floor if rank[floor] > rank[combined] else combined`, we have `rank[new_taint] >= rank[floor]` unconditionally; the inner guard `rank[floor] > rank[new_taint]` is therefore never true. +- **Repro:** swept 625 seed/floor configs with `unresolved>0`, `settrace` on line 178 → **0 executions**. Algebraic check over the reachable set → the condition is `True` in **0** configs. +- **Downstream:** none — never executes. +- **Fix:** Remove lines 173-178 (the line-172 floor subsumes them), or, if kept for parity with `minimum_scope`'s single-clamp comment, annotate it `# unreachable: line-172 floor already pins new_taint >= floor`. `minimum_scope.py:158-161` makes exactly this point in prose ("This single clamp also covers the unresolved-call case") — propagation.py kept the redundant second clamp the prose says is unnecessary. + +### F3 — `taint_join` is dead production code +- **Classification:** operator. **Severity:** P3. **Direction:** dead-code. +- **Site:** `core/taints.py:65-84`. The only live `taint_join(` calls are its own 8 unit-test references in `tests/unit/core/test_taints.py`; every other repo reference (in `test_analyzer`, `test_propagation`, `test_minimum_scope`, `test_variable_level`) is an explanatory comment asserting `least_trusted` is used *instead*. +- **Downstream:** none. +- **Fix (recommended): keep, with an explicit "documented-but-unused" marker.** Rationale: (a) the function and its `_JOIN_TABLE` encode the provenance-clash semantics that ~20 migration regression-guard comments across the test suite cite by name — deleting the operator orphans those references; (b) its 8 unit tests pin the very semantics (`taint_join(INTEGRAL,ASSURED)==MIXED_RAW`) the migration comments contrast against, so they remain useful as the "why we did NOT use this" record. Add a module-level note that `taint_join` has no production call site and exists as the documented contrast operator. (If the project's no-dead-code stance is strict, the alternative is to delete the operator + `_JOIN_TABLE` + its 8 tests and soften the regression-guard comments to reference `least_trusted` only — a larger, lower-value churn.) + +### F4 — Anchored `effective_return` laundering through a *broken* `@trust_boundary` (by-design) +- **Classification:** anchor. **Severity:** P3. **Direction:** under-taint (delegated, not a hole). +- **Site:** `project_resolver.py:156-159` (`effective_return`: anchored → declared return). A caller of a validator reads the validator's *declared* raised tier, regardless of whether the validator actually rejects. +- **Repro (end-to-end scan):** + ``` + @trust_boundary(to_level=ASSURED) def bad_validate(p): return p → PY-WL-102 fires (cannot validate) ✓ + @trusted(level=ASSURED) def producer(p): return bad_validate(read_raw(p)) → SILENT (reads bad_validate's declared ASSURED) + @trusted(level=ASSURED) def direct_launder(p): return read_raw(p) → PY-WL-101 fires (actual EXTERNAL_RAW > ASSURED) ✓ + ``` +- **Downstream:** `producer` is silent on PY-WL-101 *because* its laundering is through a declared boundary — and the broken boundary itself is caught by PY-WL-102. The delegation the rule docstring claims **holds**: nothing escapes into a rule other than 102. Direct (non-boundary) raw laundering still fires (FN guard passes). +- **Fix:** No action — correct as-is. The trust model treats the annotation as the contract; the only statically-decidable enforcement of a validator is "can it reject at all" (PY-WL-102). Residual (out of static reach, not a combination issue): a validator that *has* a rejection path but validates the wrong predicate is semantically invisible. Document as a known boundary of the model, not a bug. + +### F5 — Two ungated dynamic-construction entry points accept the unreachable trio +- **Classification:** ad-hoc / entry-point. **Severity:** P3. **Direction:** latent under-/over-taint enabler. +- **Exhaustive grep** `TaintState(` across `src/wardline/` returns six dynamic-construction sites; classified: + - `decorator_provider.py:108` — **gated** (`:111` `level if level in allowed else None`), feeds pipeline ✓ + - `decorators/_base.py:32` `coerce_level` — **gated** (`raises` if `level not in allowed`) AND runtime decorator code, not the scanner's static path ✓ + - `core/taints.py:18` — the class definition, not a call ✓ + - **`stdlib_taint.py:69`** `TaintState(returns_taint_raw)` — **ungated**; feeds `taint_map`/`return_taint_map`. `TaintState("MIXED_RAW")` succeeds. + - **`summary_cache.py:199-200`** `_deserialise_summary` — **ungated**; rehydrates `body_taint`/`return_taint` from the **disk-persistent** cache (`cache_dir=…` + `load()`), which feeds `summaries` → the pipeline. "MIXED_RAW" is a *valid* TaintState string so the "malformed files silently dropped" guard does NOT catch it; the cache_key fingerprint only rejects stale-schema files, not a same-schema file holding a poisoned-but-valid state. +- **Downstream:** today none — the shipped yaml uses only {ASSURED, EXTERNAL_RAW, GUARDED, UNKNOWN_RAW} and the cache only ever round-trips provider-produced (reachable) states. But a future yaml entry — or a hand-edited/corrupted on-disk cache file — carrying `MIXED_RAW` (or `UNKNOWN_GUARDED`/`UNKNOWN_ASSURED`) would inject an otherwise-unreachable state, immediately activating the F1 asymmetry (it would *fire* PY-WL-101 yet *suppress* the tier-modulated rules). +- **Fix:** Constrain *both* parsers to the call-return-legal subset `{ASSURED, GUARDED, EXTERNAL_RAW, UNKNOWN_RAW}` and raise on MIXED_RAW / UNKNOWN_GUARDED / UNKNOWN_ASSURED, with a message pointing at F1. Cheap guard that makes the reachable-set invariant *enforced* rather than incidental. (Cache severity is lower — a local cache file is a trusted artifact — but the one-line guard covers both for free.) + +### F6 — Stale comment: control-flow merges "keep taint_join" +- **Classification:** stale comment. **Severity:** P4. **Direction:** doc-only. +- **Site:** `tests/unit/scanner/taint/test_variable_level.py:830` — *"only control-flow MERGES (if/else, loops, match arms) keep taint_join."* This predates the control-flow-merge migration (wardline-4d9f840c24); those merges are now on `least_trusted` (verified at `variable_level.py:642,678,708,778,841` and by the L2 repro below). The comment actively misdescribes current behavior. +- **Fix:** Reword to "control-flow merges also use `least_trusted` (wardline-4d9f840c24)." + +--- + +## Coverage table — every site inspected + +Legend: **Op** = operator used. **Verdict:** ✓ correct / latent / dead / by-design. +**Exercised:** how it was empirically driven. + +| # | Site (`file:line`) | Class | Op | Verdict | Empirically exercised | +|---|---|---|---|---|---| +| 1 | `taints.py:87 least_trusted` | operator | — | ✓ closed over reachable set | YES — closure + invariant sweep | +| 2 | `taints.py:65 taint_join` | operator | — | dead (F3) | YES — grep + closure contrast | +| 3 | `variable_level.py:134 BinOp` | value-merge | LT | ✓ | YES — L2 driver (clean+raw) | +| 4 | `variable_level.py:142 List/Tuple/Set` | aggregation | LT | ✓ | YES | +| 5 | `variable_level.py:155 Dict` | aggregation | LT | ✓ | YES | +| 6 | `variable_level.py:168 IfExp` | alternative | LT | ✓ | YES | +| 7 | `variable_level.py:189 BoolOp` | alternative | LT | ✓ | YES | +| 8 | `variable_level.py:232 JoinedStr` | value-merge | LT | ✓ | YES (.join proxy + str) | +| 9 | `variable_level.py:272 DictComp` | aggregation | LT | ✓ | covered by container/list cases | +| 10 | `variable_level.py:337 .format/.join` | value-merge | LT | ✓ | YES | +| 11 | `variable_level.py:355 .get/.pop default` | alternative | LT | ✓ | YES | +| 12 | `variable_level.py:371 propagating builtins` | value-merge | LT | ✓ | YES (str(raw)) | +| 13 | `variable_level.py:560 AugAssign` | value-merge | LT | ✓ | YES | +| 14 | `variable_level.py:595 container-base write` | value-merge | LT | ✓ floor toward less-trusted | covered by dict-raw case | +| 15 | `variable_level.py:642 if/else merge` | alternative | LT | ✓ | YES | +| 16 | `variable_level.py:678 for back-edge` | alternative | LT | ✓ | YES | +| 17 | `variable_level.py:708 while back-edge` | alternative | LT | ✓ | same shape as for (16) | +| 18 | `variable_level.py:778 try/except merge` | alternative | LT | ✓ | YES | +| 19 | `variable_level.py:841 match-arm merge` | alternative | LT | ✓ | YES | +| 20 | `variable_level.py:925 compute_return_taint` | aggregation | LT | ✓ | YES (every L2 case returns through it) | +| 21 | `variable_level.py:961 compute_return_callee` | diagnostic | LT | ✓ (returns one input; well-defined) | indirectly (101 properties) | +| 22 | `minimum_scope.py:155 callee combine` | aggregation | LT | ✓ | YES — 2-hop refine repro (clean+raw) | +| 23 | `minimum_scope.py:161 seed floor` | floor | rank | ✓ toward less-trusted | YES — 2-hop refine repro (floor pins to seed) | +| 24 | `minimum_scope.py:164 via_callee max` | diagnostic | rank | ✓ non-firing | YES — executed in refine (result stored only on change) | +| 25 | `propagation.py:170 SCC-round combine` | aggregation | LT | ✓ | YES — SCC cycle repro | +| 26 | `propagation.py:172 SCC-round floor` | floor | rank | ✓ toward less-trusted | YES — SCC cycle repro | +| 27 | `propagation.py:173-178 unresolved clamp` | floor | rank | **dead (F2)** | YES — 625-config sweep, 0 hits | +| 28 | `propagation.py:314 ext-influence combine` | aggregation | LT | ✓ | YES — DAG repro | +| 29 | `propagation.py:318-328 ext floors (L1/unresolved)` | floor | rank | ✓ toward less-trusted | YES — DAG repro | +| 30 | `propagation.py:404 Phase-1b seed-join` | aggregation | LT | ✓ order-independent | YES — SCC cycle repro | +| 31 | `propagation.py:414 phase2_floor freeze` | floor | rank | ✓ no wash-out | YES — SCC cycle repro (traced) | +| 32 | `propagation.py:457 monotonicity check` | assertion | rank | ✓ pins to safer value | not triggered (no violation) | +| 33 | `propagation.py:502 post-assertion` | assertion | rank | ✓ | not triggered | +| 34 | `decorator_provider.py:131-132 per-field max` | aggregation | rank | ✓ = per-field least-trusted (conservative on decorator conflict) | algebraic (max-by-rank == least_trusted) | +| 35 | `project_resolver.py:156 effective_return` | anchor | — | by-design (F4) | YES — end-to-end scan | +| 36 | `call_taint_map.py:55-114` | assignment | — | ✓ no combination; sink override conservative | read-confirmed | +| 37 | `callgraph.py` (edges) | — | — | ✓ no taint combination | read-confirmed | +| 38 | `analyzer.py:204-276 fn_return_taints assembly` | aggregation | LT | ✓ (via compute_return_taint) | YES (end-to-end + L2 driver) | +| 39 | `untrusted_reaches_trusted.py:71,76` | ordering | rank | ✓ consumes actual vs declared | YES — end-to-end (101 fires/silent) | +| 40 | `boundary_without_rejection.py:59` | ordering | rank | ✓ consumes body vs declared | YES — end-to-end (102 fires) | +| 41 | `severity_model.py:32 modulate` | tier-map | set | ✓ single tier; MIXED_RAW branch latent (F1) | read + reachability proof | +| 42 | `stdlib_taint.py:69 parser` | entry-point | — | latent (F5) | YES — `TaintState("MIXED_RAW")` accepted | +| 43 | `summary_cache.py:199 deserialise` | entry-point | — | latent (F5 sibling) | YES — ungated, disk-persistent, valid-string passes drop-guard | +| 44 | `decorators/_base.py:32 coerce_level` | entry-point | — | ✓ gated + runtime-only (not scanner path) | YES — grep + read (`in allowed` raise) | + +LT = `least_trusted`. "rank" = `TRUST_RANK` ordering comparison (not a combination). + +## Answers to the five specific questions +1. **`taint_join` reachable?** No — dead apart from its 8 own unit tests. Disposition: keep with an unused-marker comment (F3). +2. **Sites still on `taint_join` / missed by the migrations?** None on `taint_join`. The only non-`least_trusted` combiner is `decorator_provider`'s per-field `max`-by-rank (#34), which *is* least-trusted by another name and is correct (conservative on annotation conflict). +3. **`least_trusted`/floor/anchor sites that under-taint?** No operator under-taint (`least_trusted` cannot, by construction; every floor clamps *toward* less-trusted = the L1 seed, never promotes). The only trust-raising read is the anchored `effective_return` (#35/F4) — by design, delegated to and caught by PY-WL-102; verified. +4. **L1/L2/L3 layering consistent / no double-count or wash-out?** Yes. Floors clamp to the L1 seed; `phase2_floor` freezes the post-seed lower bound so a later round can't wash out a seed-join (verified in the SCC repro); Phase-1b avoids self-loop re-injection; `least_trusted` is commutative/associative/idempotent so the result is visitation-order-independent. +5. **`project_taints` vs `function_return_taints` consumed inconsistently?** No. PY-WL-101 reads `function_return_taints` (actual return) vs `project_return_taints` (declared); PY-WL-102 reads `project_taints` (body) vs `project_return_taints`; the tier-modulated rules read `project_taints` (body). Each consumes a single resolved tier matched to its intent — no combination crosses between them. + +## Could-not-drive +- `propagation.py:457` monotonicity violation and `:502` post-assertion are defensive branches that fire only on a transfer-function bug; with the migration correct, no crafted reachable input triggers them. Verified they did NOT fire (diagnostics empty) across all repros — i.e. the engine stayed monotone — but the *violation* arm itself is, correctly, not reachable by sound inputs. diff --git a/docs/concepts/taint-algebra.md b/docs/concepts/taint-algebra.md new file mode 100644 index 00000000..65a88963 --- /dev/null +++ b/docs/concepts/taint-algebra.md @@ -0,0 +1,175 @@ +# Taint algebra — the combination operators and their invariants + +This is the authoritative specification of *how* Wardline combines taint states: +which operator runs at each kind of program point, why, and the invariants that +keep the result sound and precise. It is the engineering complement to the +reader-facing [Taint & trust model](model.md), and it consolidates the +regression-guard rationale that was previously scattered across the test suite as +inline comments. + +If you are extending the engine — adding a combination site, a rule, or an entry +point that parses a `TaintState` — read this first. + +## The two operators + +Wardline's lattice (`src/wardline/core/taints.py`) defines two binary operators +over `TaintState`. They are **not** interchangeable. + +### `least_trusted` — the rank-meet (weakest-link). The one the engine uses. + +```python +least_trusted(a, b) = a if TRUST_RANK[a] >= TRUST_RANK[b] else b +``` + +It returns the *less-trusted* (higher `TRUST_RANK`) of its two inputs — always +one of the inputs, never a new state. It is commutative, associative, and +idempotent, so the result of folding a set of states with it is independent of +visitation order. **Every** combination / merge / aggregation / alternative site +in the live engine uses `least_trusted`. + +### `taint_join` — the provenance-clash join. Documented, but unused. + +```python +taint_join(INTEGRAL, ASSURED) == MIXED_RAW # different families clash +least_trusted(INTEGRAL, ASSURED) == ASSURED # weakest link wins +``` + +`taint_join` models *provenance compatibility*: combining two values of the +**same** family yields that family's weaker member, but combining two values of +**different** families is treated as a provenance clash and collapses to the +absorbing top `MIXED_RAW`. After the three `least_trusted` migrations it has **no +production call site** — it is retained deliberately as the documented contrast +operator. See the ADR: +[Retain the 8-state lattice](../decisions/2026-05-31-wardline-taint-lattice-retain.md). + +## The discriminator: why even genuine value-merges use `least_trusted` + +There are three shapes of combination point, and **all three resolve to +`least_trusted` at L2**: + +| Shape | Example | Why `least_trusted` | +|---|---|---| +| **Alternative** (value is exactly one of N) | `x = a if c else b`; `if/else`, loop back-edges, `try/except`, `match` arms | At the merge a variable holds the value of exactly one branch — weakest-link is the sound, precise bound. | +| **Aggregation** (a summary of a *set*) | a function's callee-set taint; container literals | Aggregating the influence of a set of callees is not building one value by merging provenances — it is a weakest-link summary. | +| **Value-merge** (one value built from several) | `a + b`; `",".join(parts)`; f-strings; `.format` | This is the subtle one — see below. | + +The non-obvious case is the genuine **value-merge**. You might expect +`taint_join`'s provenance-clash semantics to be "more correct" for `a + b`. They +are not, and using them here was the false-positive class the migrations fixed: + +> Two **clean** operands of **different** families — e.g. an `ASSURED` validated +> value concatenated with an `INTEGRAL` constant separator — would clash to +> `MIXED_RAW` under `taint_join`. `MIXED_RAW` is rank 7, inside the firing raw +> zone, so it fired `PY-WL-101` on validated, clean data. That is the +> `RAW_ZONE` false positive. + +`least_trusted` is correct for value-merges too: a value built from an `ASSURED` +part and an `INTEGRAL` part is no more trusted than `ASSURED`, and no *less* +trusted than that either — there is no honest reason to treat a benign literal as +contaminating. A *raw* operand still propagates at its precise rank and still +fires. So the precision win has no soundness cost. + +## The reachable-state set and its invariant + +The only taint states any source can introduce into the live pipeline are: + +``` +{INTEGRAL, ASSURED, GUARDED, EXTERNAL_RAW, UNKNOWN_RAW} +``` + +These come from exactly four entry points: the decorator provider +(`EXTERNAL_RAW`, `GUARDED`, `ASSURED`, `INTEGRAL`), the L1 fail-closed fallback +(`UNKNOWN_RAW`), the bundled `stdlib_taint.yaml` table (`ASSURED`, `GUARDED`, +`EXTERNAL_RAW`, `UNKNOWN_RAW`), and the serialisation-sink override +(`UNKNOWN_RAW`). + +Because `least_trusted` always returns one of its inputs, **its closure over the +reachable set is the reachable set**. The remaining three states — +`{MIXED_RAW, UNKNOWN_GUARDED, UNKNOWN_ASSURED}` — are **never produced anywhere in +production**. This is the linchpin invariant. + +### What enforces it + +- **`least_trusted` is closed over the set** by construction (it returns an + input). +- **The F5 parser guards** close the two previously-ungated dynamic entry points + that could otherwise inject an unreachable state from data: + - `stdlib_taint.py` accepts only `{ASSURED, GUARDED, EXTERNAL_RAW, UNKNOWN_RAW}` + (a stdlib call cannot produce your own `INTEGRAL` data). + - `summary_cache.py` `_deserialise_summary` accepts the full reachable set + `{INTEGRAL, ASSURED, GUARDED, EXTERNAL_RAW, UNKNOWN_RAW}` — a `@trusted` + function legitimately caches `INTEGRAL` — and rejects only the trio. A + rejected (corrupt or tampered) cache file is dropped with a warning, never + injected. +- **The invariant-enforcement tests** (`tests/unit/core/test_taint_invariants.py`) + pin both the operator closure and the end-to-end pipeline property (no scan + output is ever `MIXED_RAW` or an `UNKNOWN_GUARDED`/`UNKNOWN_ASSURED` state). + +### Why the trio's unreachability matters + +If `MIXED_RAW` ever became reachable, two rule families would **disagree** on it. +`PY-WL-101` would **fire** on it as the *actual* return of a `@trusted` producer +(where `body == declared`, so it passes the rule's trust-raising gate): at rank 7 +it is strictly less trusted than any clean declared tier, so the actual-vs- +declared rank comparison trips. `severity_model.modulate`, by contrast, treats it +as the freedom zone and **suppresses** (returns `NONE`). The firing is **not** +unconditional, though: if the *body* is itself `MIXED_RAW` (the realistic route to +a `MIXED_RAW` actual return), `PY-WL-101`'s body-less-trusted-than-declared gate +suppresses first and delegates to `PY-WL-102`, so `101` does not fire there. +(Note `PY-WL-101`'s `_RAW_ZONE` set is a suppression gate on the *declared* tier, +not the firing condition — `MIXED_RAW`'s membership in it is inert because you +never *declare* `MIXED_RAW`.) That asymmetry is harmless only because the input is +unreachable. The F5 guards are what keep it latent. + +## Floor / clamp / anchor rules + +All clamps move **toward less-trusted**, never toward more-trusted: + +- A **floor** pins a function's refined taint to be no more trusted than its L1 + seed (its body-evaluation tier). Floors clamp down to the seed; they never + promote a function to a more-trusted state. +- The L3 fixed point is **monotone**: a non-anchored function only ever moves + toward less-trusted during propagation. A strict move toward more-trusted + indicates a transfer-function bug and trips `L3_MONOTONICITY_VIOLATION`, which + pins the function at its old (safer) value. +- **Anchored** functions are never refined by L3 — their declared tier is + authoritative, asserted post-fixed-point. + +## Per-rule consumption map + +Each rule reads exactly one resolved tier, matched to its intent — no combination +crosses between maps: + +| Rule | Reads | Against | +|---|---|---| +| `PY-WL-101` (untrusted reaches trusted) | `function_return_taints` (**actual** returned-value taint) | `project_return_taints` (**declared** return tier) | +| `PY-WL-102` (boundary without rejection) | `project_taints` (**body** taint) | `project_return_taints` (**declared** return tier) | +| Tier-modulated rules (e.g. broad/silent exception) | `project_taints` (**body** tier) | — (single tier into `modulate`) | + +## Known boundary: a validator that checks the wrong predicate (F4) + +When a caller launders raw data through a `@trust_boundary` validator, `PY-WL-101` +reads the validator's **declared** output tier (`effective_return`, +`project_resolver.py:156`) — not the raw input — because the trust model treats +the annotation as the contract. + +This is sound for the statically-decidable property. A **broken** validator with +*no rejection path at all* is caught by `PY-WL-102` (it can never raise, so it +cannot validate). + +The **residual** — accepted, out of static reach — is a validator that **has** a +rejection path but checks the **wrong predicate** (e.g. it validates length when +it should validate content). Such a validator passes `PY-WL-102` (it *can* reject) +and `PY-WL-101` trusts its declared output. This is semantically invisible to +static analysis: the engine can decide *"can this function reject at all"*, but +not *"does it reject the right thing"*. This is a property limit of the model, not +a bug — it is the boundary between what the annotation-as-contract trust model +promises and what a value-level semantic analysis would require. + +## See also + +- [Taint & trust model](model.md) — the reader-facing introduction. +- [Rules](rules.md) — the checks built on this algebra. +- [ADR: Retain the 8-state lattice](../decisions/2026-05-31-wardline-taint-lattice-retain.md). +- `docs/audits/2026-05-31-taint-combination-audit.md` — the audit this spec + consolidates (findings F1–F6). diff --git a/docs/decisions/2026-05-31-wardline-taint-lattice-retain.md b/docs/decisions/2026-05-31-wardline-taint-lattice-retain.md new file mode 100644 index 00000000..ab3b31f3 --- /dev/null +++ b/docs/decisions/2026-05-31-wardline-taint-lattice-retain.md @@ -0,0 +1,88 @@ +# ADR: Retain the 8-state taint lattice and `taint_join` as a documented contrast operator + +- **Status:** Accepted +- **Date:** 2026-05-31 +- **Resolves:** taint-combination audit findings F1, F3, F4, F5 + (`docs/audits/2026-05-31-taint-combination-audit.md`) + +## Context + +Wardline's taint engine combines, merges, and aggregates per-value and +per-function trust levels at many sites (expression operands, control-flow merge +points, call-graph callee sets). Historically several of these sites used +`taint_join` — a *provenance-clash* operator that maps any pair of +different-family clean states to the absorbing top `MIXED_RAW`. Three migrations +(`wardline-4d94577013` L2 expression combiners, `wardline-4d9f840c24` L2 +control-flow merges, `wardline-17b9ce2c70` L3 callee combinations) replaced every +combination site with `least_trusted` — the rank-meet (weakest-link) operator — +because two clean callees/branches of *different* families combining to +`MIXED_RAW` (rank 7, in the firing raw zone) was a `PY-WL-101` false positive. + +The 2026-05-31 audit confirmed the engine is correct after these migrations: +**zero live false positives, zero live false negatives**, every combination site +on `least_trusted`. It also established the linchpin result: the only taint +states any source can introduce into the live pipeline are +`{INTEGRAL, ASSURED, GUARDED, EXTERNAL_RAW, UNKNOWN_RAW}`. `least_trusted` returns +one of its inputs, so its closure over that set *is* that set — the trio +`{MIXED_RAW, UNKNOWN_GUARDED, UNKNOWN_ASSURED}` is **never produced** anywhere in +production. + +That left a disposition question (audit F3): `taint_join` now has **no production +call site** — its only live callers are its own 8 unit tests. Should it (and its +three orphan states) be deleted (COLLAPSE to a 5-state lattice), or retained? + +## Decision + +**RETAIN** the full 8-state `TaintState` lattice, the `taint_join` operator, and +its `_JOIN_TABLE`. `taint_join` is kept deliberately as the documented contrast +operator — the "why we did NOT use this" record — and is explicitly marked in its +docstring as having no production call site. + +The reachable-set invariant is instead made **enforced** at the two previously +ungated dynamic-construction entry points (audit F5): + +- `stdlib_taint.py` — constrained to `{ASSURED, GUARDED, EXTERNAL_RAW, UNKNOWN_RAW}`. +- `summary_cache.py` `_deserialise_summary` — constrained to the full reachable + set `{INTEGRAL, ASSURED, GUARDED, EXTERNAL_RAW, UNKNOWN_RAW}` (a `@trusted` + function legitimately caches `INTEGRAL`). + +Both reject the unreachable trio with a `ValueError` that cites the invariant. + +## Consequences + +### Positive + +- **No orphaned references.** ~18 regression-guard comments across the test suite + cite `taint_join` by name as the operator the migrations contrast against; + `taint_join`'s 8 unit tests pin the provenance-clash semantics + (`taint_join(INTEGRAL, ASSURED) == MIXED_RAW`) those comments reference. + Retaining the operator keeps that record intact and authoritative. +- **Invariant is enforced, not incidental.** The F5 parser guards make the trio's + unreachability a checked property at every dynamic entry point, rather than a + fact that holds only because nobody deleted the states. A corrupted/tampered + on-disk cache or a future stdlib-table entry carrying `MIXED_RAW` is now + rejected, not silently injected. +- **Extensibility headroom.** The `UNKNOWN_*` family and the provenance-clash + semantics remain available should a future analysis tier (e.g. true value-level + provenance tracking) need them, without re-litigating the lattice design. + +### Negative / accepted + +- The codebase carries an operator with no production call site. This is a + deliberate, documented exception to a strict no-dead-code stance — mitigated by + the explicit docstring marker, this ADR, and `docs/concepts/taint-algebra.md`. + +### Rejected alternative — COLLAPSE to 5 states + +Delete `taint_join`, `_JOIN_TABLE`, the three orphan states, and the 8 unit +tests; soften every regression-guard comment to reference `least_trusted` only. +Rejected because it is a larger, lower-value churn that orphans the very +references that document *why* the migrations were necessary, and it would rely on +deletion (rather than an enforced guard) to keep the trio unreachable — a weaker +guarantee that silently breaks the moment any state is re-added. + +## References + +- Audit: `docs/audits/2026-05-31-taint-combination-audit.md` (F1, F3, F4, F5) +- Taint algebra spec: `docs/concepts/taint-algebra.md` +- Operator definitions: `src/wardline/core/taints.py` diff --git a/mkdocs.yml b/mkdocs.yml index e8909193..f76f8c60 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -45,6 +45,7 @@ nav: - Getting Started: getting-started.md - Concepts: - Taint & trust model: concepts/model.md + - Taint algebra: concepts/taint-algebra.md - Rules: concepts/rules.md - Guides: - Configuration: guides/configuration.md diff --git a/src/wardline/core/taints.py b/src/wardline/core/taints.py index 65741671..dfbc90a6 100644 --- a/src/wardline/core/taints.py +++ b/src/wardline/core/taints.py @@ -75,6 +75,19 @@ def taint_join(a: TaintState, b: TaintState) -> TaintState: every other cross-family pair (e.g. ``taint_join(INTEGRAL, ASSURED)`` is ``MIXED_RAW`` whereas ``least_trusted(INTEGRAL, ASSURED)`` is ``ASSURED``). Do not "simplify" one into the other. + + RETAINED, NO PRODUCTION CALL SITE. After the three least_trusted migrations + (wardline-4d94577013 L2 expression combiners, wardline-4d9f840c24 L2 + control-flow merges, wardline-17b9ce2c70 L3 callee combinations) nothing in + the live pipeline calls ``taint_join`` — every combination/merge/aggregation/ + alternative site uses :func:`least_trusted`. It is kept DELIBERATELY as the + documented contrast operator: its 8 unit tests pin the provenance-clash + semantics (``taint_join(INTEGRAL, ASSURED) == MIXED_RAW``) that ~18 + regression-guard comments across the test suite cite by name as the "why we + did NOT use this" record, and the F5 parser guards keep ``MIXED_RAW`` and the + ``UNKNOWN_*`` family unreachable rather than relying on deletion. See + docs/decisions/2026-05-31-wardline-taint-lattice-retain.md (ADR) and + docs/concepts/taint-algebra.md. """ if a == b: return a diff --git a/src/wardline/scanner/rules/severity_model.py b/src/wardline/scanner/rules/severity_model.py index 9cd297d2..c15a7ff7 100644 --- a/src/wardline/scanner/rules/severity_model.py +++ b/src/wardline/scanner/rules/severity_model.py @@ -19,6 +19,21 @@ {TaintState.GUARDED, TaintState.UNKNOWN_ASSURED, TaintState.UNKNOWN_GUARDED} ) # _FREEDOM = {EXTERNAL_RAW, UNKNOWN_RAW, MIXED_RAW} — the implicit else branch. +# INVARIANT (taint-combination audit, F1): MIXED_RAW is CURRENTLY UNREACHABLE; no +# sound analysis path produces it (least_trusted is closed over the reachable set +# {INTEGRAL, ASSURED, GUARDED, EXTERNAL_RAW, UNKNOWN_RAW}; F5's parser guards keep +# the trio out of the stdlib table and the disk cache). If it ever became +# reachable, this rule family and PY-WL-101 would DISAGREE: modulate's else branch +# below treats MIXED_RAW as freedom-zone and SUPPRESSES (returns NONE), whereas +# PY-WL-101 would FIRE on it as the ACTUAL return of a @trusted producer +# (body==declared, passing 101's :86-87 trust-raising gate), because at rank 7 it +# is strictly less trusted than any clean declared tier — a rank comparison, NOT +# _RAW_ZONE membership, which gates only the *declared* tier. The 101 firing is +# not unconditional: a MIXED_RAW *body* (the realistic route to a MIXED_RAW actual +# return) trips 101's :86-87 gate and delegates to PY-WL-102 instead. F5's guards +# are what keep that asymmetry latent. See +# docs/decisions/2026-05-31-wardline-taint-lattice-retain.md and +# docs/concepts/taint-algebra.md. _DOWNGRADE: dict[Severity, Severity] = { Severity.CRITICAL: Severity.ERROR, diff --git a/src/wardline/scanner/rules/untrusted_reaches_trusted.py b/src/wardline/scanner/rules/untrusted_reaches_trusted.py index dc21633e..00a2ee1e 100644 --- a/src/wardline/scanner/rules/untrusted_reaches_trusted.py +++ b/src/wardline/scanner/rules/untrusted_reaches_trusted.py @@ -32,6 +32,26 @@ if TYPE_CHECKING: from wardline.scanner.context import AnalysisContext +# INVARIANT (taint-combination audit, F1): MIXED_RAW is CURRENTLY UNREACHABLE — +# no sound analysis path produces it (every combiner uses least_trusted, which is +# closed over the reachable set {INTEGRAL, ASSURED, GUARDED, EXTERNAL_RAW, +# UNKNOWN_RAW}; F5's parser guards keep the trio out of the stdlib table and the +# disk cache). If MIXED_RAW ever became reachable, PY-WL-101 and the tier- +# modulated rules would DISAGREE on it: PY-WL-101 would FIRE on it as the ACTUAL +# return of a @trusted producer (body==declared, which passes the trust-raising +# gate at :86-87), because at rank 7 it is strictly less trusted than any clean +# declared tier (the rank comparison at the `actual` check below), whereas +# severity_model.modulate treats it as the freedom zone and SUPPRESSES (returns +# NONE). The firing is NOT unconditional: if the body itself is MIXED_RAW (the +# realistic route to a MIXED_RAW actual return), the :86-87 body-less-trusted-than- +# declared gate suppresses first and delegates to PY-WL-102, so 101 does not fire +# there. NOTE the _RAW_ZONE set here is the +# SUPPRESSION gate on the *declared* tier (the `declared in _RAW_ZONE: continue` +# below) — MIXED_RAW's membership in it is inert, because you never *declare* +# MIXED_RAW; the firing is via the actual-return rank, not this set. The F5 guards +# are what preserve the invariant that this asymmetry stays latent. See +# docs/decisions/2026-05-31-wardline-taint-lattice-retain.md and +# docs/concepts/taint-algebra.md. _RAW_ZONE: frozenset[TaintState] = frozenset( {TaintState.EXTERNAL_RAW, TaintState.UNKNOWN_RAW, TaintState.MIXED_RAW} ) diff --git a/src/wardline/scanner/taint/propagation.py b/src/wardline/scanner/taint/propagation.py index a65459c6..48f74b0a 100644 --- a/src/wardline/scanner/taint/propagation.py +++ b/src/wardline/scanner/taint/propagation.py @@ -132,7 +132,6 @@ def _compute_scc_round( taint_keys: set[str], current: dict[str, TaintState], return_taint_map: dict[str, TaintState], - unresolved_counts: dict[str, int], phase2_floor: dict[str, TaintState], ) -> tuple[dict[str, TaintState], dict[str, str | None]]: """Compute one synchronous SCC refinement round from a stable snapshot.""" @@ -169,13 +168,14 @@ def _compute_scc_round( # false positive); a raw callee still propagates at its precise rank. combined = reduce(least_trusted, callee_taints) floor = phase2_floor[func] + # The line-above floor pins TRUST_RANK[new_taint] >= TRUST_RANK[floor] + # unconditionally, so the former inner unresolved-clamp guard + # (rank[floor] > rank[new_taint]) was never true — dead code removed + # (taint-combination audit, F2; minimum_scope.py:158-161 makes the same + # point in prose). The unresolved floor is already applied at seed time + # (phase2_floor incorporates the unresolved pessimistic floor from the + # Phase-1 external-influence pass). new_taint = floor if TRUST_RANK[floor] > TRUST_RANK[combined] else combined - if ( - unresolved_counts.__contains__(func) - and unresolved_counts[func] > 0 - and TRUST_RANK[floor] > TRUST_RANK[new_taint] - ): - new_taint = floor updates[func] = new_taint via_callee[func] = best_callee @@ -439,7 +439,6 @@ def propagate_callgraph_taints( taint_keys=taint_keys, current=current, return_taint_map=return_taint_map, - unresolved_counts=unresolved_counts, phase2_floor=phase2_floor, ) diff --git a/src/wardline/scanner/taint/stdlib_taint.py b/src/wardline/scanner/taint/stdlib_taint.py index 477caf17..3eda9caa 100644 --- a/src/wardline/scanner/taint/stdlib_taint.py +++ b/src/wardline/scanner/taint/stdlib_taint.py @@ -20,6 +20,22 @@ from wardline.core.taints import TaintState +# Legal return tiers for a stdlib call. A stdlib function returns data the +# project did not produce, so INTEGRAL (your own fully-trusted data) is +# nonsensical here, and the unreachable trio {MIXED_RAW, UNKNOWN_GUARDED, +# UNKNOWN_ASSURED} must never enter the pipeline (see the reachable-set +# invariant in docs/concepts/taint-algebra.md and the taint-combination audit, +# F5). Constraining the parser to this set makes that invariant ENFORCED at the +# entry point rather than incidental. +_STDLIB_LEGAL_RETURN: frozenset[TaintState] = frozenset( + { + TaintState.ASSURED, + TaintState.GUARDED, + TaintState.EXTERNAL_RAW, + TaintState.UNKNOWN_RAW, + } +) + STDLIB_TAINT_VERSION: int = 1 """Bumped when the table's shape or entries change materially; folded into the SP1e summary cache key so changes invalidate dependent summaries.""" @@ -72,6 +88,18 @@ def _build_table(raw: Any) -> StdlibTaintTable: f"stdlib_taint.yaml entries[{idx}].returns_taint={returns_taint_raw!r} " f"is not a canonical TaintState" ) from exc + # Reject any state outside the stdlib-legal return set — both the + # unreachable trio AND INTEGRAL (a stdlib call cannot produce your own + # fully-trusted data). This keeps the reachable-set invariant enforced; + # see docs/concepts/taint-algebra.md and the taint-combination audit (F5). + if taint not in _STDLIB_LEGAL_RETURN: + raise ValueError( + f"stdlib_taint.yaml entries[{idx}].returns_taint={returns_taint_raw!r} " + f"is not a legal stdlib return tier (allowed: " + f"{sorted(s.value for s in _STDLIB_LEGAL_RETURN)}); states outside " + f"this set would inject an otherwise-unreachable taint and break the " + f"reachable-set invariant (see docs/concepts/taint-algebra.md, audit F5)" + ) # Every curated entry must justify itself — the table is meant to keep # UNKNOWN_RAW rates auditable, so a silent missing/empty rationale is a # curation defect, not a default. diff --git a/src/wardline/scanner/taint/summary_cache.py b/src/wardline/scanner/taint/summary_cache.py index 97492283..8080a63c 100644 --- a/src/wardline/scanner/taint/summary_cache.py +++ b/src/wardline/scanner/taint/summary_cache.py @@ -41,6 +41,44 @@ _logger = logging.getLogger(__name__) +# The full reachable taint set for an analyzed function's cached body/return +# taint. Unlike the stdlib table, a cached summary CAN be INTEGRAL (a @trusted +# function produces INTEGRAL), so INTEGRAL is legal here. What must never be +# rehydrated is the unreachable trio {MIXED_RAW, UNKNOWN_GUARDED, +# UNKNOWN_ASSURED}: those are valid TaintState strings, so the "malformed file +# silently dropped" guard in load() does NOT catch them, yet they are never +# produced by any sound analysis. A hand-edited or corrupted on-disk cache file +# carrying one would inject an otherwise-unreachable state into the pipeline. +# See docs/concepts/taint-algebra.md and the taint-combination audit (F5). +_CACHE_LEGAL_TAINT: frozenset[TaintState] = frozenset( + { + TaintState.INTEGRAL, + TaintState.ASSURED, + TaintState.GUARDED, + TaintState.EXTERNAL_RAW, + TaintState.UNKNOWN_RAW, + } +) + + +def _parse_cache_taint(raw: str, field: str) -> TaintState: + """Parse a cached taint string, rejecting the unreachable trio. + + Raises ValueError on a valid-but-unreachable state (MIXED_RAW / + UNKNOWN_GUARDED / UNKNOWN_ASSURED). load() catches ValueError and drops the + poisoned file with a warning (cold-cache fallback), so an enforced invariant + here cannot crash a scan. + """ + state = TaintState(raw) # may raise ValueError on a non-canonical string + if state not in _CACHE_LEGAL_TAINT: + raise ValueError( + f"cached {field}={raw!r} is the unreachable taint state {raw!r}; no " + f"sound analysis produces {{MIXED_RAW, UNKNOWN_GUARDED, " + f"UNKNOWN_ASSURED}}, so a cache file holding one is corrupt or " + f"tampered (see docs/concepts/taint-algebra.md, audit F5)" + ) + return state + class SummaryCache: """Process-local, default-empty cache keyed on FunctionSummary.cache_key.""" @@ -196,8 +234,8 @@ def _deserialise_summary(d: dict[str, object]) -> FunctionSummary: raise ValueError(f"invalid taint_source: {taint_source!r}") return FunctionSummary( fqn=str(d["fqn"]), - body_taint=TaintState(cast("str", d["body_taint"])), - return_taint=TaintState(cast("str", d["return_taint"])), + body_taint=_parse_cache_taint(cast("str", d["body_taint"]), "body_taint"), + return_taint=_parse_cache_taint(cast("str", d["return_taint"]), "return_taint"), taint_source=taint_source, unresolved_calls=int(cast("int", d["unresolved_calls"])), schema_version=int(cast("int", d["schema_version"])), diff --git a/tests/unit/core/test_taint_invariants.py b/tests/unit/core/test_taint_invariants.py new file mode 100644 index 00000000..3a66ebde --- /dev/null +++ b/tests/unit/core/test_taint_invariants.py @@ -0,0 +1,127 @@ +"""Reachable-set & operator-closure invariants for the taint algebra. + +These tests pin the linchpin invariant from the 2026-05-31 taint-combination +audit (see docs/concepts/taint-algebra.md): the only states reachable in the +live pipeline are {INTEGRAL, ASSURED, GUARDED, EXTERNAL_RAW, UNKNOWN_RAW}, the +trio {MIXED_RAW, UNKNOWN_GUARDED, UNKNOWN_ASSURED} is never produced, and +least_trusted is closed over the reachable set. They make the invariant ENFORCED +rather than incidental. +""" + +from __future__ import annotations + +import itertools +from pathlib import Path + +from wardline.core.run import run_scan +from wardline.core.taints import TRUST_RANK, TaintState, least_trusted, taint_join + +# The states any source can introduce into the live pipeline (audit linchpin). +REACHABLE: frozenset[TaintState] = frozenset( + { + TaintState.INTEGRAL, + TaintState.ASSURED, + TaintState.GUARDED, + TaintState.EXTERNAL_RAW, + TaintState.UNKNOWN_RAW, + } +) +# The states that must NEVER be produced. +UNREACHABLE: frozenset[TaintState] = frozenset(TaintState) - REACHABLE + + +def test_unreachable_set_is_the_trio() -> None: + expected = frozenset( + { + TaintState.MIXED_RAW, + TaintState.UNKNOWN_GUARDED, + TaintState.UNKNOWN_ASSURED, + } + ) + assert expected == UNREACHABLE + + +def test_least_trusted_closed_over_reachable_set() -> None: + # For every ordered pair over the reachable set, least_trusted stays inside it. + for a, b in itertools.product(REACHABLE, repeat=2): + result = least_trusted(a, b) + assert result in REACHABLE, f"least_trusted({a}, {b}) = {result} escaped the reachable set" + # least_trusted always returns one of its inputs. + assert result in (a, b) + + +def test_least_trusted_rank_invariant_over_all_states() -> None: + # Over ALL 8 states, least_trusted never yields a MORE-trusted result than + # taint_join — the rank-meet is always at least as conservative as the + # provenance-clash join (the safety contrast the migrations relied on). + for a, b in itertools.product(TaintState, repeat=2): + assert TRUST_RANK[least_trusted(a, b)] <= TRUST_RANK[taint_join(a, b)] + + +# ── Pipeline-level invariant: a real end-to-end scan over a corpus exercising +# every decorator/seed shape must never surface a trio state in any taint map. ── + +_CORPUS = ( + "from wardline.decorators import external_boundary, trust_boundary, trusted\n" + "\n" + "@external_boundary\n" + "def read_raw(p):\n" + " return p\n" + "\n" + "@trust_boundary(to_level='GUARDED')\n" + "def guard(p):\n" + " if not p:\n" + " raise ValueError('bad')\n" + " return p\n" + "\n" + "@trust_boundary(to_level='ASSURED')\n" + "def validate(p):\n" + " if not p:\n" + " raise ValueError('bad')\n" + " return p\n" + "\n" + "@trusted\n" + "def produce_integral(p):\n" + " return validate(read_raw(p))\n" + "\n" + "@trusted(level='ASSURED')\n" + "def produce_assured(p):\n" + " return validate(read_raw(p))\n" + "\n" + "def undecorated(p):\n" + " a = validate(read_raw(p))\n" + " b = produce_integral(p)\n" + " if p:\n" + " x = a\n" + " else:\n" + " x = b\n" + " return guard(x)\n" + "\n" + "def merges(p):\n" + " parts = [validate(p), guard(p), read_raw(p)]\n" + " return ','.join(parts) + produce_integral(p)\n" +) + + +def test_no_unreachable_state_in_scan_output(tmp_path: Path) -> None: + proj = tmp_path / "proj" + proj.mkdir() + (proj / "svc.py").write_text(_CORPUS, encoding="utf-8") + result = run_scan(proj) + ctx = result.context + assert ctx is not None + + saw_some = False + for label, mapping in ( + ("project_taints", ctx.project_taints), + ("project_return_taints", ctx.project_return_taints), + ("function_return_taints", ctx.function_return_taints), + ): + for qualname, state in mapping.items(): + saw_some = True + assert state not in UNREACHABLE, ( + f"{label}[{qualname}] = {state} — an unreachable taint state " + f"surfaced in scan output (reachable-set invariant violated)" + ) + # Guard against the test silently passing on empty maps. + assert saw_some, "scan produced no taint entries — corpus did not exercise the engine" diff --git a/tests/unit/scanner/taint/test_propagation.py b/tests/unit/scanner/taint/test_propagation.py index aa932e56..e86f0ffe 100644 --- a/tests/unit/scanner/taint/test_propagation.py +++ b/tests/unit/scanner/taint/test_propagation.py @@ -1,9 +1,13 @@ from __future__ import annotations +import logging + +import wardline.scanner.taint.propagation as propagation from wardline.core.taints import TaintState as T from wardline.scanner.taint.propagation import ( DIAG_CONVERGENCE_BOUND, - DIAG_MONOTONICITY_VIOLATION, # noqa: F401 + DIAG_MONOTONICITY_VIOLATION, + _check_monotonicity_violation, compute_sccs, propagate_callgraph_taints, ) @@ -185,3 +189,91 @@ def test_empty_taint_map_returns_empty() -> None: resolved_counts={}, unresolved_counts={}, return_taint_map={}, ) assert refined == {} and prov == {} and diags == [] and it == {} + + +# ── Fault-injection: the two defensive arms that sound inputs never trigger. +# With the transfer functions correct the engine stays monotone and anchored +# entries never change, so these branches are unreachable by any natural corpus +# (audit "could-not-drive"). We reach them by crafting a transfer-function bug: +# the monotonicity comparison is exercised directly through its isolated helper, +# and both kernel arms via a monkeypatched _compute_scc_round seam. ── + + +def test_check_monotonicity_violation_helper_detects_trust_increase() -> None: + # The isolated comparison: a strict move toward MORE trust (lower rank) is a + # violation; equal or less-trusted is not. + assert _check_monotonicity_violation(old_taint=T.UNKNOWN_RAW, new_taint=T.INTEGRAL) is True + assert _check_monotonicity_violation(old_taint=T.ASSURED, new_taint=T.EXTERNAL_RAW) is False + assert _check_monotonicity_violation(old_taint=T.GUARDED, new_taint=T.GUARDED) is False + + +def test_monotonicity_violation_pins_and_diagnoses(monkeypatch) -> None: + # A buggy transfer round proposes moving a NON-anchored function toward MORE + # trust. The kernel must emit L3_MONOTONICITY_VIOLATION and pin the function + # at its old (safer, less-trusted) value rather than commit the upgrade. + calls = {"n": 0} + + def buggy_round(**_kwargs): + calls["n"] += 1 + if calls["n"] == 1: + # A is non-anchored at UNKNOWN_RAW; propose INTEGRAL (more trusted). + return ({"A": T.INTEGRAL}, {"A": "B"}) + return ({}, {}) + + monkeypatch.setattr(propagation, "_compute_scc_round", buggy_round) + edges = {"A": {"B"}, "B": set()} + tm = {"A": T.UNKNOWN_RAW, "B": T.GUARDED} + src = {"A": "fallback", "B": "anchored"} + refined, _prov, diags, _it = propagate_callgraph_taints( + edges=edges, taint_map=tm, taint_sources=src, + resolved_counts={"A": 1, "B": 0}, unresolved_counts={"A": 0, "B": 0}, + return_taint_map={"A": T.UNKNOWN_RAW, "B": T.GUARDED}, + ) + assert any(code == DIAG_MONOTONICITY_VIOLATION for code, _ in diags) + assert refined["A"] == T.UNKNOWN_RAW # pinned to the old safer value, NOT INTEGRAL + + +def test_post_assertion_anchored_drift_bails_to_unrefined(monkeypatch, caplog) -> None: + # A buggy round mutates an ANCHORED function's taint in-place. The + # post-fixed-point assertion must detect the drift and bail to the unrefined + # map with seed-only provenance (NO diagnostic — this arm only logs ERROR). + def buggy_round(**kwargs): + kwargs["current"]["B"] = T.MIXED_RAW # corrupt anchored B + return ({}, {}) + + monkeypatch.setattr(propagation, "_compute_scc_round", buggy_round) + edges = {"A": {"B"}, "B": set()} + tm = {"A": T.UNKNOWN_RAW, "B": T.GUARDED} + src = {"A": "fallback", "B": "anchored"} + with caplog.at_level(logging.ERROR, logger=propagation.__name__): + refined, prov, _diags, _it = propagate_callgraph_taints( + edges=edges, taint_map=tm, taint_sources=src, + resolved_counts={"A": 1, "B": 0}, unresolved_counts={"A": 0, "B": 0}, + return_taint_map={"A": T.UNKNOWN_RAW, "B": T.GUARDED}, + ) + assert refined == tm # bailed to the unrefined L1 map + assert prov["A"].source == "fallback" # seed-only provenance + assert any("post-assertion FAILED" in r.message for r in caplog.records) + + +def test_post_assertion_module_default_upgrade_bails(monkeypatch, caplog) -> None: + # A buggy round upgrades a MODULE_DEFAULT function toward MORE trust by direct + # mutation (bypassing the monotonicity commit guard). The module_default + # post-assertion must detect the upgrade and bail to the unrefined map. + def buggy_round(**kwargs): + kwargs["current"]["A"] = T.INTEGRAL # A is module_default, started UNKNOWN_RAW + return ({}, {}) + + monkeypatch.setattr(propagation, "_compute_scc_round", buggy_round) + edges = {"A": {"B"}, "B": set()} + tm = {"A": T.UNKNOWN_RAW, "B": T.GUARDED} + src = {"A": "module_default", "B": "anchored"} + with caplog.at_level(logging.ERROR, logger=propagation.__name__): + refined, prov, _diags, _it = propagate_callgraph_taints( + edges=edges, taint_map=tm, taint_sources=src, + resolved_counts={"A": 1, "B": 0}, unresolved_counts={"A": 0, "B": 0}, + return_taint_map={"A": T.UNKNOWN_RAW, "B": T.GUARDED}, + ) + assert refined == tm # bailed to the unrefined L1 map + assert prov["A"].source == "module_default" # seed-only provenance + assert any("post-assertion FAILED" in r.message for r in caplog.records) diff --git a/tests/unit/scanner/taint/test_stdlib_taint.py b/tests/unit/scanner/taint/test_stdlib_taint.py index 52d16d9e..49bfb2fc 100644 --- a/tests/unit/scanner/taint/test_stdlib_taint.py +++ b/tests/unit/scanner/taint/test_stdlib_taint.py @@ -105,3 +105,46 @@ def test_missing_or_empty_rationale_raises(rationale: str | None) -> None: def test_empty_entries_is_valid_empty_table() -> None: # A degenerate-but-valid table: no curated fallbacks, contributes nothing. assert dict(_build_table({"version": STDLIB_TAINT_VERSION, "entries": []})) == {} + + +# ── F5: the stdlib parser rejects states outside the legal stdlib return set. +# A stdlib call cannot produce INTEGRAL (your own fully-trusted data), and the +# unreachable trio {MIXED_RAW, UNKNOWN_GUARDED, UNKNOWN_ASSURED} must never enter +# the pipeline (reachable-set invariant; taint-combination audit F5). ── + + +@pytest.mark.parametrize( + "state", ["MIXED_RAW", "UNKNOWN_GUARDED", "UNKNOWN_ASSURED", "INTEGRAL"] +) +def test_illegal_stdlib_return_tier_raises(state: str) -> None: + # MIXED_RAW etc. ARE canonical TaintState strings, so they pass TaintState(), + # but they are not legal stdlib return tiers — the guard must reject them. + with pytest.raises(ValueError, match="legal stdlib return tier"): + _build_table( + { + "version": STDLIB_TAINT_VERSION, + "entries": [ + {"package": "p", "function": "f", "returns_taint": state, "rationale": "x"} + ], + } + ) + + +@pytest.mark.parametrize( + "state", ["ASSURED", "GUARDED", "EXTERNAL_RAW", "UNKNOWN_RAW"] +) +def test_legal_stdlib_return_tiers_accepted(state: str) -> None: + table = _build_table( + { + "version": STDLIB_TAINT_VERSION, + "entries": [ + {"package": "p", "function": "f", "returns_taint": state, "rationale": "x"} + ], + } + ) + assert table[("p", "f")].taint == TaintState(state) + + +def test_shipped_stdlib_table_still_loads() -> None: + # The shipped yaml uses only legal tiers; the F5 guard must not break it. + assert len(load_stdlib_taint()) > 0 diff --git a/tests/unit/scanner/taint/test_summary_cache.py b/tests/unit/scanner/taint/test_summary_cache.py index 1226d6dd..dce91978 100644 --- a/tests/unit/scanner/taint/test_summary_cache.py +++ b/tests/unit/scanner/taint/test_summary_cache.py @@ -4,7 +4,11 @@ from wardline.core.taints import TaintState as T from wardline.scanner.taint.summary import SUMMARY_SCHEMA_VERSION, FunctionSummary -from wardline.scanner.taint.summary_cache import SummaryCache +from wardline.scanner.taint.summary_cache import ( + SummaryCache, + _deserialise_summary, + _serialise_summary, +) _KEY = "a" * 64 _KEY2 = "b" * 64 @@ -126,3 +130,77 @@ def test_load_requires_cache_dir() -> None: def test_in_memory_cache_has_no_cache_dir() -> None: assert SummaryCache().cache_dir is None + + +# ── F5: the disk-persistent cache deserialiser rejects the unreachable trio +# {MIXED_RAW, UNKNOWN_GUARDED, UNKNOWN_ASSURED} — valid TaintState strings that +# the malformed-file drop guard would otherwise let through — but STILL accepts +# the full reachable set INCLUDING INTEGRAL (a @trusted function caches INTEGRAL). +# Taint-combination audit F5. ── + + +def _summary_dict(body: str, ret: str) -> dict[str, object]: + return { + "fqn": "m.f", + "body_taint": body, + "return_taint": ret, + "taint_source": "anchored", + "unresolved_calls": 0, + "schema_version": SUMMARY_SCHEMA_VERSION, + "cache_key": _KEY, + } + + +@pytest.mark.parametrize("state", ["MIXED_RAW", "UNKNOWN_GUARDED", "UNKNOWN_ASSURED"]) +def test_deserialise_rejects_unreachable_body_taint(state: str) -> None: + with pytest.raises(ValueError, match="unreachable taint state"): + _deserialise_summary(_summary_dict(state, "INTEGRAL")) + + +@pytest.mark.parametrize("state", ["MIXED_RAW", "UNKNOWN_GUARDED", "UNKNOWN_ASSURED"]) +def test_deserialise_rejects_unreachable_return_taint(state: str) -> None: + with pytest.raises(ValueError, match="unreachable taint state"): + _deserialise_summary(_summary_dict("INTEGRAL", state)) + + +def test_deserialise_integral_roundtrips() -> None: + # MANDATORY regression guard: a @trusted function produces INTEGRAL, so the + # cache MUST round-trip INTEGRAL body/return taint. Rejecting it here would + # silently break caching of trusted functions. + s = FunctionSummary( + fqn="m.f", body_taint=T.INTEGRAL, return_taint=T.INTEGRAL, + taint_source="anchored", unresolved_calls=0, + schema_version=SUMMARY_SCHEMA_VERSION, cache_key=_KEY, + ) + out = _deserialise_summary(_serialise_summary(s)) + assert out == s + assert out.body_taint == T.INTEGRAL and out.return_taint == T.INTEGRAL + + +def test_integral_survives_full_save_load_cycle(tmp_path) -> None: + # End-to-end: a poisoned-but-valid trio state would be dropped by load(), + # but a legitimate INTEGRAL summary must survive the disk round-trip. + c = SummaryCache(cache_dir=tmp_path) + s = FunctionSummary( + fqn="m.f", body_taint=T.INTEGRAL, return_taint=T.INTEGRAL, + taint_source="anchored", unresolved_calls=0, + schema_version=SUMMARY_SCHEMA_VERSION, cache_key=_KEY, + ) + c.put(_KEY, (s,)) + c.save() + c2 = SummaryCache(cache_dir=tmp_path) + c2.load() + assert c2.get(_KEY) == (s,) + + +def test_load_drops_poisoned_trio_cache_file(tmp_path, caplog) -> None: + # A hand-edited/corrupted cache file holding a valid-but-unreachable state is + # dropped (cold-cache fallback), not injected — load() catches the ValueError. + import json + + (tmp_path / f"{_KEY}.json").write_text( + json.dumps([_summary_dict("MIXED_RAW", "MIXED_RAW")]), encoding="utf-8" + ) + c = SummaryCache(cache_dir=tmp_path) + c.load() # must not raise + assert len(c) == 0 diff --git a/tests/unit/scanner/taint/test_variable_level.py b/tests/unit/scanner/taint/test_variable_level.py index 700688eb..672e5391 100644 --- a/tests/unit/scanner/taint/test_variable_level.py +++ b/tests/unit/scanner/taint/test_variable_level.py @@ -826,8 +826,9 @@ def test_join_via_local_var_does_not_demote_validated_data() -> None: # element, which it never is. least_trusted(INTEGRAL, ASSURED) = ASSURED — the # benign separator does not manufacture a MIXED_RAW provenance clash, so a # validated producer stays CLEAN (no false positive). The BinOp/List/Dict/IfExp/ - # BoolOp/.get combiners use the SAME least_trusted rule (see PART F); only - # control-flow MERGES (if/else, loops, match arms) keep taint_join. + # BoolOp/.get combiners use the SAME least_trusted rule (see PART F); and + # control-flow MERGES (if/else, loops, match arms) ALSO use least_trusted + # (migration wardline-4d9f840c24) — no combiner uses taint_join. out = _vt( "def f(p):\n v = validate(p)\n x = ','.join([v])\n", function_taint=T.ASSURED, taint_map={"validate": T.ASSURED}, @@ -839,8 +840,8 @@ def test_join_via_local_var_does_not_demote_validated_data() -> None: # NOT the provenance-clash taint_join — so a benign literal/clean operand does not # manufacture a MIXED_RAW false positive on validated data, while raw still # propagates at its precise rank. Mirrors the f-string/.format/.join precedent. -# Control-flow MERGES (if/else, loop back-edge, match arms) deliberately keep -# taint_join and are covered by the merge tests above. ── +# Control-flow MERGES (if/else, loop back-edge, match arms) ALSO use least_trusted +# (migration wardline-4d9f840c24) and are covered by the merge tests above. ── _VALIDATE_TM = {"validate": T.ASSURED} From 705d6286fc57e1057684fa8ee81ef45971f9876d Mon Sep 17 00:00:00 2001 From: John Morrissey Date: Sun, 31 May 2026 17:54:59 +1000 Subject: [PATCH 3/4] chore: stop tracking local .mcp.json (machine-specific MCP config) .mcp.json holds a machine-specific MCP server path; add it to .gitignore and untrack it so local edits stay local. File remains on disk. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 3 ++- .mcp.json | 10 ---------- 2 files changed, 2 insertions(+), 11 deletions(-) delete mode 100644 .mcp.json diff --git a/.gitignore b/.gitignore index 4424e454..04f37103 100644 --- a/.gitignore +++ b/.gitignore @@ -23,4 +23,5 @@ findings.jsonl # Filigree-generated agent operator playbooks (local tooling, not project docs) CLAUDE.md -AGENTS.md \ No newline at end of file +AGENTS.md +.mcp.json diff --git a/.mcp.json b/.mcp.json deleted file mode 100644 index 91d793bb..00000000 --- a/.mcp.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "mcpServers": { - "filigree": { - "type": "stdio", - "command": "/home/john/.local/bin/filigree-mcp", - "args": [], - "env": {} - } - } -} \ No newline at end of file From 2dd075ea19fe9874155f61613989db5394b15a6a Mon Sep 17 00:00:00 2001 From: John Morrissey Date: Sun, 31 May 2026 17:56:15 +1000 Subject: [PATCH 4/4] chore(release): 0.2.1 Hardening + docs release (taint-combination engine first-class hardening, epic wardline-2b138b3662). No user-facing feature, no breaking change. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 5 ++++- src/wardline/_version.py | 2 +- tests/unit/test_package.py | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ec3033cd..50980e0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.2.1] - 2026-05-31 + ### Added - **Taint algebra concepts page + lattice-retention ADR** — a new @@ -165,6 +167,7 @@ for Python — enterprise-class trust-boundary analysis at small-team weight. - **Packaging** — MIT-licensed; optional extras `scanner` (config + CLI) and `loom` (HTTP integrations). -[Unreleased]: https://github.com/foundryside-dev/wardline/compare/v0.2.0...HEAD +[Unreleased]: https://github.com/foundryside-dev/wardline/compare/v0.2.1...HEAD +[0.2.1]: https://github.com/foundryside-dev/wardline/compare/v0.2.0...v0.2.1 [0.2.0]: https://github.com/foundryside-dev/wardline/compare/v0.1.0...v0.2.0 [0.1.0]: https://github.com/foundryside-dev/wardline/releases/tag/v0.1.0 diff --git a/src/wardline/_version.py b/src/wardline/_version.py index d3ec452c..3ced3581 100644 --- a/src/wardline/_version.py +++ b/src/wardline/_version.py @@ -1 +1 @@ -__version__ = "0.2.0" +__version__ = "0.2.1" diff --git a/tests/unit/test_package.py b/tests/unit/test_package.py index 046ed9f7..36977970 100644 --- a/tests/unit/test_package.py +++ b/tests/unit/test_package.py @@ -3,4 +3,4 @@ def test_version_is_exported() -> None: assert isinstance(wardline.__version__, str) - assert wardline.__version__.startswith("0.2.0") + assert wardline.__version__.startswith("0.2.1")