From d29d21f6e3789571a2963df67cfa30444bdfb223 Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Wed, 9 Sep 2026 05:37:01 -0400 Subject: [PATCH 01/65] =?UTF-8?q?docs(spec):=20leg=204=20=E2=80=94=20taint?= =?UTF-8?q?()=20as=20a=20refutation=20instrument?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The last leg of the 2.0 query facade. taint() is the one verb in epic .github#55's release plan that no language has; this locks its design across Python, Java and TypeScript. Nine decisions, four of which reject a concept the earlier §5.4 sketch would have added: no framework catalogue (sources stay the caller's), no verdict enum (BoundedResult's `complete` plus a stored `refuted` set carries all three cases), no Selector type (the existing (name, within) pair, made plural), and no fourth copy of the path Cypher — the shared skeleton lifts into commons/graphs.py instead. The framing that drove the rest: proving a flow already works, because locate() addresses the sink, paths_between returns the witness and .weakest caps the claim. Refuting one has no answer today, and in triage a wrong refutation closes a live alert. So taint() is designed as a refutation instrument, which is why depth is unbounded by default and why the sanitizer predicate must live inside the query rather than filter its results. Two findings from the probe run against Neo4j 5.26.30, both recorded in §7 with the plans that produced them: - A relationship-property predicate inlines into allShortestPaths, so the search returns the shortest *unsanitized* path instead of filtering the shortest one and reporting nothing. No exhaustive fallback appears, including on the zero-row case. This is what makes the server-side cut correct rather than merely faster. - Only the DDG relationship carries `var` — the projection writes PARAM_IN and PARAM_OUT with no properties at all — so a naive `r.var <> $v` predicate is null on every cross-call hop and falsely refutes every interprocedural flow, silently. coalesce() is mandatory, and the tripwire for it is the leg's first test. Two issues found while designing this and filed separately, neither gating the leg: codeanalyzer-python#195 and python-sdk#381. --- docs/design/specs/2026-09-09-leg-4-taint.md | 321 ++++++++++++++++++++ 1 file changed, 321 insertions(+) create mode 100644 docs/design/specs/2026-09-09-leg-4-taint.md diff --git a/docs/design/specs/2026-09-09-leg-4-taint.md b/docs/design/specs/2026-09-09-leg-4-taint.md new file mode 100644 index 00000000..a904e2fb --- /dev/null +++ b/docs/design/specs/2026-09-09-leg-4-taint.md @@ -0,0 +1,321 @@ +# Leg 4 — `taint()`, the query sweep's policy layer + +Status: design locked 2026-09-09. Epic `codellm-devkit/.github#55`, leg 4 of its release plan. +Ships `2.0.0` final. + +Leg 4 is the last leg. Legs 1/1.5/1.6 (Python), 2.5a/2.5b (TypeScript) and 3a/3b (Java) delivered +the query surface on all three languages; `taint()` is the one verb in the epic's plan that no +language has, and this spec is what it becomes. + +--- + +## 1. Contract-impact triage + +**Does this change the schema v2 output?** No. `taint()` reads the SDG the analyzers already emit — +the five relationship types `{P}_DDG`, `{P}_CDG`, `{P}_PARAM_IN`, `{P}_PARAM_OUT`, `{P}_SUMMARY` — +and derives nothing the graph does not carry. No new node kind, edge kind, field, level or id shape. + +**Which repos are touched?** + +| Change type | Analyzers | SDKs | Docs | +| --- | --- | --- | --- | +| New facade surface (one accessor, three languages) | — | `python-sdk` | `docs/agent-api-reference.md`, `docs/skills/using-cldk/SKILL.md`, `CLAUDE.md` | + +One repo. Two issues found while designing this leg are filed and tracked separately, and neither +gates it: `codeanalyzer-python#195` (schema declares a property the projection never writes) and +`python-sdk#381` (Python's value traversals ignore the scope prefix). + +## 2. Where the surface stands (measured 2026-09-09, at `af133c8` = `v2.0.0-rc.4`) + +Every flow verb exists on all three facades. `taint` exists nowhere: + +``` +locate get_ddg get_cfg get_cdg reaches paths_between +slice_backward slice_forward backward_cone +flows_to_call flows_to_argument → python java typescript +taint → — — — +``` + +Four facts about the existing implementations shape every decision below. + +**The path query is `allShortestPaths`, not a quantified path pattern.** `_PATHS` records why: a +variable-length pattern enumerates *trails*, the shape that never terminated (killed at 600 s); +`allShortestPaths` is a bidirectional BFS and answers the pathological cases in 0.08 s on the +440,270-node forward cone. A consequence worth stating early: `taint()` inherits no Neo4j 5.9 floor, +unlike `reaches` and `backward_cone`, which need quantified paths and call +`_require_quantified_paths`. + +**An interior `all()` predicate is already the idiom, and it is nearly free.** `_CALL_PATHS` carries +`all(n IN nodes(p) WHERE n:PyCallable)` with the note that Neo4j inlines an `all()` node predicate +into the shortest-path search itself, so the route found is the shortest *satisfying* one — measured +at 0.054 s against 0.046 s. Java re-measured the same shape on ThingsBoard's largest `formal_in` +backward cone: **9.6 / 9.9 / 9.9 ms with the predicate against 9.2 / 9.5 / 9.1 without, identical +rows.** + +**Only the DDG relationship carries `var`.** `codeanalyzer-python` 1.5.0's projection writes +`{P}_PARAM_IN` and `{P}_PARAM_OUT` with no properties at all (`project.py:260-270`), and +`{P}_CDG`/`{P}_SUMMARY` likewise. So `r.var` is null on four of the five relationship types, +**including both interprocedural ones**. §7 turns this into the leg's first test. + +**The Cypher is triplicated; the Python-side helpers are not.** `hop_sort_key` and `flow_path` +already live in `commons/graphs.py`. `_PATHS`, `_VIA_CASE` and `_PATH_ORDER` are each written three +times, and they differ in more than labels: Python has no scope predicate, Java scopes endpoints and +interior, TypeScript scopes the interior via `_scoped()`; Python projects `n.var`, TypeScript also +`n.of`, Java neither. + +## 3. Decisions + +Each was taken against a stated alternative. Four of the nine (T2, T3, T4, T8) reject a new concept +the §5.4 sketch or the obvious reading would have introduced — a framework catalogue, a verdict enum, +a `Selector` type, a third copy of the path Cypher. The rest add exactly one thing each and say what +it buys. + +| # | Decision | Why | +| --- | --- | --- | +| **T1** | **Design against alert triage — prove *or refute* a scanner's sink.** The sink arrives from outside as a position; the sources are a caller-supplied set. | It is the use case the evidence base measures (513 alerts quarantined for want of a `file:line → enclosing callable` lookup), and it is the one whose failure mode is asymmetric: a wrong refutation closes a live alert. Proving needs no new verb — `locate()` addresses the sink, `paths_between` returns the witness, `.weakest` caps the claim. **Refuting is what has no answer today**, because a caller who gets nothing back cannot distinguish "no flow exists" from "the flow left the resolved graph". `taint()` is a refutation instrument. | +| **T2** | **The SDK ships no vocabulary.** Sources, sinks and sanitizers are the caller's to supply; no framework catalogue, no entrypoint-derived default. | Already ruled in leg 1.5 §9 ("Sources, sinks and sanitizers remain leg 4, and remain the caller's to supply"). A per-framework catalogue across three languages is a taxonomy that rots, and this leg's whole contribution is mechanism, not policy. | +| **T3** | **A verdict is a value, not an enum.** `taint()` returns empty when it finds nothing; the existing `BoundedResult` protocol plus a `refuted` set carries the three cases. | `BoundedResult` (`commons/results.py:346`) is the one completeness protocol every bounded verb already speaks, under E5 "a bound is never silent", and it guarantees the payload behaves as its list so `if not result:` is honest. A third verdict type would be a new concept for a distinction two existing fields make. | +| **T4** | **Address a value as `(name, within)`, made plural.** No `Selector` type, contrary to the §5.4 sketch. | It is exactly the convention `paths_between` and `flows_to_argument` already use, so the strictness (`AmbiguousName`, `SelectorNotInGraph`) is inherited unchanged and E6/E7 are satisfied by construction. | +| **T5** | **Two sanitizer kinds, distinguished by shape.** A `(name, within)` pair cuts a **variable**; a bare `str` cuts a **callable** on the path. | They are different mechanisms wearing one word. A *validating guard* never appears on the data path — the worked example runs `@formal_in:1 → 19:8 → 22:8/actual_in:0` and never touches the guard at `20:11` — so only a variable-granular cut severs it. A *transforming* sanitizer (`html.escape`, `shlex.quote`) does sit on the path and is naturally named as a callable. Supporting only one silently fails the other. | +| **T6** | **Shape must agree with resolution, or raise.** A bare name resolving to a variable, or a pair whose name resolves to a callable, is `SelectorNotInGraph`. | T5's one signature carries two semantics, so the accident of omitting `within` must be loud. This is the mitigation that makes T5 safe, using machinery that already exists. | +| **T7** | **Server-side: one query, the sanitizer predicate *inside* it.** Not client-side composition over `backward_cone` + `paths_between`. | Correctness, not round trips. If sanitized paths are filtered out of a result that was already capped at `max_paths`, then a source whose twenty shortest paths are all sanitized and whose twenty-first is not comes back empty — **a false refutation**, the one output that must never be wrong. The cut has to happen before the bound, and only the server can do that. Measured in §7: the predicate inlines, so the search finds the shortest *unsanitized* path rather than filtering the shortest one. | +| **T8** | **Lift the shared skeleton into `commons/graphs.py`** — one `sdg_path_query()` with two per-language injection points — and adopt Java's id-derived owner recovery so the projection is near-uniform. | Three copies of `_PATH_ORDER` means three copies of its U+0001-separator argument (string comparison agrees with field-by-field comparison only when the separator sorts below every character a field can hold, and `|` at 0x7C sorts above every lowercase letter), and the null-safety finding in §2 is the same species of subtlety arriving now: exactly the thing that gets fixed in one copy and not the other two. Java already recovers a vertex's owner from `ref.partition("@")[0]` "because it is cheaper than a `J_HAS_BODY_NODE` hop and because it is what makes the two backends describe a vertex identically", and states that "the graph statement need not project a callable at all". | +| **T9** | **The cap is per-pair, not global.** `WITH a, b, collect(p)[0..$cap]`, not the flat `LIMIT $cap` `_PATHS` uses. | With one sink and forty sources a flat cap lets one prolific pair starve the other thirty-nine, and in triage the per-source witness *is* the answer. | + +## 4. The query + +``` +MATCH (a:{node_label}) WHERE a.id IN $srcs AND {scope(a)} +MATCH (b:{node_label}) WHERE b.id IN $dsts AND {scope(b)} +MATCH p = allShortestPaths((a)-[:{rels}*1..{depth}]->(b)) +WHERE all(n IN nodes(p) WHERE {scope(n)}) + AND all(n IN nodes(p) WHERE NOT any(q IN $cut_callables WHERE n.id STARTS WITH q)) + AND all(r IN relationships(p) WHERE NOT coalesce(r.var, '') IN $cut_vars) +WITH a, b, p, {PATH_ORDER} AS key ORDER BY length(p), key +WITH a, b, collect(p)[0..$cap] AS ps +UNWIND ps AS p +RETURN a.id AS src, b.id AS dst, {projection} AS ns, + [r IN relationships(p) | {via: type(r), var: r.var, prov: r.prov}] AS rs +``` + +Four things about it are load-bearing. + +**`coalesce(r.var, '')` is mandatory, not defensive.** Without it the predicate is three-valued +logic: `null <> $var` is `null`, `all()` over it is `null`, and the path is excluded. Since `var` is +null on both interprocedural relationship types (§2), the naive form **falsely refutes every +cross-call flow**, silently, looking exactly like a clean refutation. + +**The callable-granular sanitizer is a prefix cut, and it is free.** A body-node id *is* +`@` (`codeanalyzer-python` `project.py:138-147`), so cutting a +callable is `n.id STARTS WITH ` — the same shape already proven to inline. T5's second +half needs no new mechanism. + +**Scoping follows Java's template, not Python's.** Every bound variable carries its own predicate, +including the redundant one on the far endpoint, per Java's rule that "the audit judges **per bound +variable**, and a variable whose only excuse is a predicate over an unnamed path reads to it as +unscoped." Measured at ~4% (§2). + +**`$cap` is `max_paths + 1` per pair**, so one extra row reports truncation without a second +counting traversal — `_PATHS`'s existing device, moved inside the per-pair `collect`. + +The shared skeleton: + +```python +def sdg_path_query(P: str, *, node_label: str, + scope: Callable[[str], str], projection: str) -> str: ... +``` + +| shared | per-language | +| --- | --- | +| `allShortestPaths`, the rel alternation from `sdg_rel_pattern(P)`, `PATH_ORDER`/`VIA_CASE` from `via_table(P)`, the per-pair cap | `node_label` — `PyBodyNode` / `JBodyNode` / `CanNode:TSBodyNode` | +| the relationship projection, and the predicate vocabulary: per-bound-variable scope, prefix node cut, null-safe var cut | `scope()` — `STARTS WITH $prefix` vs TypeScript's namespace-aware `_scoped()` | +| | `projection` — Python adds `n.var`, TypeScript also `n.of`, Java neither | + +## 5. What a caller sees + +```python +def taint(self, + sources: Sequence[Tuple[str, str]], + sinks: Sequence[Tuple[str, str]], + sanitizers: Sequence[Tuple[str, str] | str] = (), + *, depth: int | None = None, + max_paths: int = DEFAULT_MAX_PATHS) -> TaintResult: ... +``` + +```python +class TaintResult(FlowPaths): # paths, complete, list behaviour, .weakest per path + refuted: list[Tuple[str, str]] # (source, sink) pairs the traversal exhausted with no path + roots: list[SliceNode] # what each selector matched + resolved: str # via slice_resolved() + unresolved: list[Diagnostic] # the frontier ledger, tagged with the pair each affects +``` + +**`depth` is unbounded by default**, following `reaches` / `flows_to_*` / `paths_between` rather than +the slices' `DEFAULT_DEPTH`. This is forced, not stylistic: at a bounded depth `taint()` could +essentially never report `refuted`, so a bounded default would make refutation unreachable — and +leg 1.5 already ruled that "a bounded boolean is a wrong answer, not a small one." + +**The verdict is per pair.** A single `complete` cannot serve m×n pairs — pair A finding flows and +truncating would downgrade pair B's sound refutation — so `refuted` carries the verdict and +`complete` keeps its `FlowPaths` meaning exactly: + +| the pair | verdict | +| --- | --- | +| has paths in `paths` | **flows**, and `.weakest` caps how strongly it may be stated | +| appears in `refuted` | **refuted** — the traversal was exhaustive | +| appears in neither | **inconclusive** — `unresolved` says why | + +`refuted` is *stored*, not derived. It is computable as +`all_pairs − pairs_with_paths − pairs_with_unresolved`, and that is exactly why it should not be: the +load-bearing answer must not be a subtraction the caller can get wrong. A refutation is earned, not +inferred. + +**`weakest` is per-language by construction.** DDG provenance has three tiers in Python +(`reaching-defs` / `points-to` / `ssa`), two in Java, one in TypeScript — so every TypeScript flow's +`weakest` is `reaching-defs`. That is a fact to document, not a gap to close, and `prov_rank` already +ranks an unrecognised provenance weakest of all. + +## 6. Errors and degradation + +Everything reuses existing machinery; no new exception type. + +| condition | behaviour | +| --- | --- | +| a name matches more than one thing | `AmbiguousName` | +| a name matches nothing | `SelectorNotInGraph` | +| a sanitizer's shape disagrees with its resolution (T6) | `SelectorNotInGraph` | +| `depth` not a positive int, `max_paths` below 1 | `ValueError`, via `check_depth` / `check_max_paths`, **before** any round trip | +| `sources` or `sinks` empty | refused, not answered `[]` — `cone_sinks` already "refus[es] the two ways of naming nothing" | +| a pair where source and sink are the same position | **skipped with a diagnostic**, not raised | +| graph below L4 | `_require_dataflow()` — `PARAM_IN`/`PARAM_OUT` are L4 edges | +| Java analysis with a disconnected port lattice | `_require_connected_ports("taint")` — `PORTS_DISCONNECTED` | + +The last two are the honest-degradation cases and both already exist. `PORTS_DISCONNECTED` is a +**data-measured** gate, never a version literal: it fires when `formal_in` vertices have out-degree +zero over all five relationship types, which is true of `codeanalyzer-java` before 3.0.3 and under +`--l3-engine wala`. rc.4 pins 3.1.1 and floors graphs at 3.1.1, so a conforming Java graph answers; +a local run with the wala engine still degrades, and says so. + +The same-position skip is a **deliberate divergence** from `paths_between`, which raises. Raising on +one degenerate pair would discard a forty-pair batch, and in triage a caller assembling sources +programmatically will hit that case by accident. + +## 7. Verification + +### Probe already run (2026-09-09, Neo4j 5.26.30, disposable container) + +T7 rested on an assumption — that Neo4j inlines a **relationship-property** predicate into +`allShortestPaths` the way it inlines a node-label one. Measured rather than argued, on a synthetic +graph carrying the five real SDG relationship types: + +| test | result | +| --- | --- | +| is the relationship predicate inlined? | **yes** — appears inside `ShortestPath`'s `Details`, no trailing `Filter` | +| does the search find the shortest *satisfying* path? | **yes** — sanitized 2-hop path cut, unsanitized 3-hop path returned | +| compound: scope + node cut + var cut, five rel types, `*..5`? | **all three inline into one operator** | +| refutation (zero rows) — does an exhaustive fallback appear? | **no** — no `ExhaustiveShortestPath`, no `AntiConditionalApply` | +| naive `r.var <> $v` on a flow whose middle hop is `PARAM_IN`? | **empty** — false refutation | +| null-safe `coalesce(r.var,'') <> $v`, same flow? | **found** | + +The finding this closes: the sanitized-shortest / unsanitized-longer case, which is the whole reason +T7 beat client-side filtering, works *because* the predicate is inlined. The caveat it introduces: +this holds for **inlinable predicate forms only**. A sanitizer expressed as a subquery or an +aggregate reintroduces the exhaustive fallback, which is trail enumeration — the shape already +killed at 600 s on this corpus. The spec pins the forms proven here. + +**Not yet measured:** wall-clock on a large graph. The plan is the same BFS that measured 0.08 s on +the 440,270-node cone and the inlined predicates prune rather than expand, so the expectation is +that it is cheap — but expectation is not measurement, and §10 requires the numbers. + +### Tests, in priority order + +1. **Null-safety tripwire.** An interprocedural fixture flow whose middle hop is `PARAM_IN`; fails if + the predicate ever loses its `coalesce`. This is the test that separates a found flow from a + silent false refutation. +2. **Cut before bound.** Sanitized shortest path, unsanitized longer path, `max_paths=1`; the longer + path must come back. This is T7 as an assertion. +3. **Per-pair starvation.** One prolific pair and one sparse pair in the same call; the sparse pair's + witness must survive. +4. **Refuted vs inconclusive.** A pair with no route reports `refuted`; a pair whose only route + crosses an unresolved dispatch reports neither, and its diagnostic names the pair. +5. **Both sanitizer kinds (T5).** A validating guard cut by variable, a transforming sanitizer cut by + callable, and the T6 shape/resolution disagreement raising. +6. **Self-loop regression.** The `#349` class — a relationship bound twice in one `MATCH` silently + drops self-loops; the Java graph has 978. +7. **Byte-identical lift.** The generated Cypher equals each current constant, asserted *before* the + projection change lands, so the refactor cannot be what reddens a live suite. +8. **Cross-language parity and API stability.** The backend-contract test covers `taint` on all three; + no existing accessor changes name, signature or return type. + +### The corpus rule + +*A corpus that cannot exercise a case makes a green suite meaningless.* This project has paid for +that lesson three times — Java `get_test_methods()` "agreed across backends" only because daytrader8 +has zero `@Test` methods; TypeScript's `implements_types` subtraction was a no-op because 0 of 207 +classes write the property; `$anon$N` collisions were invisible until ThingsBoard. **Before writing +the tests above, confirm the reference graphs actually contain a validating guard and a transforming +sanitizer.** If they do not, the fixture is part of the work, not a precondition of it. + +## 8. Release plan + +`taint()` is additive, so it ships in one PR per the epic's plan, on `release/2.0`, as the last leg +before `2.0.0` final. Two steps inside it, in this order, because T8 touches three shipped backends +mid-rc: + +1. **Lift with no behaviour change.** `sdg_path_query()` in `commons/graphs.py`; each backend's + `_PATHS` becomes a call to it; test 7 asserts byte-identical output. Nothing else moves. +2. **`taint()` plus the projection change.** The new accessor on all three facades and both backend + families, and Java's id-derived owner recovery adopted by Python and TypeScript, each with its own + before/after measurement. + +No analyzer release is required, and no version pin moves: rc.4's pins (`codeanalyzer-java` 3.1.1, +`codeanalyzer-python` 1.5.0, `codeanalyzer-typescript` 1.5.2) already carry everything `taint()` +reads. + +## 9. Out of scope + +- **A policy vocabulary.** No framework catalogue, no entrypoint-derived source defaults (T2). +- **Deriving edges no analyzer emitted.** The field-alias join remains an upstream question; taint + reads the SDG as emitted. +- **The two issues this design found.** `codeanalyzer-python#195` and `python-sdk#381` are tracked on + their own and neither gates this leg. `#381` in particular is *not* a prerequisite: taint writes + its own scope predicates per T8/§4 regardless of what the existing Python templates do. +- **Sanitizer inference.** Whether a callable *is* a sanitizer is the caller's judgement, always. +- **The remaining §10 items of the facade spec** — the twelve named field-failure tests — which + belong to the leg but are enumerated there, not re-listed here. + +## 10. Definition of done + +- `taint()` on all three facades and both backend families, with the signature in §5 and no other + public accessor changing name, signature or return type. +- `sdg_path_query()` in `commons/graphs.py`; no `_PATHS`, `_VIA_CASE` or `_PATH_ORDER` constant + remains duplicated across the three Neo4j backends. +- All eight tests in §7 pass, against the live graphs and not only fixtures. +- The null-safety tripwire (test 1) fails when `coalesce` is removed. Verified by removing it, not by + assuming. +- Wall-clock for `taint()` recorded on the largest corpus in the verification set, for a found flow, + a refutation, and a forty-source batch. A refutation is the common case in triage and must be + measured as its own number, not inferred from the found case. +- `PORTS_DISCONNECTED` and `_require_dataflow` degradation paths each exercised by a test that asserts + the message names the accessor. +- `docs/agent-api-reference.md`, `docs/skills/using-cldk/SKILL.md` and `CLAUDE.md`'s per-language + notes updated in the same change. +- `CHANGELOG.md` carries the addition under a heading matching the release tag literally. + +## 11. Caveats and known risks + +- **The inlining result is a property of Neo4j's planner, not of Cypher.** It was measured on 5.26.30. + A future server could plan a predicated `allShortestPaths` differently, and the failure mode is a + performance collapse into trail enumeration rather than a wrong answer. Test 2 catches the + correctness half; nothing pins the plan, and a plan assertion would be brittle in its own way. +- **Wall-clock on a large graph is still unmeasured.** m×n BFS runs in one statement is the shape most + likely to surprise, and a forty-source batch has no measurement behind it yet. +- **`refuted` is only as sound as `unresolved` is complete.** If a dispatch the graph could not follow + is *not* reported as a diagnostic, the pair lands in `refuted` and the refutation is wrong. The + ledger's completeness is inherited from `backward_cone` and has never been audited against a known + set of unresolved dispatches. That audit is the highest-value follow-up this leg does not do. +- **T5's single signature carrying two semantics** is mitigated by T6 but not eliminated: a caller who + omits `within` on a name that happens to resolve as a callable gets a callable cut, silently and + legitimately. The names would have to collide across kinds for this to bite. +- **The same-position skip diverges from `paths_between`'s raise.** Deliberate (§6), and it means two + accessors in one surface answer the same degenerate input differently. Documented on both. From 629c1de6161214a1de60f0c83180a4c0b88fdf46 Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Wed, 9 Sep 2026 05:55:01 -0400 Subject: [PATCH 02/65] =?UTF-8?q?docs(spec):=20name=20the=20field=20for=20?= =?UTF-8?q?what=20it=20certifies=20=E2=80=94=20exhausted,=20not=20refuted?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `refuted` read as a claim about the program. What the SDK can certify is narrower: the traversal was unbounded, found nothing, and no diagnostic implicated the pair. A triage caller seeing "refuted" closes the alert, and the gap between the word and the guarantee was invisible at the call site. So the field is `exhausted`, and §5 now states its three membership conditions as rules rather than prose: 1. depth is None for the call 2. zero paths for that pair 3. no unresolved diagnostic implicates that pair Condition 1 was previously only implied — the spec said a bounded depth meant taint "could essentially never" report the verdict. It is now a rule: when depth is not None, `exhausted` is empty. A pair with no path within five hops is not refuted, it is unmeasured, which is the bounded-boolean error leg 1.5 named. Test 4 asserts it. §5 also spells out why the step from `exhausted` to "false positive" is the caller's and not the SDK's. The graph approximates asymmetrically: the DDG's points-to edges are may-edges, over-approximate and safe for an absence claim, but the call structure is under-approximate wherever dispatch is unresolved, so a missing call edge means a real flow can exist with no path in the graph. Absence of path bounds the program only where the frontier was fully resolved, which is what `unresolved` enumerates. The verdict table gains a column separating what is certified from what a caller may do with it, and §11's caveat now names the ways the ledger can be silent because the analyzer never saw the route to report it: framework-mediated routing, reflection, FFI, unanalysed files, and cross-request DB persistence. --- docs/design/specs/2026-09-09-leg-4-taint.md | 77 ++++++++++++++------- 1 file changed, 52 insertions(+), 25 deletions(-) diff --git a/docs/design/specs/2026-09-09-leg-4-taint.md b/docs/design/specs/2026-09-09-leg-4-taint.md index a904e2fb..26024c6d 100644 --- a/docs/design/specs/2026-09-09-leg-4-taint.md +++ b/docs/design/specs/2026-09-09-leg-4-taint.md @@ -72,9 +72,9 @@ it buys. | # | Decision | Why | | --- | --- | --- | -| **T1** | **Design against alert triage — prove *or refute* a scanner's sink.** The sink arrives from outside as a position; the sources are a caller-supplied set. | It is the use case the evidence base measures (513 alerts quarantined for want of a `file:line → enclosing callable` lookup), and it is the one whose failure mode is asymmetric: a wrong refutation closes a live alert. Proving needs no new verb — `locate()` addresses the sink, `paths_between` returns the witness, `.weakest` caps the claim. **Refuting is what has no answer today**, because a caller who gets nothing back cannot distinguish "no flow exists" from "the flow left the resolved graph". `taint()` is a refutation instrument. | +| **T1** | **Design against alert triage — prove *or refute* a scanner's sink.** The sink arrives from outside as a position; the sources are a caller-supplied set. | It is the use case the evidence base measures (513 alerts quarantined for want of a `file:line → enclosing callable` lookup), and it is the one whose failure mode is asymmetric: a wrong refutation closes a live alert. Proving needs no new verb — `locate()` addresses the sink, `paths_between` returns the witness, `.weakest` caps the claim. **Refuting is what has no answer today**, because a caller who gets nothing back cannot distinguish "no flow exists" from "the flow left the resolved graph". `taint()` produces the evidence a refutation is built from, and §5 is explicit about the one step it cannot take on the caller's behalf. | | **T2** | **The SDK ships no vocabulary.** Sources, sinks and sanitizers are the caller's to supply; no framework catalogue, no entrypoint-derived default. | Already ruled in leg 1.5 §9 ("Sources, sinks and sanitizers remain leg 4, and remain the caller's to supply"). A per-framework catalogue across three languages is a taxonomy that rots, and this leg's whole contribution is mechanism, not policy. | -| **T3** | **A verdict is a value, not an enum.** `taint()` returns empty when it finds nothing; the existing `BoundedResult` protocol plus a `refuted` set carries the three cases. | `BoundedResult` (`commons/results.py:346`) is the one completeness protocol every bounded verb already speaks, under E5 "a bound is never silent", and it guarantees the payload behaves as its list so `if not result:` is honest. A third verdict type would be a new concept for a distinction two existing fields make. | +| **T3** | **A verdict is a value, not an enum.** `taint()` returns empty when it finds nothing; the existing `BoundedResult` protocol plus an `exhausted` set carries the three cases. | `BoundedResult` (`commons/results.py:346`) is the one completeness protocol every bounded verb already speaks, under E5 "a bound is never silent", and it guarantees the payload behaves as its list so `if not result:` is honest. A third verdict type would be a new concept for a distinction two existing fields make. | | **T4** | **Address a value as `(name, within)`, made plural.** No `Selector` type, contrary to the §5.4 sketch. | It is exactly the convention `paths_between` and `flows_to_argument` already use, so the strictness (`AmbiguousName`, `SelectorNotInGraph`) is inherited unchanged and E6/E7 are satisfied by construction. | | **T5** | **Two sanitizer kinds, distinguished by shape.** A `(name, within)` pair cuts a **variable**; a bare `str` cuts a **callable** on the path. | They are different mechanisms wearing one word. A *validating guard* never appears on the data path — the worked example runs `@formal_in:1 → 19:8 → 22:8/actual_in:0` and never touches the guard at `20:11` — so only a variable-granular cut severs it. A *transforming* sanitizer (`html.escape`, `shlex.quote`) does sit on the path and is naturally named as a callable. Supporting only one silently fails the other. | | **T6** | **Shape must agree with resolution, or raise.** A bare name resolving to a variable, or a pair whose name resolves to a callable, is `SelectorNotInGraph`. | T5's one signature carries two semantics, so the accident of omitting `within` must be loud. This is the mitigation that makes T5 safe, using machinery that already exists. | @@ -143,32 +143,52 @@ def taint(self, ``` ```python -class TaintResult(FlowPaths): # paths, complete, list behaviour, .weakest per path - refuted: list[Tuple[str, str]] # (source, sink) pairs the traversal exhausted with no path - roots: list[SliceNode] # what each selector matched - resolved: str # via slice_resolved() +class TaintResult(FlowPaths): # paths, complete, list behaviour, .weakest per path + exhausted: list[Tuple[str, str]] # pairs searched to exhaustion with a clean ledger + roots: list[SliceNode] # what each selector matched + resolved: str # via slice_resolved() unresolved: list[Diagnostic] # the frontier ledger, tagged with the pair each affects ``` -**`depth` is unbounded by default**, following `reaches` / `flows_to_*` / `paths_between` rather than -the slices' `DEFAULT_DEPTH`. This is forced, not stylistic: at a bounded depth `taint()` could -essentially never report `refuted`, so a bounded default would make refutation unreachable — and -leg 1.5 already ruled that "a bounded boolean is a wrong answer, not a small one." +### `exhausted` — what it claims, and what it does not + +A pair is in `exhausted` when **all three** hold: + +1. **`depth is None`** for the call — the traversal was unbounded. +2. **Zero paths** were found for that pair. +3. **No `unresolved` diagnostic implicates** that pair. + +So `exhausted` is a claim about **the emitted graph plus the ledger, never about the program.** The +field is named for what the SDK can mechanically certify, and the step from there to "this alert is +a false positive" is the caller's to take, deliberately. + +That step is not free, and the reason is asymmetry in how the graph approximates. The DDG's +`points-to` edges are *may*-edges — over-approximate, the safe direction for an absence claim. But +the **call structure is under-approximate wherever dispatch is unresolved**, and a missing call edge +means a real flow can exist with no path in the graph. Absence of path therefore bounds the program +only if every call on the relevant frontier was resolved, which is exactly what `unresolved` +enumerates. `exhausted`'s usefulness reduces entirely to that ledger's completeness — see §11. + +Condition 1 is a rule, not a tendency: **when `depth` is not `None`, `exhausted` is empty.** A pair +with no path within five hops is not refuted, it is unmeasured, and conflating the two is the +bounded-boolean error leg 1.5 named ("a bounded boolean is a wrong answer, not a small one"). This is +also why `depth` is unbounded by default here, following `reaches` / `flows_to_*` / `paths_between` +rather than the slices' `DEFAULT_DEPTH`. **The verdict is per pair.** A single `complete` cannot serve m×n pairs — pair A finding flows and -truncating would downgrade pair B's sound refutation — so `refuted` carries the verdict and +truncating would downgrade pair B's clean exhaustion — so `exhausted` carries the verdict and `complete` keeps its `FlowPaths` meaning exactly: -| the pair | verdict | -| --- | --- | -| has paths in `paths` | **flows**, and `.weakest` caps how strongly it may be stated | -| appears in `refuted` | **refuted** — the traversal was exhaustive | -| appears in neither | **inconclusive** — `unresolved` says why | +| the pair | what the SDK certifies | what a triage caller does with it | +| --- | --- | --- | +| has paths in `paths` | a witness exists; `.weakest` caps how strongly it may be stated | act on it | +| appears in `exhausted` | searched to exhaustion, ledger clean for this pair | treat as a false positive, at the confidence §11 allows | +| appears in neither | nothing — `unresolved` says what stopped it | do not close; the diagnostic names what to check | -`refuted` is *stored*, not derived. It is computable as -`all_pairs − pairs_with_paths − pairs_with_unresolved`, and that is exactly why it should not be: the -load-bearing answer must not be a subtraction the caller can get wrong. A refutation is earned, not -inferred. +`exhausted` is *stored*, not derived. It is computable — as +`all_pairs − pairs_with_paths − pairs_with_unresolved` when `depth is None`, and as `∅` otherwise — +and that is exactly why it should not be: the load-bearing answer must not be a subtraction the +caller can get wrong, and a subtraction with a mode switch in front of it is worse than one without. **`weakest` is per-language by construction.** DDG provenance has three tiers in Python (`reaching-defs` / `points-to` / `ssa`), two in Java, one in TypeScript — so every TypeScript flow's @@ -236,8 +256,10 @@ that it is cheap — but expectation is not measurement, and §10 requires the n path must come back. This is T7 as an assertion. 3. **Per-pair starvation.** One prolific pair and one sparse pair in the same call; the sparse pair's witness must survive. -4. **Refuted vs inconclusive.** A pair with no route reports `refuted`; a pair whose only route - crosses an unresolved dispatch reports neither, and its diagnostic names the pair. +4. **Exhausted vs inconclusive.** A pair with no route reports `exhausted`; a pair whose only route + crosses an unresolved dispatch reports neither, and its diagnostic names the pair. A third case: + the same call with an explicit `depth` returns `exhausted == []` even for the pair that has no + route at all — condition 1 of §5, asserted rather than assumed. 5. **Both sanitizer kinds (T5).** A validating guard cut by variable, a transforming sanitizer cut by callable, and the T6 shape/resolution disagreement raising. 6. **Self-loop regression.** The `#349` class — a relationship bound twice in one `MATCH` silently @@ -294,7 +316,7 @@ reads. - The null-safety tripwire (test 1) fails when `coalesce` is removed. Verified by removing it, not by assuming. - Wall-clock for `taint()` recorded on the largest corpus in the verification set, for a found flow, - a refutation, and a forty-source batch. A refutation is the common case in triage and must be + an exhausted pair, and a forty-source batch. Exhaustion is the common case in triage and must be measured as its own number, not inferred from the found case. - `PORTS_DISCONNECTED` and `_require_dataflow` degradation paths each exercised by a test that asserts the message names the accessor. @@ -310,8 +332,13 @@ reads. correctness half; nothing pins the plan, and a plan assertion would be brittle in its own way. - **Wall-clock on a large graph is still unmeasured.** m×n BFS runs in one statement is the shape most likely to surprise, and a forty-source batch has no measurement behind it yet. -- **`refuted` is only as sound as `unresolved` is complete.** If a dispatch the graph could not follow - is *not* reported as a diagnostic, the pair lands in `refuted` and the refutation is wrong. The +- **`exhausted` is only as useful as `unresolved` is complete.** The field itself stays true by + construction — it claims exhaustion over the emitted graph and nothing more — but the caller's step + from it to "false positive" inherits every gap in the ledger. If a dispatch the graph could not + follow is *not* reported, the pair lands in `exhausted` looking exactly like a clean one, and the + caller closes a live alert. Known ways the ledger can be silent because the analyzer never saw the + route to report it: framework-mediated routing, reflection, FFI boundaries, files that were not + analysed, and cross-request DB persistence, which no static SDG subsumes. The ledger's completeness is inherited from `backward_cone` and has never been audited against a known set of unresolved dispatches. That audit is the highest-value follow-up this leg does not do. - **T5's single signature carrying two semantics** is mitigated by T6 but not eliminated: a caller who From 7dba5f61af06c784e305c56ef719a2cc211b01e6 Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Wed, 9 Sep 2026 06:19:26 -0400 Subject: [PATCH 03/65] =?UTF-8?q?docs(spec):=20correct=20=C2=A72=20?= =?UTF-8?q?=E2=80=94=20Python's=20value=20traversals=20are=20scoped=20by?= =?UTF-8?q?=20id,=20not=20unscoped?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I filed #381 claiming Python's _SLICE/_PATHS/_VALUE_REACHES omit an application-scope predicate that Java's and TypeScript's carry, and that the omission was undocumented, untested, and the #349 shape. Every part of that is wrong, and #381 is closed as not a defect. tests/analysis/python/test_neo4j_multi_application_scope.py audits exactly this. It sanctions four scope kinds — introspection, application, prefix, and *id* — and its parametrised audit requires every class-level and harvested inline statement to use one. Keying by id qualifies, on stated grounds: a body-node id embeds the application and the emitter only ever links nodes from its own run, so such a statement is scoped by construction. The three templates take that route deliberately, for a measured reason — the predicate would mean testing 195,784 reached nodes against a list. A sibling test asserts the audit can *see* those three statements, so they cannot drift out of it. Java writes the predicate anyway under a stricter rule ("the audit judges the predicate that is written, not the graph that happens to be attached"), for ~4%. Two documented standards, each measured on its own corpus. Reasoning by analogy between them is precisely what leg 1.6's :PyCanNode result warned against. §2 now records this, and says why leg 4 still follows Java's stricter convention: taint()'s traversal is new code whose cost is unmeasured, and its sanitizer predicate filters the path interior regardless, so the scope predicate rides along in an all() that is already there. Also recorded, because it shortens the lift: _VIA_CASE and _PATH_ORDER are byte-identical across all three backends, so only _PATHS genuinely differs — by node label and projection. And _PATH_ORDER already spells coalesce(relationships(p)[i].var, ''), so §4's null-safe predicate is established practice in these files rather than a new idiom. The methodological note, since it is the reusable part: I grepped the backend for $prefix and concluded the predicate was absent and unexplained. The explanation lived in the test suite, where that search could not reach — the same shape as proving read-only-ness by grepping for CREATE/MERGE when the write goes through a library call (#324). --- docs/design/specs/2026-09-09-leg-4-taint.md | 39 +++++++++++++++------ 1 file changed, 29 insertions(+), 10 deletions(-) diff --git a/docs/design/specs/2026-09-09-leg-4-taint.md b/docs/design/specs/2026-09-09-leg-4-taint.md index 26024c6d..ac0a230a 100644 --- a/docs/design/specs/2026-09-09-leg-4-taint.md +++ b/docs/design/specs/2026-09-09-leg-4-taint.md @@ -21,9 +21,11 @@ and derives nothing the graph does not carry. No new node kind, edge kind, field | --- | --- | --- | --- | | New facade surface (one accessor, three languages) | — | `python-sdk` | `docs/agent-api-reference.md`, `docs/skills/using-cldk/SKILL.md`, `CLAUDE.md` | -One repo. Two issues found while designing this leg are filed and tracked separately, and neither -gates it: `codeanalyzer-python#195` (schema declares a property the projection never writes) and -`python-sdk#381` (Python's value traversals ignore the scope prefix). +One repo. One issue found while designing this leg is filed and tracked separately, and it does not +gate this one: `codeanalyzer-python#195` (the schema declares a `var` property on `PY_PARAM_IN` / +`PY_PARAM_OUT` that the projection never writes). A second, `python-sdk#381`, was filed here and then +**closed as not a defect** — §2's fourth fact records what it got wrong, because the reasoning it +replaces is what this leg follows. ## 2. Where the surface stands (measured 2026-09-09, at `af133c8` = `v2.0.0-rc.4`) @@ -58,10 +60,26 @@ rows.** **including both interprocedural ones**. §7 turns this into the leg's first test. **The Cypher is triplicated; the Python-side helpers are not.** `hop_sort_key` and `flow_path` -already live in `commons/graphs.py`. `_PATHS`, `_VIA_CASE` and `_PATH_ORDER` are each written three -times, and they differ in more than labels: Python has no scope predicate, Java scopes endpoints and -interior, TypeScript scopes the interior via `_scoped()`; Python projects `n.var`, TypeScript also -`n.of`, Java neither. +already live in `commons/graphs.py`. `_VIA_CASE` and `_PATH_ORDER` are **byte-identical** across all +three backends — pure duplicates. Only `_PATHS` genuinely differs, and in two ways: the node label, +and the node projection (Python adds `n.var`, TypeScript also `n.of`, Java neither). + +The scope predicate looks like a third difference and is not. Python's `_SLICE` / `_PATHS` / +`_VALUE_REACHES` carry no `STARTS WITH` because they are keyed **by id**, which +`tests/analysis/python/test_neo4j_multi_application_scope.py` sanctions as one of four scope kinds: +a body-node id embeds the application and the emitter only ever links nodes from its own run, so such +a statement is scoped by construction — and the predicate was measured there as costing a test of +195,784 reached nodes against a list. Java writes it anyway, on the stricter rule that "the audit +judges the predicate that is **written**, not the graph that happens to be attached", for ~4%. Two +documented standards, each measured on its own corpus, and reasoning by analogy between them is what +leg 1.6's `:PyCanNode` result warned against. + +**This leg follows Java's**, for a reason neither backend had: `taint()`'s traversal is new code whose +cost is not yet known, and its sanitizer predicate filters the path interior anyway, so the scope +predicate rides along in an `all()` that is already there. + +`_PATH_ORDER` already spells `coalesce(relationships(p)[i].var, '')`, so the null-safe idiom §4 +requires is established practice in these files rather than something `taint()` introduces. ## 3. Decisions @@ -299,9 +317,10 @@ reads. - **A policy vocabulary.** No framework catalogue, no entrypoint-derived source defaults (T2). - **Deriving edges no analyzer emitted.** The field-alias join remains an upstream question; taint reads the SDG as emitted. -- **The two issues this design found.** `codeanalyzer-python#195` and `python-sdk#381` are tracked on - their own and neither gates this leg. `#381` in particular is *not* a prerequisite: taint writes - its own scope predicates per T8/§4 regardless of what the existing Python templates do. +- **The issue this design found.** `codeanalyzer-python#195` is tracked on its own and does not gate + this leg. `python-sdk#381` was filed here too and **closed as not a defect** — the audit that already + covers those statements sanctions id-keying as a scope kind (§2). Taint writes its own + per-bound-variable scope predicates per T8/§4 either way. - **Sanitizer inference.** Whether a callable *is* a sanitizer is the caller's judgement, always. - **The remaining §10 items of the facade spec** — the twelve named field-failure tests — which belong to the leg but are enumerated there, not re-listed here. From cc5de972fda10f6cdd41683a9145ba9558937059 Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Wed, 9 Sep 2026 06:26:22 -0400 Subject: [PATCH 04/65] =?UTF-8?q?docs(plan):=20leg=204=20=E2=80=94=20the?= =?UTF-8?q?=20shared=20path=20skeleton,=20then=20taint()?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two plans, because the spec's §8 steps are separable and each produces testable software on its own. 4a is a no-behaviour-change refactor across three shipped backends; 4b is the new verb that sits on it. 4a (20 steps). _VIA_CASE and _PATH_ORDER turn out to be byte-identical in all three backends, so they lift as via_case(P)/path_order(P) with nothing to parameterise. Only _PATHS genuinely differs, in five places rather than the three the spec assumed: node label, endpoint scope, interior scope, node projection, and — found while writing the plan — the relationship variable, which Java spells `e` and the other two spell `r`. That last one is semantically inert, so it becomes a parameter instead of a judgement call: byte-identity is the whole safety property of this step, and normalising a variable name would forfeit it for nothing. Every task in 4a completes against string equality with the constant it replaces, and Task 4 adds live confirmation because string equality cannot catch a mis-wired import. It also asserts the witness order is unchanged: max_paths truncates a total order, so a reordered ORDER BY would silently change which witnesses callers see. 4b (47 steps). The load-bearing task is 2, and it is the one an implementer would get wrong. shortest_walks is BFS-then-DFS: the BFS records the distance each node was first reached at, and the DFS replay is pinned to dist[dst]. Filtering only the replay — the obvious place — leaves dist describing the unfiltered graph, so a sanitized 2-hop route pins the target length and a clean 3-hop route is never visited. The pair flows and taint reports nothing. That is the same false refutation T7 rejects, reappearing on the local backends while Neo4j stays correct because the planner inlines its predicate into the search. The predicate therefore enters both passes through one steps() helper, and the adversarial graph is a test rather than a comment. Also recorded in 4b: taint needs five implementations, not three. Python and TypeScript each have a local and a Neo4j backend implementing paths_between independently; Java has one, on the ABC, because its Neo4j backend rebuilds the canonical JApplication and answers from it. The plan says how to confirm that rather than asserting it. Self-review found two of the spec's eight tests with no task — per-pair starvation and the self-loop regression — and both are now steps with the measurement needed to pick a fixture: odoo's heaviest callable holds 1.39M of 5.13M DDG edges for the prolific pair, and the self-loop counts are 978 in Java and 64,702 in odoo. --- .../2026-09-09-leg-4a-shared-path-skeleton.md | 418 +++++++++++ docs/design/plans/2026-09-09-leg-4b-taint.md | 688 ++++++++++++++++++ 2 files changed, 1106 insertions(+) create mode 100644 docs/design/plans/2026-09-09-leg-4a-shared-path-skeleton.md create mode 100644 docs/design/plans/2026-09-09-leg-4b-taint.md diff --git a/docs/design/plans/2026-09-09-leg-4a-shared-path-skeleton.md b/docs/design/plans/2026-09-09-leg-4a-shared-path-skeleton.md new file mode 100644 index 00000000..63d19b4b --- /dev/null +++ b/docs/design/plans/2026-09-09-leg-4a-shared-path-skeleton.md @@ -0,0 +1,418 @@ +# Leg 4a — the shared SDG path skeleton + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development +> (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use +> checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Lift the triplicated SDG path Cypher into one generator in `commons/graphs.py`, with **no +behaviour change** — every generated statement byte-identical to the constant it replaces. + +**Architecture:** Three Neo4j backends each carry `_VIA_CASE`, `_PATH_ORDER` and `_PATHS`. +`_VIA_CASE` and `_PATH_ORDER` are byte-identical across all three, so they become +`via_case(P)` / `path_order(P)`. `_PATHS` differs in five places, all of which become parameters of +one `sdg_path_query()`. Nothing else moves: the runner methods, the projections consumed downstream, +and the statements' semantics are untouched. + +**Tech Stack:** Python 3.11+, pytest, no new dependency. Neo4j is not required — every test here +compares strings. + +**Spec:** `docs/design/specs/2026-09-09-leg-4-taint.md` (T8, §2's fourth fact, §8 step 1) + +## Global Constraints + +- **No behaviour change.** This plan changes no query's semantics. The completion criterion for + every task is string equality against the current constant, not a live query. +- **No public accessor changes name, signature or return type.** Nothing in this plan touches a + facade. +- The three backends are **shipped code on `release/2.0` mid-rc** (`v2.0.0-rc.4`). Byte-identity is + what keeps the refactor from being the thing that reddens a live suite. +- Java's relationship variable in `_PATHS` is `e`; Python's and TypeScript's is `r`. It is + semantically inert and **must not be normalised in this plan** — pass it as a parameter so + byte-identity holds. Normalising is a separate, arguable change. +- Python's path statements are scoped **by id**, which + `tests/analysis/python/test_neo4j_multi_application_scope.py` sanctions as one of four scope kinds + for a measured reason. **Do not "fix" Python to carry `STARTS WITH`** — that is a behaviour change, + it was measured and rejected, and #381 was closed as not a defect for exactly this. +- Do not delete `_paths()`'s unused `prefix=` keyword argument here. It is dead, it is cosmetic, and + it is not this plan's business. + +## File Structure + +| File | Responsibility after this plan | +| --- | --- | +| `cldk/analysis/commons/graphs.py` | gains `via_case(P)`, `path_order(P)`, `sdg_path_query(...)` alongside the existing `sdg_rels` / `sdg_rel_pattern` / `via_table` / `hop_sort_key` / `flow_path` family | +| `cldk/analysis/python/neo4j/neo4j_backend.py` | `_VIA_CASE` / `_PATH_ORDER` deleted; `_PATHS` becomes one `sdg_path_query(...)` call | +| `cldk/analysis/java/neo4j/neo4j_backend.py` | same | +| `cldk/analysis/typescript/neo4j/neo4j_backend.py` | same | +| `tests/analysis/commons/test_lifted_helpers.py` | gains the byte-identity assertions (the file already exists and is the right home) | + +`graphs.py` is 370 lines and already owns this family, so no new module. It grows by roughly 60 lines. + +--- + +### Task 1: `via_case()` and `path_order()` + +The two pure duplicates. Both are currently built at class-definition time from the module-level +`VIA` (`via_table("PY")` / `("J")` / `("TS")`), so the lifted forms take `P` and rebuild the same +table internally. + +**Files:** +- Modify: `cldk/analysis/commons/graphs.py` (append after `via_table`, around line 158) +- Test: `tests/analysis/commons/test_lifted_helpers.py` + +**Interfaces:** +- Consumes: `via_table(P)` — already in this module. +- Produces: `via_case(P: str) -> str`, `path_order(P: str) -> str`. Task 3 replaces three class + attributes with calls to these. + +- [ ] **Step 1: Write the failing test** + +`tests/analysis/commons/test_lifted_helpers.py`: + +```python +import pytest + +from cldk.analysis.commons.graphs import path_order, via_case +from cldk.analysis.java.neo4j.neo4j_backend import JNeo4jBackend +from cldk.analysis.python.neo4j.neo4j_backend import PyNeo4jBackend +from cldk.analysis.typescript.neo4j.neo4j_backend import TSNeo4jBackend + +#: Each backend's relationship-type prefix, and the class whose constants it must reproduce. +BACKENDS = [("PY", PyNeo4jBackend), ("J", JNeo4jBackend), ("TS", TSNeo4jBackend)] + + +@pytest.mark.parametrize("P, backend", BACKENDS) +def test_via_case_reproduces_the_backends_constant(P, backend): + """The lift is only safe if it is byte-identical: a changed CASE arm would change which word a + hop is reported under, and a changed ORDER BY would change which paths max_paths keeps.""" + assert via_case(P) == backend._VIA_CASE + + +@pytest.mark.parametrize("P, backend", BACKENDS) +def test_path_order_reproduces_the_backends_constant(P, backend): + assert path_order(P) == backend._PATH_ORDER + + +def test_the_three_backends_agreed_before_the_lift(): + """The premise of lifting these two rather than parameterising them: all three were already + identical. If this ever fails, one backend diverged and the lift is hiding it.""" + assert len({b._VIA_CASE for _, b in BACKENDS}) == 1 + assert len({b._PATH_ORDER for _, b in BACKENDS}) == 1 +``` + +- [ ] **Step 2: Run it to make sure it fails** + +Run: `uv run --all-groups pytest tests/analysis/commons/test_lifted_helpers.py -v` +Expected: FAIL with `ImportError: cannot import name 'via_case'`. + +- [ ] **Step 3: Implement the minimal code** + +In `cldk/analysis/commons/graphs.py`, after `via_table`: + +```python +def via_case(P: str) -> str: + """The Cypher ``CASE`` that maps a hop's relationship type to the caller's word for it. + + Computed in Cypher rather than in Python because the ``ORDER BY`` in :func:`path_order` sorts by + the same vocabulary :func:`hop_sort_key` sorts by. Ordering by the raw ``type(r)`` instead would + be just as deterministic and a *different* order (``PY_CDG`` before ``PY_DDG`` before + ``PY_PARAM_IN``, against ``argument`` before ``control`` before ``data``), so the two backends + would truncate ``max_paths`` to different witnesses. + """ + return "CASE type(relationships(p)[i]) " + " ".join(f"WHEN '{rel}' THEN '{word}'" for rel, word in via_table(P).items()) + " ELSE type(relationships(p)[i]) END" + + +def path_order(P: str) -> str: + """One sort key per path, ordered exactly as Python would order the tuple :func:`hop_sort_key` + builds. + + ``\\u0001`` is the separator rather than ``|`` for one reason: string comparison agrees with + field-by-field comparison **only** when the separator sorts below every character a field can + hold, and ``|`` (0x7C) sorts *above* every lowercase letter, which would order a variable ``x`` + after ``xy``. ``elementId`` is the last field of each hop and breaks the tie between parallel + relationships a caller cannot tell apart; it is stable for repeated calls against one database + and means nothing outside it. + """ + return "reduce(k = '', i IN range(0, length(p) - 1) | k + " + via_case(P) + " + '\\u0001' + coalesce(relationships(p)[i].var, '') " "+ '\\u0001' + nodes(p)[i + 1].id + '\\u0001' + elementId(relationships(p)[i]) + '\\u0001')" +``` + +- [ ] **Step 4: Run the tests and make sure they pass** + +Run: `uv run --all-groups pytest tests/analysis/commons/test_lifted_helpers.py -v` +Expected: 7 passed. + +If `test_path_order_reproduces_the_backends_constant` fails, diff the two strings character by +character — the likely cause is the implicit string concatenation in the original spanning two +source lines with a space between `") "` and `"+ '\\u0001'"`. Reproduce it exactly; do not +"tidy" the spacing. + +- [ ] **Step 5: Commit** + +```bash +git add cldk/analysis/commons/graphs.py tests/analysis/commons/test_lifted_helpers.py +git commit -m "refactor(graphs): lift via_case and path_order out of the three backends" +``` + +--- + +### Task 2: `sdg_path_query()` + +**Files:** +- Modify: `cldk/analysis/commons/graphs.py` +- Test: `tests/analysis/commons/test_lifted_helpers.py` + +**Interfaces:** +- Consumes: `path_order(P)` from Task 1. +- Produces: + +```python +def sdg_path_query(P: str, *, node_label: str, endpoint_scope: str = "", + interior_scope: str = "", projection: str, rel_var: str = "r") -> str: ... +``` + +`endpoint_scope` and `interior_scope` are Cypher fragments already spelled by the caller (a +backend's `_scoped()` output, or `"n.id STARTS WITH $prefix"`), empty when that backend does not +carry one. `projection` is the per-node map body **without** the surrounding braces. The returned +string is still a `.format()` template carrying `{rels}` and `{depth}`, exactly as today, so the +runner methods are unchanged. + +- [ ] **Step 1: Write the failing test** + +Append to `tests/analysis/commons/test_lifted_helpers.py`: + +```python +from cldk.analysis.commons.graphs import sdg_path_query + +#: The five differences between the three `_PATHS` constants, as the arguments that reproduce each. +#: Written out rather than derived, because a derivation that produced the wrong string would also +#: produce the wrong expectation. +PATHS_ARGS = { + "PY": dict( + node_label="PyBodyNode", + projection="ref: n.id, kind: n.kind, var: n.var, line: n.start_line, " + "callable: head([(c:PyCallable)-[:PY_HAS_BODY_NODE]->(n) | c.signature]), " + "c_line: head([(c:PyCallable)-[:PY_HAS_BODY_NODE]->(n) | c.start_line])", + ), + "J": dict( + node_label="JBodyNode", + endpoint_scope="n.id STARTS WITH $prefix", + interior_scope="n.id STARTS WITH $prefix", + projection="ref: n.id, kind: n.kind, line: n.start_line", + rel_var="e", + ), + "TS": dict( + node_label="CanNode:TSBodyNode", + interior_scope="(n.id STARTS WITH $p)", + projection="ref: n.id, kind: n.kind, of: n.of, line: n.start_line, " + "callable: head([(c:TSCallable)-[:TS_HAS_BODY_NODE]->(n) | c.signature]), " + "c_line: head([(c:TSCallable)-[:TS_HAS_BODY_NODE]->(n) | c.start_line])", + ), +} + + +@pytest.mark.parametrize("P, backend", BACKENDS) +def test_sdg_path_query_reproduces_the_backends_paths(P, backend): + """Byte-identical, including Java's `e` relationship variable and TypeScript's parenthesised + scope fragment. A difference here is a behaviour change, which this plan forbids.""" + assert sdg_path_query(P, **PATHS_ARGS[P]) == backend._PATHS + + +@pytest.mark.parametrize("P, backend", BACKENDS) +def test_the_generated_template_still_formats(P, backend): + """The result is a `.format()` template, not a finished statement: the runners pass `rels` and + `depth`. If the lift ever emitted a literal brace it would raise here rather than at runtime.""" + from cldk.analysis.commons.graphs import sdg_rel_pattern + + out = sdg_path_query(P, **PATHS_ARGS[P]).format(rels=sdg_rel_pattern(P), depth="") + assert "{" not in out and "}" not in out.replace("{{", "").replace("}}", "") +``` + +- [ ] **Step 2: Run it to make sure it fails** + +Run: `uv run --all-groups pytest tests/analysis/commons/test_lifted_helpers.py -k sdg_path_query -v` +Expected: FAIL with `ImportError: cannot import name 'sdg_path_query'`. + +- [ ] **Step 3: Implement the minimal code** + +In `cldk/analysis/commons/graphs.py`: + +```python +def sdg_path_query(P: str, *, node_label: str, endpoint_scope: str = "", interior_scope: str = "", projection: str, rel_var: str = "r") -> str: + """The shortest-path statement all three Neo4j backends issue for ``paths_between``. + + ``allShortestPaths`` and not a plain variable-length match. A variable-length pattern enumerates + *trails*, the shape that does not terminate on a real dependence graph; ``allShortestPaths`` is a + bidirectional BFS and answers the pathological cases in milliseconds. ``$cap`` is + ``max_paths + 1`` at the call site, so one extra row reports the truncation rather than a second + ``count(p)`` traversal for a number the caller cannot act on. + + The five per-language differences are the five parameters. ``endpoint_scope`` and + ``interior_scope`` are Cypher fragments over the node variable ``n``, empty for a backend whose + statement is scoped by id instead (see the scope-kind audit in + ``tests/analysis/python/test_neo4j_multi_application_scope.py`` — id-keying is sanctioned, and + adding a predicate to those statements was measured and rejected). ``rel_var`` exists only + because Java spells its relationship ``e`` and the other two spell it ``r``; the name is inert + and is a parameter so this lift can be byte-identical rather than a judgement call. + + Returns a ``.format()`` template still carrying ``{rels}`` and ``{depth}``. + """ + a_scope = f" WHERE {endpoint_scope.replace('n.', 'a.')}" if endpoint_scope else "" + b_scope = f" WHERE {endpoint_scope.replace('n.', 'b.')}" if endpoint_scope else "" + interior = f" WHERE all(n IN nodes(p) WHERE {interior_scope})" if interior_scope else "" + return ( + f"MATCH (a:{node_label} {{{{id:$src}}}}){a_scope} " + f"MATCH (b:{node_label} {{{{id:$dst}}}}){b_scope} " + "MATCH p = allShortestPaths((a)-[:{rels}*1..{depth}]->(b))" + interior + " " + "WITH p, " + path_order(P) + " AS key ORDER BY length(p), key LIMIT $cap " + f"RETURN [n IN nodes(p) | {{{{{projection}}}}}] AS ns, " + f"[{rel_var} IN relationships(p) | {{{{via: type({rel_var}), var: {rel_var}.var, prov: {rel_var}.prov}}}}] AS rs" + ) +``` + +- [ ] **Step 4: Run the tests and make sure they pass** + +Run: `uv run --all-groups pytest tests/analysis/commons/test_lifted_helpers.py -v` +Expected: 13 passed. + +The `.replace('n.', 'a.')` on `endpoint_scope` is the one fragile line: it assumes the fragment +names the node variable as `n.`. Only Java supplies one and it does. If a future backend supplies a +fragment shaped differently, the parameter should become a callable — but do not pre-build that +here. + +- [ ] **Step 5: Commit** + +```bash +git add cldk/analysis/commons/graphs.py tests/analysis/commons/test_lifted_helpers.py +git commit -m "refactor(graphs): add sdg_path_query, reproducing all three _PATHS byte-identically" +``` + +--- + +### Task 3: Replace the constants in the three backends + +The tests from Tasks 1 and 2 compare against `backend._PATHS`, so they keep passing **only** if the +replacement is exact — they become the regression test for this task rather than needing new ones. + +**Files:** +- Modify: `cldk/analysis/python/neo4j/neo4j_backend.py` (`_VIA_CASE` ~1590, `_PATH_ORDER` ~1600, `_PATHS` ~1615) +- Modify: `cldk/analysis/java/neo4j/neo4j_backend.py` (~898, ~918, ~933) +- Modify: `cldk/analysis/typescript/neo4j/neo4j_backend.py` (~1847, ~1854, ~1867) + +**Interfaces:** +- Consumes: `via_case`, `path_order`, `sdg_path_query` from Tasks 1-2. +- Produces: nothing new. `_PATHS` remains a class attribute of the same name and type, because + `tests/analysis/python/test_neo4j_multi_application_scope.py` enumerates class attributes that are + `str` and starting with a Cypher clause — a `_PATHS` that stopped being a plain class-level string + would silently drop out of that audit, and + `test_the_audit_sees_the_dataflow_statements_too` names it explicitly so it would fail loudly. + +- [ ] **Step 1: Run the audit tests first, to record the baseline** + +Run: `uv run --all-groups pytest tests/analysis/python/test_neo4j_multi_application_scope.py -v` +Expected: all pass. Note the count; it must not change. + +- [ ] **Step 2: Replace in the Python backend** + +Delete `_VIA_CASE` and `_PATH_ORDER` (keeping their comments above `_PATHS` where they explain the +statement), and replace `_PATHS` with: + +```python + _PATHS = sdg_path_query( + "PY", + node_label="PyBodyNode", + projection="ref: n.id, kind: n.kind, var: n.var, line: n.start_line, " + "callable: head([(c:PyCallable)-[:PY_HAS_BODY_NODE]->(n) | c.signature]), " + "c_line: head([(c:PyCallable)-[:PY_HAS_BODY_NODE]->(n) | c.start_line])", + ) +``` + +Add `sdg_path_query` (and `via_case`/`path_order` if `_CALL_PATHS` still references the deleted +`_PATH_ORDER` — it does; point it at `path_order("PY")`) to the `cldk.analysis.commons.graphs` +import. + +- [ ] **Step 3: Run the tests** + +Run: `uv run --all-groups pytest tests/analysis/commons/test_lifted_helpers.py tests/analysis/python -v` +Expected: pass, with the same count as Step 1 for the audit file. + +- [ ] **Step 4: Repeat for Java and TypeScript** + +Java (note `rel_var="e"` and both scope fragments): + +```python + _PATHS = sdg_path_query( + "J", + node_label="JBodyNode", + endpoint_scope="n.id STARTS WITH $prefix", + interior_scope="n.id STARTS WITH $prefix", + projection="ref: n.id, kind: n.kind, line: n.start_line", + rel_var="e", + ) +``` + +TypeScript (interior only, and the fragment comes from the module's own `_scoped`): + +```python + _PATHS = sdg_path_query( + "TS", + node_label="CanNode:TSBodyNode", + interior_scope=_scoped("n"), + projection="ref: n.id, kind: n.kind, of: n.of, line: n.start_line, " + "callable: head([(c:TSCallable)-[:TS_HAS_BODY_NODE]->(n) | c.signature]), " + "c_line: head([(c:TSCallable)-[:TS_HAS_BODY_NODE]->(n) | c.start_line])", + ) +``` + +- [ ] **Step 5: Run the full offline suite** + +Run: `uv run --all-groups pytest tests -x -q` +Expected: the same pass/skip counts as before this plan. **Use `--all-groups`, not +`--all-extras`** — `--all-extras` builds a 58-package environment against `--all-groups`' 148 and +produces phantom failures. + +- [ ] **Step 6: Confirm no duplicate remains** + +Run: +```bash +grep -rn "_VIA_CASE\|_PATH_ORDER = " cldk/analysis/*/neo4j/neo4j_backend.py +``` +Expected: no output. Any hit is a copy the lift missed. + +- [ ] **Step 7: Commit** + +```bash +git add cldk/analysis/*/neo4j/neo4j_backend.py +git commit -m "refactor(neo4j): the three backends build _PATHS from the shared generator" +``` + +--- + +### Task 4: Live confirmation + +String equality proves the statements did not change. It does not prove the backends still work, +because a mis-wired import or a stale attribute would pass every test above. + +**Files:** none. This task runs tests only. + +- [ ] **Step 1: Run each language's live dataflow suite against a real graph** + +```bash +uv run --all-groups pytest tests/analysis/python/test_dataflow.py -q +uv run --all-groups pytest tests/analysis/java/test_java_dataflow_live.py -q +uv run --all-groups pytest tests/analysis/typescript/test_typescript_dataflow_live.py -q +``` + +Expected: the same counts as before the plan. These need the `CLDK_TEST_NEO4J_*` variables pointing +at graphs emitted by the pinned analyzer generations; they skip cleanly when absent, and **a skip is +not a pass** — if all three skip, this task is not done. + +- [ ] **Step 2: Confirm `paths_between` returns identical witnesses** + +For one known reachable pair per language, capture `[h.via for h in path.hops]` before and after +(use `git stash` to compare). Expected: identical lists in identical order. `max_paths` truncates a +*total order*, so a changed order would silently change which witnesses a caller sees — the one +regression byte-identity cannot rule out if the lift accidentally reordered the `ORDER BY` terms. + +- [ ] **Step 3: Commit nothing; report the counts** + +This task produces evidence, not a diff. Record the three counts in the PR body. diff --git a/docs/design/plans/2026-09-09-leg-4b-taint.md b/docs/design/plans/2026-09-09-leg-4b-taint.md new file mode 100644 index 00000000..d3d8d579 --- /dev/null +++ b/docs/design/plans/2026-09-09-leg-4b-taint.md @@ -0,0 +1,688 @@ +# Leg 4b — `taint()` + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development +> (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use +> checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add `taint()` to all three facades and all five backend implementations, as a refutation +instrument: it returns witness paths for pairs that flow, and an `exhausted` set for pairs it +searched to exhaustion with a clean ledger. + +**Architecture:** One accessor, resolved and gated once on each language's `AnalysisBackend` +(where the policy already lives), with the traversal supplied per backend — Cypher on the Neo4j +side via a new `sdg_taint_query()`, in-process on the local side via `shortest_walks()`. The +sanitizer cut must happen **inside** the search on both sides; filtering results is the one +implementation that would be wrong. + +**Tech Stack:** Python 3.11+, pydantic, Neo4j 5.x, pytest. No new dependency. + +**Spec:** `docs/design/specs/2026-09-09-leg-4-taint.md` + +**Prerequisite:** `docs/design/plans/2026-09-09-leg-4a-shared-path-skeleton.md` must be complete. +Task 3 here extends `sdg_path_query`'s neighbourhood and assumes it exists. + +## Global Constraints + +- **Five implementation sites, not three.** Python and TypeScript each have a local + (`codeanalyzer/codeanalyzer.py`) and a Neo4j backend that both implement `paths_between` + independently. **Java has one:** the policy lives on `JavaAnalysisBackend` and `JNeo4jBackend` + rebuilds the canonical `JApplication` and answers from it. Confirm with + `grep -rn "def paths_between" cldk/analysis/*/codeanalyzer/*.py cldk/analysis/*/neo4j/*.py`. +- **The cut happens before the bound, on both sides.** This is the spec's T7 and it is a correctness + requirement, not a performance one. See Task 2 for why the obvious local implementation gets it + wrong. +- **`coalesce(r.var, '')` is mandatory** in every variable predicate. Only the DDG relationship + carries `var`; `PARAM_IN`, `PARAM_OUT`, `CDG` and `SUMMARY` carry none, so a bare `r.var <> $v` + is `null` on every cross-call hop and excludes the path. `path_order()` already spells the + `coalesce`, so this is the file's existing idiom. +- **`depth is None` is a precondition of `exhausted`.** When `depth` is not `None`, `exhausted` is + empty. Not "usually empty" — empty. +- `taint()` needs **L4** (`PARAM_IN`/`PARAM_OUT` are L4 edges) and, on Java, a **connected port + lattice**. Reuse `_require_dataflow()` and `_require_connected_ports("taint")`; do not write new + gates. +- No Neo4j 5.9 floor: `allShortestPaths` is not a quantified path pattern. Do **not** call + `_require_quantified_paths`. +- **No existing public accessor changes name, signature or return type.** +- Run pytest with `--all-groups`, never `--all-extras`. + +## File Structure + +| File | Responsibility | +| --- | --- | +| `cldk/analysis/commons/results.py` | `TaintResult(FlowPaths)` — the one result type, shared by all five implementations | +| `cldk/analysis/commons/graphs.py` | `sdg_taint_query()` (the Cypher) and `shortest_walks()`'s new predicate (the in-process twin) | +| `cldk/analysis/commons/resolve.py` | `resolve_sanitizers()` — the T6 shape/resolution agreement, once for all languages | +| `cldk/analysis/{python,java,typescript}/backend.py` | `taint()` on each ABC: validate, resolve, gate, assemble the per-pair verdict; delegate the walk | +| `cldk/analysis/{python,typescript}/neo4j/neo4j_backend.py` | the Cypher walk | +| `cldk/analysis/{python,typescript}/codeanalyzer/codeanalyzer.py` | the in-process walk | +| `cldk/analysis/{python,java,typescript}/{python,java,typescript}_analysis.py` | the facade method, docstring, example | +| `tests/analysis/commons/test_taint_semantics.py` | new — the two soundness tripwires, language-free | +| `tests/analysis/{python,java,typescript}/test_*_taint.py` | new — per-language offline and live | + +--- + +### Task 1: `TaintResult` + +**Files:** +- Modify: `cldk/analysis/commons/results.py` (append after `FlowPaths`, ~line 642) +- Test: `tests/analysis/commons/test_taint_semantics.py` (new) + +**Interfaces:** +- Consumes: `FlowPaths`, `SliceNode`, `Diagnostic` — all already in this module. +- Produces: `TaintResult`. Every later task returns one. + +- [ ] **Step 1: Write the failing test** + +```python +from cldk.analysis.commons.results import Diagnostic, TaintResult + + +def _pair(a="a", b="b"): + return (a, b) + + +def test_empty_and_complete_is_an_exhausted_pair(): + """The verdict table's middle row: nothing found, and the search was whole.""" + r = TaintResult(paths=[], complete=True, exhausted=[_pair()], roots=[], resolved="", unresolved=[]) + assert not r # BoundedResult makes the payload's truth the list's + assert _pair() in r.exhausted + + +def test_empty_and_incomplete_is_not_exhausted(): + """The bottom row: nothing found, but something stopped the search, so no pair may be listed.""" + d = Diagnostic(kind="unresolved_dispatch", message="x", subject="a->b") + r = TaintResult(paths=[], complete=False, exhausted=[], roots=[], resolved="", unresolved=[d]) + assert not r + assert r.exhausted == [] + + +def test_a_result_behaves_as_its_path_list(): + """Inherited from BoundedResult: `for p in result` and `len(result)` are the paths, not fields.""" + r = TaintResult(paths=[], complete=True, exhausted=[], roots=[], resolved="", unresolved=[]) + assert list(r) == [] and len(r) == 0 + + +def test_exhausted_survives_a_round_trip(): + """A triage caller writes the result to JSON and another process reads the verdict back.""" + r = TaintResult(paths=[], complete=True, exhausted=[_pair()], roots=[], resolved="", unresolved=[]) + assert TaintResult.model_validate(r.model_dump()).exhausted == [_pair()] +``` + +Check `Diagnostic`'s actual field names first (`commons/results.py:56`) and match them; the names +above are placeholders **only** for `Diagnostic`, and must be corrected to the real ones before the +test is run. + +- [ ] **Step 2: Run it to make sure it fails** + +Run: `uv run --all-groups pytest tests/analysis/commons/test_taint_semantics.py -v` +Expected: FAIL with `ImportError: cannot import name 'TaintResult'`. + +- [ ] **Step 3: Implement** + +```python +class TaintResult(FlowPaths): + """Which of the requested (source, sink) pairs flow, which were searched to exhaustion, and what + stopped the rest. + + A subclass of :class:`FlowPaths` rather than a new shape: the witnesses *are* flow paths, each + already carrying ``weakest``, and ``complete`` already means "were all the witnesses returned". + Three fields are added and nothing is redefined. + + **What ``exhausted`` claims.** A pair is listed when all three hold: the call passed + ``depth=None``, the search found no path for it, and no :attr:`unresolved` diagnostic implicates + it. That is a claim about **the emitted graph plus the ledger, and never about the program** — + which is why the field is named for the search rather than for the conclusion. The step from + "exhausted" to "this alert is a false positive" is the caller's, deliberately: the DDG's + ``points-to`` edges over-approximate, which is the safe direction for an absence claim, but the + call structure *under*-approximates wherever dispatch is unresolved, so a missing call edge means + a real flow can exist with no path in the graph. Absence of path bounds the program only where the + frontier was fully resolved, and :attr:`unresolved` is what enumerates where it was not. + + **Why it is stored rather than derived.** It is computable — ``all_pairs − pairs_with_paths − + pairs_with_unresolved`` when ``depth is None``, and ``∅`` otherwise — and that is the reason not + to: the load-bearing answer must not be a subtraction the caller can get wrong, and a subtraction + with a mode switch in front of it is worse than one without. + + Attributes: + exhausted: The ``(source, sink)`` pairs searched to exhaustion with a clean ledger, named by + the same strings the caller passed. Empty whenever ``depth`` was not ``None``. + roots: What each selector matched, so a conclusion is auditable rather than asserted. + resolved: The human-readable form of ``roots``, via ``slice_resolved``. + unresolved: The frontier ledger. Each diagnostic names the pair it affects, so a caller can + tell which of forty sources was blocked rather than only that one was. + """ + + exhausted: list[Tuple[str, str]] + roots: list[SliceNode] + resolved: str + unresolved: list[Diagnostic] +``` + +- [ ] **Step 4: Run the tests** + +Run: `uv run --all-groups pytest tests/analysis/commons/test_taint_semantics.py -v` +Expected: 4 passed. + +- [ ] **Step 5: Commit** + +```bash +git add cldk/analysis/commons/results.py tests/analysis/commons/test_taint_semantics.py +git commit -m "feat(results): TaintResult, with exhausted named for the search and not the conclusion" +``` + +--- + +### Task 2: `shortest_walks()` filters in **both** passes + +This is the task most likely to be implemented wrongly, so read the whole task before starting. + +`shortest_walks` (`commons/graphs.py:193`) is two passes. The first is a BFS recording `dist[node]`, +the hop count each node was *first* reached at. The second is a DFS replay pinned to +`target = dist[dst]`, stepping only to nodes whose recorded distance is exactly one more than the walk +so far. + +**Applying a sanitizer predicate only in the DFS replay is wrong.** `dist` would still be the +unfiltered shortest distance, so a graph whose 2-hop route is sanitized and whose 3-hop route is not +would return **nothing**: the replay is pinned to length 2, and the 3-hop route is never visited. +That is precisely the false refutation the spec's T7 rejects — and it would appear on the local +backends while the Neo4j backend, whose predicate the planner inlines into the search, answers +correctly. The two backends would disagree, and the parity suites would only notice if the corpus +happens to contain such a shape. + +**The predicate must be applied in the BFS pass too**, so `dist` is the shortest *satisfying* +distance. + +**Files:** +- Modify: `cldk/analysis/commons/graphs.py` (`shortest_walks`, ~line 193) +- Test: `tests/analysis/commons/test_taint_semantics.py` + +**Interfaces:** +- Produces: `shortest_walks(..., allow_edge: Callable[[str, str | None], bool] | None = None, + allow_node: Callable[[str], bool] | None = None)`. `allow_edge` receives + `(relationship type, var)`; `allow_node` receives a node id. Both default to `None`, meaning no + filtering, so every existing caller is unaffected. + +- [ ] **Step 1: Write the failing test — the adversarial shape, in-process** + +```python +from cldk.analysis.commons.graphs import shortest_walks, via_table + +VIA = via_table("PY") + +#: The shape that separates a correct filter from a plausible one: the SHORTEST route is sanitized +#: and a LONGER one is clean. Filtering the replay alone returns nothing here. +ADJ = { + "a": {"m": [("PY_DDG", "tainted", ["ssa"])], "n1": [("PY_DDG", "clean", ["ssa"])]}, + "m": {"b": [("PY_DDG", "tainted", ["ssa"])]}, + "n1": {"n2": [("PY_DDG", "clean", ["ssa"])]}, + "n2": {"b": [("PY_DDG", "clean", ["ssa"])]}, +} + + +def test_unfiltered_finds_the_short_route(): + walks = shortest_walks(ADJ, "a", "b", None, 10, via=VIA) + assert [len(w) for w in walks] == [2] + + +def test_a_sanitized_shortest_route_does_not_hide_a_clean_longer_one(): + """The local twin of the inlining result: the search must find the shortest *satisfying* walk, + not filter the shortest walk. If this returns [], the predicate was applied to the replay only + and every local taint refutation is unsound.""" + walks = shortest_walks(ADJ, "a", "b", None, 10, via=VIA, allow_edge=lambda rel, var: var != "tainted") + assert [len(w) for w in walks] == [3], "the clean 3-hop route was not found" + + +def test_a_null_var_hop_is_not_cut_by_a_variable_sanitizer(): + """PARAM_IN carries no var. A predicate that treats None as "not equal to anything" is fine; one + that treats it as unknown-and-therefore-excluded refutes every interprocedural flow.""" + adj = {"a": {"p": [("PY_DDG", "clean", ["ssa"])]}, "p": {"q": [("PY_PARAM_IN", None, None)]}, "q": {"b": [("PY_DDG", "clean", ["ssa"])]}} + walks = shortest_walks(adj, "a", "b", None, 10, via=VIA, allow_edge=lambda rel, var: var != "tainted") + assert [len(w) for w in walks] == [3] + + +def test_a_node_cut_removes_a_whole_callable(): + """The callable-granular sanitizer: every body node under the callable's id prefix is cut.""" + walks = shortest_walks(ADJ, "a", "b", None, 10, via=VIA, allow_node=lambda nid: not nid.startswith("m")) + assert [len(w) for w in walks] == [3] +``` + +- [ ] **Step 2: Run it to make sure it fails** + +Run: `uv run --all-groups pytest tests/analysis/commons/test_taint_semantics.py -k walks -v` +Expected: the first test passes; the other three FAIL with `TypeError: unexpected keyword argument`. + +- [ ] **Step 3: Implement — one helper, used by both passes** + +```python +def shortest_walks(edges, src, dst, depth, limit, *, via, allow_edge=None, allow_node=None) -> List[list]: + ... + def steps(node: str): + """The (destination, label) pairs the search may take from ``node``, filtered. + + Used by **both** passes, and that is the whole point. The BFS records the distance each node + was first reached at and the DFS replay is pinned to ``dist[dst]``, so a predicate applied to + the replay alone would leave ``dist`` describing the unfiltered graph — a sanitized short + route would pin the target length and a clean longer route would never be visited, returning + no walk for a pair that flows. Filtering here makes ``dist`` the shortest *satisfying* + distance, which is what the Neo4j side gets for free from the planner inlining the predicate + into the shortest-path search. + """ + for d, labels in edges.get(node, {}).items(): + if allow_node is not None and not allow_node(d): + continue + kept = [lab for lab in labels if allow_edge is None or allow_edge(lab[0], lab[1])] + if kept: + yield d, kept +``` + +Then rewrite the BFS to iterate `steps(s)` rather than `edges.get(s, ())`, and the DFS's `options` +comprehension to iterate `steps(node)` rather than `edges.get(node, {}).items()`. Apply `allow_node` +to `src` as well: a source inside a cut callable yields no walk at all. + +- [ ] **Step 4: Run the tests** + +Run: `uv run --all-groups pytest tests/analysis/commons/test_taint_semantics.py -v` +Expected: 8 passed. + +- [ ] **Step 5: Prove the existing callers are unaffected** + +Run: `uv run --all-groups pytest tests -q -k "dataflow or paths or slice"` +Expected: unchanged counts. Both predicates default to `None`, so this should be a no-op — if it is +not, `steps()` changed the iteration order, which changes which walks `limit` keeps. + +- [ ] **Step 6: Commit** + +```bash +git add cldk/analysis/commons/graphs.py tests/analysis/commons/test_taint_semantics.py +git commit -m "feat(graphs): shortest_walks filters in both passes, so a sanitized short route cannot hide a clean long one" +``` + +--- + +### Task 3: `sdg_taint_query()` + +**Files:** +- Modify: `cldk/analysis/commons/graphs.py` +- Test: `tests/analysis/commons/test_lifted_helpers.py` + +**Interfaces:** +- Consumes: `path_order(P)` from plan 4a. +- Produces: `sdg_taint_query(P, *, node_label, endpoint_scope="", interior_scope="", projection, + rel_var="r") -> str` — same injection points as `sdg_path_query`, a different statement. + +- [ ] **Step 1: Write the failing test** + +```python +def test_the_taint_query_is_null_safe_and_caps_per_pair(): + """Three properties, each of which has a specific failure mode if absent.""" + q = sdg_taint_query("PY", node_label="PyBodyNode", projection="ref: n.id") + assert "coalesce(r.var, '')" in q, "a bare r.var <> $v refutes every interprocedural flow" + assert "collect(p)[0..$cap]" in q, "a flat LIMIT lets one prolific pair starve the rest" + assert "a.id IN $srcs" in q and "b.id IN $dsts" in q, "taint is m x n in one statement" + assert "allShortestPaths" in q, "a variable-length pattern enumerates trails and will not finish" + + +def test_the_taint_query_groups_by_pair(): + """The result must say which source reached which sink; a caller cannot recover it otherwise.""" + q = sdg_taint_query("PY", node_label="PyBodyNode", projection="ref: n.id") + assert "a.id AS src" in q and "b.id AS dst" in q +``` + +- [ ] **Step 2: Run it; expect ImportError** + +- [ ] **Step 3: Implement** + +```python +def sdg_taint_query(P: str, *, node_label: str, endpoint_scope: str = "", interior_scope: str = "", projection: str, rel_var: str = "r") -> str: + """The multi-source, multi-sink shortest-path statement behind ``taint()``. + + Differs from :func:`sdg_path_query` in four ways, each load-bearing: + + * **``$srcs`` / ``$dsts`` are lists**, so m sources against n sinks is one round trip and one + traversal per pair rather than m*n statements. + * **The sanitizer cut is inside the pattern**, not applied to its rows. Measured on Neo4j + 5.26.30: an ``all()`` over ``relationships(p)`` inlines into the ``ShortestPath`` operator, so + the search returns the shortest *unsanitized* path. Filtering afterwards would report no flow + for a source whose shortest paths are all sanitized and whose next one is not — a false + refutation, the one answer that must never be wrong. Keep the predicate in an inlinable form: + a subquery or an aggregate here reintroduces the exhaustive fallback, which is trail + enumeration. + * **``coalesce(.var, '')``**, because only the DDG relationship carries ``var``. A bare + comparison is ``null`` on every ``PARAM_IN``/``PARAM_OUT``/``CDG``/``SUMMARY`` hop, ``all()`` + over it is ``null``, and the path is silently excluded. + * **The cap is per pair** (``collect(p)[0..$cap]`` after an ordered ``WITH``), because with one + sink and forty sources a flat ``LIMIT`` lets one prolific pair starve the other thirty-nine, + and in triage the per-source witness is the answer. + + ``$cut_callables`` is a list of callable ``can://`` ids; a body node's id *is* + ``@``, so cutting a callable is a prefix test and needs no join. + ``$cut_vars`` is a list of variable names. + """ + a_scope = f" AND {endpoint_scope.replace('n.', 'a.')}" if endpoint_scope else "" + b_scope = f" AND {endpoint_scope.replace('n.', 'b.')}" if endpoint_scope else "" + interior = f" AND all(n IN nodes(p) WHERE {interior_scope})" if interior_scope else "" + return ( + f"MATCH (a:{node_label}) WHERE a.id IN $srcs{a_scope} " + f"MATCH (b:{node_label}) WHERE b.id IN $dsts{b_scope} " + "MATCH p = allShortestPaths((a)-[:{rels}*1..{depth}]->(b)) " + "WHERE all(n IN nodes(p) WHERE NOT any(q IN $cut_callables WHERE n.id STARTS WITH q))" + + interior + " " + f"AND all({rel_var} IN relationships(p) WHERE NOT coalesce({rel_var}.var, '') IN $cut_vars) " + "WITH a, b, p, " + path_order(P) + " AS key ORDER BY length(p), key " + "WITH a, b, collect(p)[0..$cap] AS ps " + "UNWIND ps AS p " + f"RETURN a.id AS src, b.id AS dst, [n IN nodes(p) | {{{{{projection}}}}}] AS ns, " + f"[{rel_var} IN relationships(p) | {{{{via: type({rel_var}), var: {rel_var}.var, prov: {rel_var}.prov}}}}] AS rs" + ) +``` + +- [ ] **Step 4: Run the tests; expect pass** + +- [ ] **Step 5: Verify the plan on a real server before trusting it** + +Start a disposable container (**never port 7687 — it is an ssh tunnel on this machine**; 7700 was +free at design time): + +```bash +podman run -d --rm --name cldk-taint-plan -p 7700:7687 -e NEO4J_AUTH=neo4j/taintprobe123 docker.io/library/neo4j:5 +``` + +Create the adversarial graph from the spec's §7, then `EXPLAIN` the generated statement and confirm: +the predicates appear **inside** the `ShortestPath` operator's `Details`; there is no +`ExhaustiveShortestPath` and no `AntiConditionalApply`; and the zero-row case plans the same way. +Record the plan in the PR. Stop the container when done. + +- [ ] **Step 6: Commit** + +```bash +git add cldk/analysis/commons/graphs.py tests/analysis/commons/test_lifted_helpers.py +git commit -m "feat(graphs): sdg_taint_query, with the sanitizer cut inside the pattern" +``` + +--- + +### Task 4: Selector resolution and the T6 agreement + +**Files:** +- Modify: `cldk/analysis/commons/resolve.py` +- Test: `tests/analysis/commons/test_taint_semantics.py` + +**Interfaces:** +- Consumes: each backend's `resolve_value(name, *, within) -> SliceNode` and + `resolve_callable(name, *, in_class=None, in_module=None) -> SliceNode`. +- Produces: + +```python +def resolve_sanitizers(sanitizers, *, resolve_value, resolve_callable) -> Tuple[List[str], List[str]]: + """Returns (cut_vars, cut_callable_ids).""" +``` + +- [ ] **Step 1: Write the failing test** + +```python +import pytest + +from cldk.analysis.commons.resolve import resolve_sanitizers +from cldk.utils.exceptions.exceptions import SelectorNotInGraph + + +def test_a_pair_cuts_a_variable_and_a_bare_name_cuts_a_callable(): + vars_, callables = resolve_sanitizers( + [("token", "Handler.handle"), "html.escape"], + resolve_value=lambda n, within: _node(ref=f"can://app/python/h.py/handle@3:4", name=n), + resolve_callable=lambda n, **_: _node(ref="can://app/python/h.py/escape", name=n), + ) + assert vars_ == ["token"] + assert callables == ["can://app/python/h.py/escape"] + + +def test_a_bare_name_that_is_not_a_callable_raises_rather_than_cutting_a_variable(): + """T6. One signature carries two semantics, so the accident of omitting `within` must be loud — + silently cutting the other thing is how a caller gets a confident wrong answer.""" + with pytest.raises(SelectorNotInGraph): + resolve_sanitizers(["token"], resolve_value=_unused, resolve_callable=_raises) + + +def test_a_pair_whose_name_is_a_callable_raises_too(): + with pytest.raises(SelectorNotInGraph): + resolve_sanitizers([("html.escape", "Handler.handle")], resolve_value=_raises, resolve_callable=_unused) +``` + +Write `_node`, `_raises` and `_unused` as small local helpers; `_node` builds a `SliceNode` with the +fields that model requires. + +- [ ] **Step 2: Run it; expect ImportError** + +- [ ] **Step 3: Implement** + +Route by shape, and let each resolver's own failure be the error — do not catch one and try the +other, which is exactly the silent fallback T6 forbids. + +- [ ] **Step 4: Run the tests; expect 3 passed** + +- [ ] **Step 5: Commit** + +```bash +git add cldk/analysis/commons/resolve.py tests/analysis/commons/test_taint_semantics.py +git commit -m "feat(resolve): sanitizer selectors route by shape, and a mismatch raises" +``` + +--- + +### Task 5: `taint()` on the three ABCs + +The signature, the argument checks, the gates, the resolution, and the per-pair verdict assembly — +once per language, on the class where that language's policy already lives. The traversal is an +abstract method the concrete backends supply. + +**Files:** +- Modify: `cldk/analysis/python/backend.py` (near `paths_between`, ~889) +- Modify: `cldk/analysis/java/backend.py` (~1832) +- Modify: `cldk/analysis/typescript/backend.py` (~811) +- Test: `tests/analysis/{python,java,typescript}/test_*_taint.py` (new, offline) + +**Interfaces:** +- Produces on each ABC: + +```python +def taint(self, sources, sinks, sanitizers=(), *, depth=None, max_paths=DEFAULT_MAX_PATHS) -> TaintResult +``` +and an abstract `_taint_walk(self, srcs, dsts, *, cut_vars, cut_callables, depth, max_paths)` +returning `(rows_by_pair, unresolved)`. + +- [ ] **Step 1: Write the failing tests** — order of checks, gates, and the depth rule: + +```python +def test_arguments_are_judged_before_any_resolution(backend): + """A malformed depth is a ValueError before a name is looked up, as on every sibling accessor.""" + with pytest.raises(ValueError): + backend.taint([("x", "f")], [("y", "g")], depth=0) + + +def test_empty_sources_or_sinks_is_refused_not_answered_empty(backend): + """cone_sinks already refuses the two ways of naming nothing; an empty answer here would be + indistinguishable from a refutation.""" + with pytest.raises(ValueError): + backend.taint([], [("y", "g")]) + with pytest.raises(ValueError): + backend.taint([("x", "f")], []) + + +def test_a_bounded_depth_yields_no_exhausted_pair(backend): + """Condition 1 of the spec's §5, as a rule and not a tendency.""" + r = backend.taint([("x", "f")], [("y", "g")], depth=5) + assert r.exhausted == [] + + +def test_the_same_position_pair_is_skipped_with_a_diagnostic_not_raised(backend): + """A deliberate divergence from paths_between, which raises: raising would discard a forty-pair + batch, and a caller assembling sources programmatically will hit this by accident.""" + r = backend.taint([("x", "f")], [("x", "f")]) + assert r.paths == [] and r.exhausted == [] + assert any("same position" in d.message for d in r.unresolved) +``` + +- [ ] **Step 2: Run them; expect `AttributeError: taint`** + +- [ ] **Step 3: Implement on the Python ABC first**, in this order — the order is the contract: + +```python +check_depth(depth); check_max_paths(max_paths) # 1. arguments, before any round trip +# 2. refuse empty sources/sinks +self._require_dataflow() # 3. level gate +src_nodes = [self.resolve_value(n, within=w) for n, w in sources] # 4. resolution (may raise) +dst_nodes = [...] +cut_vars, cut_callables = resolve_sanitizers(...) # 5. T6 +# 6. Java only: self._require_connected_ports("taint") +rows, unresolved = self._taint_walk(...) # 7. the walk +# 8. assemble: group rows by (src, dst); exhausted = pairs with no rows and no diagnostic, +# ONLY when depth is None; complete = nothing truncated and not unresolved +``` + +- [ ] **Step 4: Run the Python offline test; expect pass. Then repeat for Java and TypeScript.** + +Java inserts `_require_connected_ports("taint")` after resolution — the existing accessors judge +arguments and names *first* and mention the gap second, and `taint` must match. + +- [ ] **Step 5: Commit** (one commit per language keeps the review reviewable) + +--- + +### Task 6: The Python walks — Neo4j and local + +**Files:** +- Modify: `cldk/analysis/python/neo4j/neo4j_backend.py` (`_TAINT` constant + `_taint_walk`) +- Modify: `cldk/analysis/python/codeanalyzer/codeanalyzer.py` (`_taint_walk` via `shortest_walks`) +- Test: `tests/analysis/python/test_python_taint.py`, and the backend-contract test + +- [ ] **Step 1: Write the parity test first** — both backends, same fixture, same answer: + +```python +def test_both_backends_agree_on_a_known_flow_and_on_a_refutation(local, graph): + for backend in (local, graph): + r = backend.taint(SOURCES, SINKS) + assert [ [h.via for h in p.hops] for p in r.paths ] == EXPECTED_HOPS + assert sorted(r.exhausted) == sorted(EXPECTED_EXHAUSTED) +``` + +Backends agreeing on a *predicate* is not agreeing on the *set* — assert the hop lists and the +exhausted pairs, not just truthiness. + +- [ ] **Step 2: Run; expect failure at `_taint_walk` not implemented** + +- [ ] **Step 3: Implement the Neo4j walk** — `_TAINT = sdg_taint_query("PY", ...)` with the same + arguments plan 4a's Task 3 used for `_PATHS`, and a `_run` passing + `srcs`, `dsts`, `cut_vars`, `cut_callables`, `cap=max_paths + 1`. + +**Keep `_TAINT` a plain class-level string starting with `MATCH`**, or it drops out of +`tests/analysis/python/test_neo4j_multi_application_scope.py`'s statement audit. Then add `"_TAINT"` +to that file's `test_the_audit_sees_the_dataflow_statements_too` list, so it is covered explicitly +like its siblings. + +- [ ] **Step 4: Implement the local walk** via `shortest_walks(..., allow_edge=..., allow_node=...)`, + building the predicates from `cut_vars` / `cut_callables`. + +- [ ] **Step 5: Per-pair starvation (spec §7 test 3).** One prolific pair and one sparse pair in the + same call, `max_paths=2`: + +```python +def test_a_prolific_pair_does_not_starve_a_sparse_one(graph): + """The cap is per pair. With a flat LIMIT the prolific pair fills it and the sparse pair's only + witness is dropped -- reported as no flow, which in triage closes a live alert.""" + r = graph.taint([PROLIFIC_SOURCE, SPARSE_SOURCE], [SINK], max_paths=2) + pairs = {(p.hops[0].frm.ref, p.hops[-1].to.ref) for p in r.paths} + assert len(pairs) == 2, "one pair returned no witness" + assert r.complete is False, "truncation must be reported, not silent (E5)" +``` + +Pick `PROLIFIC_SOURCE` by measurement, not guess: on odoo the heaviest callable holds 1.39M of +5.13M DDG edges, so a source inside it has many shortest paths. + +- [ ] **Step 6: Run the parity test; expect pass** + +- [ ] **Step 7: Commit** + +--- + +### Task 7: TypeScript's two walks, and Java's one + +**Files:** +- Modify: `cldk/analysis/typescript/neo4j/neo4j_backend.py`, `.../codeanalyzer/codeanalyzer.py` +- Modify: `cldk/analysis/java/backend.py` (the single shared walk) and `cldk/analysis/java/neo4j/neo4j_backend.py` if it needs the rows +- Test: `tests/analysis/typescript/test_typescript_taint.py`, `tests/analysis/java/test_java_taint.py` + +- [ ] **Step 1: TypeScript, mirroring Task 6.** Its `_PATHS` scopes the interior only, so `_TAINT` + passes `interior_scope=_scoped("n")` and no `endpoint_scope`. +- [ ] **Step 2: Java — one implementation.** `JNeo4jBackend` rebuilds the canonical `JApplication` and + answers from it, so the walk goes on `JavaAnalysisBackend` over the adjacency, via + `shortest_walks`. Confirm before writing: if `grep -n "def _value_paths" + cldk/analysis/java/neo4j/neo4j_backend.py` shows a Cypher path runner, Java has two after all + and needs `_TAINT` too. +- [ ] **Step 3: Java degradation test.** On an analysis with a disconnected port lattice, `taint()` + raises `CodeanalyzerExecutionException` and the message names `taint`. Reuse + `tests/analysis/java/test_java_degradation.py`'s pattern — and note that file's lesson: it + *creates* the degraded condition rather than assuming the host lacks something. +- [ ] **Step 4: Self-loop regression (spec §7 test 6).** A relationship bound twice in one `MATCH` + is silently dropped by Cypher's relationship-uniqueness rule — that is `#349`, which cost odoo + 64,702 `PY_DDG` self-loops and reported `complete=True` while doing it. Java's graph has 978 + self-loops and Python's odoo 64,702, so both can test it: a source and sink one self-loop apart + must return a one-hop path, not an empty result. + +```python +def test_a_self_loop_is_a_path(graph): + r = graph.taint([SELF_LOOP_VALUE], [SELF_LOOP_VALUE_AS_SINK]) + assert [len(p.hops) for p in r.paths] == [1] +``` + + `sdg_taint_query` binds each relationship once, so this should pass as written — the test + exists because the shape is easy to reintroduce, and because 3b required it for the same reason. + +- [ ] **Step 5: Every language's backend-contract test lists `taint`**, so a backend that forgets it + fails rather than inheriting an abstract method. +- [ ] **Step 6: Commit per language** + +--- + +### Task 8: The facades, the corpus check, and the docs + +**Files:** +- Modify: `cldk/analysis/python/python_analysis.py` (after `flows_to_argument`, ~1371) +- Modify: `cldk/analysis/java/java_analysis.py` (~1637), `cldk/analysis/typescript/typescript_analysis.py` +- Modify: `docs/agent-api-reference.md`, `docs/skills/using-cldk/SKILL.md`, `CLAUDE.md`, `CHANGELOG.md` +- Test: the live suites + +- [ ] **Step 1: Check the corpus before writing the sanitizer tests.** + +*A corpus that cannot exercise a case makes a green suite meaningless* — this project has paid for +that three times (Java `get_test_methods()` agreed across backends only because daytrader8 has zero +`@Test` methods; TypeScript's `implements_types` subtraction was a no-op on 0 of 207 classes; +`$anon$N` collisions were invisible until ThingsBoard). So: find in the reference graphs (a) a +validating guard whose variable is on a flow, and (b) a transforming sanitizer call on a flow. Record +what you found, per language. **If either is absent, building the fixture is part of this task** — +a sanitizer test on a corpus with no sanitizer asserts nothing. + +- [ ] **Step 2: Add the facade method** on all three, delegating to the backend, with a docstring + carrying a runnable example and the `exhausted` contract. Copy `paths_between`'s docstring + shape; it is the house style. + +- [ ] **Step 3: Run the three live suites and record three numbers each** — a found flow, an + exhausted pair, and a forty-source batch. Exhaustion is triage's common case and must be + measured as its own number, not inferred from the found case. + +- [ ] **Step 4: Prove the tripwire is live.** Delete the `coalesce` from `sdg_taint_query`, run + `tests/analysis/commons/test_taint_semantics.py`, confirm it **fails**, restore it. A tripwire + nobody has seen fail is a test whose failure mode is unverified. + +- [ ] **Step 5: Docs.** `CLAUDE.md`'s per-language notes gain `taint` (and any degradation it + inherits); `CHANGELOG.md` gets the entry under a heading matching the release tag literally, or + the release workflow refuses to publish. + +- [ ] **Step 6: Full gate** + +```bash +uv run --all-groups make test +``` +Expected: green, coverage above the floor. This is the gate the release workflow runs, and a red +`make test` **deletes the tag** it was cut from. + +- [ ] **Step 7: Commit and open the PR closing #382** From fb626d7a9e6496824ace5dea522f3944869a470e Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Wed, 9 Sep 2026 06:40:21 -0400 Subject: [PATCH 05/65] refactor(graphs): lift via_case and path_order out of the three Neo4j backends Leg 4a, Task 1 of docs/design/plans/2026-09-09-leg-4a-shared-path-skeleton.md. Additive only: the three backends still carry their own _VIA_CASE and _PATH_ORDER, and Task 3 replaces them. Nothing changes behaviour yet. The test corrects a claim the plan and spec both got wrong. I had written that the two constants are byte-identical across the three backends. They are not: three distinct values each, because every CASE arm names its own language's relationship types (J_DDG at 244 characters against PY_DDG at 250). What was triplicated is the *expression*; the result was always per-language, which is exactly why the lifted forms take P and are functions rather than constants. So the test asserts the honest property in two parts. Each backend's constant is reproduced byte-identically by via_case(P) / path_order(P) -- that is the safety property, since a changed CASE arm changes which word a hop is reported under and a changed ORDER BY term changes which paths max_paths keeps. And the three results differ in the relationship prefix and in nothing else, checked by substituting the prefix. A future divergence beyond the prefix fails there rather than being absorbed into a parameter silently. path_order's docstring now also records why its coalesce is load-bearing rather than defensive: of the five SDG relationship types only {P}_DDG carries var, so the bare property is null on every control, argument, return and summary hop, and a null term would make the whole sort key null and the ordering arbitrary. That is the same fact leg 4b's sanitizer predicate turns on, and it was already being handled correctly here. Both names are registered in this file's LIFTED table, so the existing "lives in commons, and python re-exports the same object" audit covers them alongside their siblings. --- cldk/analysis/commons/graphs.py | 44 ++++++++++++++ tests/analysis/commons/test_lifted_helpers.py | 57 ++++++++++++++++++- 2 files changed, 100 insertions(+), 1 deletion(-) diff --git a/cldk/analysis/commons/graphs.py b/cldk/analysis/commons/graphs.py index 76295ae1..c61939ef 100644 --- a/cldk/analysis/commons/graphs.py +++ b/cldk/analysis/commons/graphs.py @@ -157,6 +157,50 @@ def via_table(P: str) -> dict[str, str]: } +#: The two fragments every SDG path statement is built from. Both were written out identically in +#: all three Neo4j backends, each computing its own language's value from that backend's ``VIA`` +#: table -- so the *expression* was triplicated while the *result* is per-language, which is why +#: these are functions of ``P`` and not constants. The strings are not interchangeable: they differ +#: in the relationship prefix and in nothing else. + + +def via_case(P: str) -> str: + """The Cypher ``CASE`` mapping a hop's relationship type to the caller's word for it (E6). + + Computed in Cypher rather than in Python because :func:`path_order`'s ``ORDER BY`` sorts by the + same vocabulary :func:`hop_sort_key` sorts by. Ordering by the raw ``type(r)`` instead would be + just as deterministic and a *different* order (``PY_CDG`` before ``PY_DDG`` before + ``PY_PARAM_IN``, against ``argument`` before ``control`` before ``data``), so two backends of one + language would truncate ``max_paths`` to different witnesses. + """ + return "CASE type(relationships(p)[i]) " + " ".join(f"WHEN '{rel}' THEN '{word}'" for rel, word in via_table(P).items()) + " ELSE type(relationships(p)[i]) END" + + +def path_order(P: str) -> str: + """One sort key per path, ordered exactly as Python would order the tuple :func:`hop_sort_key` + builds -- so a truncation at ``max_paths`` is a prefix of the documented total order rather than + whichever paths the database happened to return first. + + The separator is ``\\u0001`` rather than ``|`` for one reason and only that reason: string + comparison agrees with field-by-field comparison **only** when the separator sorts below every + character a field can hold, and ``|`` (0x7C) sorts *above* every lowercase letter, which would + order a variable ``x`` after ``xy``. + + ``coalesce(relationships(p)[i].var, '')`` is load-bearing, not defensive: of the five SDG + relationship types only ``{P}_DDG`` carries ``var``, so the bare property is ``null`` on every + control, argument, return and summary hop -- and a ``null`` term would make the whole key + ``null`` and the ordering arbitrary. + + ``elementId`` is each hop's last field and breaks the tie between parallel relationships a caller + cannot tell apart. It is stable for repeated calls against one database and means nothing outside + it, which is why it is last and why nothing above depends on it. + """ + return ( + "reduce(k = '', i IN range(0, length(p) - 1) | k + " + via_case(P) + " + '\\u0001' + coalesce(relationships(p)[i].var, '') " + "+ '\\u0001' + nodes(p)[i + 1].id + '\\u0001' + elementId(relationships(p)[i]) + '\\u0001')" + ) + + def hop_sort_key(hops: Sequence[PathHop]) -> Tuple: """The order two paths are compared in, in the caller's *own* vocabulary. diff --git a/tests/analysis/commons/test_lifted_helpers.py b/tests/analysis/commons/test_lifted_helpers.py index 69745402..cc259755 100644 --- a/tests/analysis/commons/test_lifted_helpers.py +++ b/tests/analysis/commons/test_lifted_helpers.py @@ -7,7 +7,7 @@ "reject_bare_string", "check_selector", "encode_cursor", "decode_cursor", "keyset_where", "cursor_params", "edge_page", "EdgeOrder"], "cldk.analysis.commons.graphs": ["bounded_subgraph", "hop_sort_key", "slice_resolved", "cone_sinks", - "as_slice_node", "flow_path", "edge_sort_key", "sdg_rels", "sdg_rel_pattern", "via_table"], + "as_slice_node", "flow_path", "edge_sort_key", "sdg_rels", "sdg_rel_pattern", "via_table", "via_case", "path_order"], "cldk.analysis.commons.keys": ["resolve_module_key", "scope_paths", "call_graph_scope", "module_key_of", "module_dotted"], } @@ -191,3 +191,58 @@ def test_every_in_memory_reaches_routes_through_the_shared_rule(module, owner): source = inspect.getsource(getattr(importlib.import_module(module), owner).reaches) assert "call_reaches(" in source, f"{owner}.reaches does not use the shared rule" assert "nx.descendants" not in source, f"{owner}.reaches still asks descendants, which excludes the source" + + +# ---------------------------------------------------------------------------------------------- +# Leg 4a, Task 1: the SDG path Cypher's two shared fragments. +# +# The three Neo4j backends each carried an identical *expression* computing a per-language *value*: +# ``_VIA_CASE`` and ``_PATH_ORDER`` are built from that backend's ``VIA`` table, so the strings +# differ (``J_DDG`` against ``PY_DDG``) while the code producing them did not. That is what makes +# them liftable as functions of ``P`` rather than as constants. +# ---------------------------------------------------------------------------------------------- + + +def _path_backends(): + """The three backends and their relationship-type prefixes, imported lazily like every other + backend reference in this file so a missing install extra cannot fail collection.""" + from cldk.analysis.java.neo4j.neo4j_backend import JNeo4jBackend + from cldk.analysis.python.neo4j.neo4j_backend import PyNeo4jBackend + from cldk.analysis.typescript.neo4j.neo4j_backend import TSNeo4jBackend + + return [("PY", PyNeo4jBackend), ("J", JNeo4jBackend), ("TS", TSNeo4jBackend)] + + +@pytest.mark.parametrize("P", ["PY", "J", "TS"]) +def test_via_case_reproduces_each_backends_constant(P): + """Byte-identical, because a changed ``CASE`` arm changes which word a hop is reported under and + a changed ``ORDER BY`` term changes which paths ``max_paths`` keeps.""" + from cldk.analysis.commons.graphs import via_case + + backend = dict(_path_backends())[P] + assert via_case(P) == backend._VIA_CASE + + +@pytest.mark.parametrize("P", ["PY", "J", "TS"]) +def test_path_order_reproduces_each_backends_constant(P): + from cldk.analysis.commons.graphs import path_order + + backend = dict(_path_backends())[P] + assert path_order(P) == backend._PATH_ORDER + + +def test_the_three_constants_differ_only_in_the_relationship_prefix(): + """What is and is not shared, stated exactly. + + The values are **not** interchangeable -- three distinct strings, because each names its own + language's relationship types. What was duplicated is the expression, and the only difference + between the results is the prefix, which is why one function of ``P`` replaces three constants. + A future divergence beyond the prefix would fail here rather than being absorbed silently. + """ + from cldk.analysis.commons.graphs import path_order, via_case + + assert len({via_case(P) for P in ("PY", "J", "TS")}) == 3 + assert len({path_order(P) for P in ("PY", "J", "TS")}) == 3 + for P in ("J", "TS"): + assert via_case(P).replace(f"{P}_", "PY_") == via_case("PY") + assert path_order(P).replace(f"{P}_", "PY_") == path_order("PY") From d1829205bbf6c671e59278fe78220205ddf03fc3 Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Wed, 9 Sep 2026 06:42:08 -0400 Subject: [PATCH 06/65] docs: #195 is fixed upstream, and correct the byte-identical claim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two corrections, both found by doing the work rather than reading it. **The analyzers moved.** codeanalyzer-python 1.5.1 (#196), codeanalyzer-java 3.1.2 (#250) and codeanalyzer-typescript 1.5.3 (#197) all fix #195 in lockstep: PARAM_IN and PARAM_OUT now carry the bound formal's var. §2's fourth fact was written against the rc.4 pins and is now generation-dependent, so it says so. The null-safe predicate stays mandatory for three reasons that survive the fix: CDG and SUMMARY still carry no properties at all; the fix writes prune({"var": e.var}) so the key is absent whenever the formal has no variable, and present-usually is not present-always; and both Neo4j backends attach to graphs emitted at floors below these releases, where the param legs carry nothing. What the fix changes is precision, in the direction #195 argued for: a variable-granular cut can now follow a value across a call boundary, because the var sits on the param edge rather than only on the DDG hops either side. On an older graph the degradation points the safe way — coalesce yields '', the hop is kept, and a sanitized path is reported as a flow, so the caller investigates something already sanitized. That is the opposite of the null-logic trap, which cuts the path and reports a refutation. It is measurable from data, so §6 reports it rather than leaving it silent. §8 no longer claims no pin moves. Three patch releases now exist that improve taint()'s headline mechanism without enabling it, so moving the pins is a judgement call the spec states and deliberately does not make — no code differs either way, because the coalesce stays. **The byte-identical claim was wrong.** Plan 4a's Task 1 asserted the three backends' _VIA_CASE and _PATH_ORDER were identical strings. Writing the test found three distinct values each: every CASE arm names its own language's relationship types, so J_DDG gives 244 characters against PY_DDG's 250. What was triplicated is the expression; the result was always per-language, which is why the lifted forms take P. The plan now asserts the honest property — each constant reproduced byte-identically from P, and the three differing in the prefix and nothing else. Task 1's expectations are corrected too: 22 passed, not 7, and the run needs --no-cov because one test file exercises ~42% of the package against a 50% floor and reports a coverage failure over a green suite. --- .../2026-09-09-leg-4a-shared-path-skeleton.md | 34 +++++++++++---- docs/design/plans/2026-09-09-leg-4b-taint.md | 11 +++-- docs/design/specs/2026-09-09-leg-4-taint.md | 41 +++++++++++++++---- 3 files changed, 67 insertions(+), 19 deletions(-) diff --git a/docs/design/plans/2026-09-09-leg-4a-shared-path-skeleton.md b/docs/design/plans/2026-09-09-leg-4a-shared-path-skeleton.md index 63d19b4b..c80192e6 100644 --- a/docs/design/plans/2026-09-09-leg-4a-shared-path-skeleton.md +++ b/docs/design/plans/2026-09-09-leg-4a-shared-path-skeleton.md @@ -93,11 +93,21 @@ def test_path_order_reproduces_the_backends_constant(P, backend): assert path_order(P) == backend._PATH_ORDER -def test_the_three_backends_agreed_before_the_lift(): - """The premise of lifting these two rather than parameterising them: all three were already - identical. If this ever fails, one backend diverged and the lift is hiding it.""" - assert len({b._VIA_CASE for _, b in BACKENDS}) == 1 - assert len({b._PATH_ORDER for _, b in BACKENDS}) == 1 +def test_the_three_constants_differ_only_in_the_relationship_prefix(): + """What is and is not shared, stated exactly. + + **Corrected while implementing.** An earlier draft of this plan asserted the three constants were + byte-identical. They are not: three distinct values each, because every CASE arm names its own + language's relationship types (``J_DDG`` at 244 characters against ``PY_DDG`` at 250). What was + triplicated is the *expression*; the result was always per-language, which is exactly why the + lifted forms take ``P`` and are functions rather than constants. A divergence beyond the prefix + fails here rather than being absorbed into a parameter silently. + """ + assert len({via_case(P) for P, _ in BACKENDS}) == 3 + assert len({path_order(P) for P, _ in BACKENDS}) == 3 + for P in ("J", "TS"): + assert via_case(P).replace(f"{P}_", "PY_") == via_case("PY") + assert path_order(P).replace(f"{P}_", "PY_") == path_order("PY") ``` - [ ] **Step 2: Run it to make sure it fails** @@ -138,10 +148,18 @@ def path_order(P: str) -> str: - [ ] **Step 4: Run the tests and make sure they pass** -Run: `uv run --all-groups pytest tests/analysis/commons/test_lifted_helpers.py -v` -Expected: 7 passed. +Run: `uv run --all-groups pytest tests/analysis/commons/test_lifted_helpers.py -q --no-cov` +Expected: 22 passed. + +**Pass `--no-cov`.** One test file exercises ~42% of the package against a 50% `--cov-fail-under` +floor, so the run reports `FAIL Required test coverage of 50% not reached` on top of a green suite. +That is the floor, not a failure. + +Also register both names in this file's existing `LIFTED` table (the +`cldk.analysis.commons.graphs` list), so the "lives in commons, and `python.backend` re-exports the +same object" audit covers them alongside their siblings. -If `test_path_order_reproduces_the_backends_constant` fails, diff the two strings character by +If `test_path_order_reproduces_each_backends_constant` fails, diff the two strings character by character — the likely cause is the implicit string concatenation in the original spanning two source lines with a space between `") "` and `"+ '\\u0001'"`. Reproduce it exactly; do not "tidy" the spacing. diff --git a/docs/design/plans/2026-09-09-leg-4b-taint.md b/docs/design/plans/2026-09-09-leg-4b-taint.md index d3d8d579..b349d4ec 100644 --- a/docs/design/plans/2026-09-09-leg-4b-taint.md +++ b/docs/design/plans/2026-09-09-leg-4b-taint.md @@ -31,10 +31,13 @@ Task 3 here extends `sdg_path_query`'s neighbourhood and assumes it exists. - **The cut happens before the bound, on both sides.** This is the spec's T7 and it is a correctness requirement, not a performance one. See Task 2 for why the obvious local implementation gets it wrong. -- **`coalesce(r.var, '')` is mandatory** in every variable predicate. Only the DDG relationship - carries `var`; `PARAM_IN`, `PARAM_OUT`, `CDG` and `SUMMARY` carry none, so a bare `r.var <> $v` - is `null` on every cross-call hop and excludes the path. `path_order()` already spells the - `coalesce`, so this is the file's existing idiom. +- **`coalesce(r.var, '')` is mandatory** in every variable predicate, and it stays mandatory even + though `codeanalyzer-python#195` is now fixed. As of python 1.5.1 / java 3.1.2 / typescript 1.5.3 + both param legs carry the bound formal's `var` — but `CDG` and `SUMMARY` still carry no properties, + the fix writes `prune({"var": e.var})` so the key is absent whenever the formal has none, and both + Neo4j backends attach to graphs emitted at floors below those releases. A bare `r.var <> $v` is + `null` on any such hop, `all()` over it is `null`, and the path is silently excluded. + `path_order()` already spells the `coalesce` for the same reason, so this is the existing idiom. - **`depth is None` is a precondition of `exhausted`.** When `depth` is not `None`, `exhausted` is empty. Not "usually empty" — empty. - `taint()` needs **L4** (`PARAM_IN`/`PARAM_OUT` are L4 edges) and, on Java, a **connected port diff --git a/docs/design/specs/2026-09-09-leg-4-taint.md b/docs/design/specs/2026-09-09-leg-4-taint.md index ac0a230a..1b1c2801 100644 --- a/docs/design/specs/2026-09-09-leg-4-taint.md +++ b/docs/design/specs/2026-09-09-leg-4-taint.md @@ -54,10 +54,30 @@ at 0.054 s against 0.046 s. Java re-measured the same shape on ThingsBoard's lar backward cone: **9.6 / 9.9 / 9.9 ms with the predicate against 9.2 / 9.5 / 9.1 without, identical rows.** -**Only the DDG relationship carries `var`.** `codeanalyzer-python` 1.5.0's projection writes -`{P}_PARAM_IN` and `{P}_PARAM_OUT` with no properties at all (`project.py:260-270`), and -`{P}_CDG`/`{P}_SUMMARY` likewise. So `r.var` is null on four of the five relationship types, -**including both interprocedural ones**. §7 turns this into the leg's first test. +**`var` is absent from some SDG relationship types, and which ones depends on the analyzer +generation.** As designed against the rc.4 pins, `codeanalyzer-python` 1.5.0 wrote `{P}_PARAM_IN` and +`{P}_PARAM_OUT` with no properties at all, so `r.var` was null on four of the five types — including +both interprocedural ones. **That was filed as `codeanalyzer-python#195` and fixed in lockstep across +all three analyzers** (python 1.5.1 / #196, java 3.1.2 / #250, typescript 1.5.3 / #197): both param +legs now carry the bound formal's `var`. + +**The null-safe predicate remains mandatory**, for three reasons that survive the fix: + +1. `{P}_CDG` and `{P}_SUMMARY` still carry no properties at all — two of five types. +2. The fix writes `prune({"var": e.var})`, so the key is absent on any param edge whose formal has no + variable. Present-usually is not present-always, and a predicate cannot tell the difference. +3. Both Neo4j backends attach to graphs they did not emit, at floors below these releases. A graph + from 1.5.0 has no param `var` at all. + +What the fix *does* change is precision, in the direction `#195` argued for: a variable-granular +sanitizer can now follow a value **across a call boundary**, because the `var` is on the param edge +rather than only on the intraprocedural DDG hops either side of it. + +The degradation on an older graph is real but points the safe way. There, `coalesce(r.var, '')` yields +`''`, which is not in `$cut_vars`, so the hop is kept and a sanitized path is **reported as a flow** — +the caller investigates something already sanitized. Annoying, not dangerous, and the opposite of the +null-logic trap, which cuts the path and reports a refutation. It is measurable from the data (does +any `{P}_PARAM_IN` carry `var`?), so §6 reports it rather than leaving it silent. **The Cypher is triplicated; the Python-side helpers are not.** `hop_sort_key` and `flow_path` already live in `commons/graphs.py`. `_VIA_CASE` and `_PATH_ORDER` are **byte-identical** across all @@ -308,9 +328,16 @@ mid-rc: families, and Java's id-derived owner recovery adopted by Python and TypeScript, each with its own before/after measurement. -No analyzer release is required, and no version pin moves: rc.4's pins (`codeanalyzer-java` 3.1.1, -`codeanalyzer-python` 1.5.0, `codeanalyzer-typescript` 1.5.2) already carry everything `taint()` -reads. +No analyzer release is required — `taint()` reads only what rc.4's pins already emit. But three +patch releases landed after this spec was first written, all fixing `#195` in lockstep +(**python 1.5.1**, **java 3.1.2**, **typescript 1.5.3**), and they improve `taint()`'s sanitizer +precision across call boundaries rather than enabling anything. + +**Moving the pins is therefore a judgement call, and this spec does not make it.** Arguments for: +`taint()`'s headline mechanism gets more precise, and a variable-granular cut is the one the spec's +T5 says only a variable-granular sanitizer can sever. Argument against: it is a pin move during an rc +for a precision gain, not a correctness fix, and the floors would move with it. Either way the +null-safe predicate stays, so no code differs between the two choices. ## 9. Out of scope From a5ee1acbadb39ef86eace0c33a35ae58052b6355 Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Wed, 9 Sep 2026 06:55:02 -0400 Subject: [PATCH 07/65] refactor(graphs): add sdg_path_query, reproducing all three _PATHS byte-identically Leg 4a, Task 2. Still additive: the three backends keep their own _PATHS and Task 3 replaces them, so nothing changes behaviour yet. Five things differ between the three statements, not the three the spec first assumed, and all five are parameters: node label PyBodyNode / JBodyNode / CanNode:TSBodyNode endpoint scope none / n.id STARTS WITH $prefix / none interior scope none / n.id STARTS WITH $prefix / (n.id STARTS WITH $p) projection Python adds n.var, TypeScript also n.of, Java neither relationship r, except Java's e The last one was found while writing this and is the reason byte-identity is achievable at all. It is semantically inert, so it is a parameter rather than a normalisation: byte-identity is this step's entire safety property, and these are shipped statements on a release branch mid-rc. Normalising a variable name would forfeit the property for nothing. The two empty scopes are also not oversights, and the docstring says so where someone tempted to "fix" them will read it. Python's statements are keyed by a body-node id, which embeds the application, and tests/analysis/python/test_neo4j_multi_application_scope.py sanctions id-keying as one of four scope kinds for a measured reason: the predicate would mean testing 195,784 reached nodes against a list. Java writes it anyway under the stricter rule that the audit judges the predicate written rather than the graph attached, for a measured ~4%. A test pins which backend scopes what, separately from the equality assertions, so an edit that moved Python onto the prefix predicate fails with a reason instead of quietly changing a measured decision. One test of my own was wrong on the first run and is corrected here rather than papered over: it asserted the formatted statement contains no braces. It must contain single ones -- Cypher map literals need them, so {{id:$src}} resolving to {id:$src} is the point. The property worth asserting is that no *doubled* brace survives, which would mean an unresolved escape and a statement the server would reject. --- cldk/analysis/commons/graphs.py | 43 ++++++++++ tests/analysis/commons/test_lifted_helpers.py | 78 +++++++++++++++++++ 2 files changed, 121 insertions(+) diff --git a/cldk/analysis/commons/graphs.py b/cldk/analysis/commons/graphs.py index c61939ef..0f912787 100644 --- a/cldk/analysis/commons/graphs.py +++ b/cldk/analysis/commons/graphs.py @@ -201,6 +201,49 @@ def path_order(P: str) -> str: ) +def sdg_path_query(P: str, *, node_label: str, endpoint_scope: str = "", interior_scope: str = "", projection: str, rel_var: str = "r") -> str: + """The shortest-path statement every Neo4j backend issues for ``paths_between``. + + ``allShortestPaths`` and not a plain variable-length match. A variable-length pattern enumerates + *trails*, the shape that does not terminate on a real dependence graph (killed at 600 s on odoo); + ``allShortestPaths`` is a bidirectional BFS and answers the pathological cases in milliseconds -- + 0.08 s for an unreachable pair seeded in a 440,270-node forward cone. ``$cap`` is ``max_paths + 1`` + at the call site, so one extra row reports the truncation rather than a second ``count(p)`` + traversal for a number the caller cannot act on. + + Five things differ between the three backends, and all five are parameters: + + * ``node_label`` -- ``PyBodyNode`` / ``JBodyNode`` / ``CanNode:TSBodyNode``. + * ``endpoint_scope`` and ``interior_scope`` -- Cypher fragments over the node variable ``n``, + empty for a backend that does not write one. **Empty is not an oversight.** Python's statements + are keyed by a body-node ``id``, which embeds the application, and + ``tests/analysis/python/test_neo4j_multi_application_scope.py`` sanctions id-keying as one of + four scope kinds for a measured reason: the predicate there would mean testing 195,784 reached + nodes against a list. Java writes it anyway under a stricter rule -- the audit judges the + predicate that is *written*, not the graph that happens to be attached -- for a measured ~4%. + Two standards, each measured on its own corpus. + * ``projection`` -- the per-node map body without its braces. Python adds ``n.var``, TypeScript + also ``n.of``, Java neither, because Java recovers the owner from the id prefix instead of + joining a callable back. + * ``rel_var`` -- ``r`` everywhere but Java, which spells it ``e``. Inert, and a parameter only so + this generator can reproduce all three byte-identically instead of normalising one of them. + + Returns a ``.format()`` template still carrying ``{rels}`` and ``{depth}``, so the runner methods + are unchanged. + """ + a_scope = f" WHERE {endpoint_scope.replace('n.', 'a.')}" if endpoint_scope else "" + b_scope = f" WHERE {endpoint_scope.replace('n.', 'b.')}" if endpoint_scope else "" + interior = f" WHERE all(n IN nodes(p) WHERE {interior_scope})" if interior_scope else "" + return ( + f"MATCH (a:{node_label} {{{{id:$src}}}}){a_scope} " + f"MATCH (b:{node_label} {{{{id:$dst}}}}){b_scope} " + "MATCH p = allShortestPaths((a)-[:{rels}*1..{depth}]->(b))" + interior + " " + "WITH p, " + path_order(P) + " AS key ORDER BY length(p), key LIMIT $cap " + f"RETURN [n IN nodes(p) | {{{{{projection}}}}}] AS ns, " + f"[{rel_var} IN relationships(p) | {{{{via: type({rel_var}), var: {rel_var}.var, prov: {rel_var}.prov}}}}] AS rs" + ) + + def hop_sort_key(hops: Sequence[PathHop]) -> Tuple: """The order two paths are compared in, in the caller's *own* vocabulary. diff --git a/tests/analysis/commons/test_lifted_helpers.py b/tests/analysis/commons/test_lifted_helpers.py index cc259755..f1573c2c 100644 --- a/tests/analysis/commons/test_lifted_helpers.py +++ b/tests/analysis/commons/test_lifted_helpers.py @@ -246,3 +246,81 @@ def test_the_three_constants_differ_only_in_the_relationship_prefix(): for P in ("J", "TS"): assert via_case(P).replace(f"{P}_", "PY_") == via_case("PY") assert path_order(P).replace(f"{P}_", "PY_") == path_order("PY") + + +# ---------------------------------------------------------------------------------------------- +# Leg 4a, Task 2: the whole path statement. +# +# Five things differ between the three backends' ``_PATHS``, and all five are parameters: the node +# label, the endpoint scope, the interior scope, the node projection, and -- found while writing +# this -- the relationship variable, which Java spells ``e`` where the other two spell ``r``. That +# last one is semantically inert; it is a parameter so this lift can be byte-identical rather than a +# judgement call. Normalising it is a separate, arguable change. +# ---------------------------------------------------------------------------------------------- + +#: The arguments that reproduce each backend's statement, written out rather than derived: a +#: derivation that produced the wrong string would also produce the wrong expectation. +PATHS_ARGS = { + "PY": dict( + node_label="PyBodyNode", + projection="ref: n.id, kind: n.kind, var: n.var, line: n.start_line, " + "callable: head([(c:PyCallable)-[:PY_HAS_BODY_NODE]->(n) | c.signature]), " + "c_line: head([(c:PyCallable)-[:PY_HAS_BODY_NODE]->(n) | c.start_line])", + ), + "J": dict( + node_label="JBodyNode", + endpoint_scope="n.id STARTS WITH $prefix", + interior_scope="n.id STARTS WITH $prefix", + projection="ref: n.id, kind: n.kind, line: n.start_line", + rel_var="e", + ), + "TS": dict( + node_label="CanNode:TSBodyNode", + interior_scope="(n.id STARTS WITH $p)", + projection="ref: n.id, kind: n.kind, of: n.of, line: n.start_line, " + "callable: head([(c:TSCallable)-[:TS_HAS_BODY_NODE]->(n) | c.signature]), " + "c_line: head([(c:TSCallable)-[:TS_HAS_BODY_NODE]->(n) | c.start_line])", + ), +} + + +@pytest.mark.parametrize("P", ["PY", "J", "TS"]) +def test_sdg_path_query_reproduces_each_backends_paths(P): + """Byte-identical, and that is the whole safety property of this lift: these three statements are + shipped code on a release branch mid-rc, so anything but equality is a behaviour change.""" + from cldk.analysis.commons.graphs import sdg_path_query + + backend = dict(_path_backends())[P] + assert sdg_path_query(P, **PATHS_ARGS[P]) == backend._PATHS + + +@pytest.mark.parametrize("P", ["PY", "J", "TS"]) +def test_the_generated_statement_still_formats(P): + """The result is a ``.format()`` template, not a finished statement -- the runners supply ``rels`` + and ``depth``. + + What formatting must leave behind is *single* braces: Cypher map literals need them, so + ``{{id:$src}}`` becoming ``{id:$src}`` is the point. What it must **not** leave is a doubled + brace, which would mean an escape the template never resolved and a statement the server would + reject. + """ + from cldk.analysis.commons.graphs import sdg_path_query, sdg_rel_pattern + + out = sdg_path_query(P, **PATHS_ARGS[P]).format(rels=sdg_rel_pattern(P), depth="") + assert "{{" not in out and "}}" not in out, "an escape survived formatting" + assert "{id:$src}" in out and "{id:$dst}" in out + assert out.count("allShortestPaths") == 1 + + +def test_the_scope_predicates_are_written_where_the_backend_writes_them(): + """Not a restatement of the equality above: it pins *which* backend scopes what, so a future + edit that moved Python onto the prefix predicate would fail here with a reason rather than + silently changing a statement whose omission was measured and is sanctioned (see + tests/analysis/python/test_neo4j_multi_application_scope.py -- id-keying is a scope kind). + """ + from cldk.analysis.commons.graphs import sdg_path_query + + py, j, ts = (sdg_path_query(P, **PATHS_ARGS[P]) for P in ("PY", "J", "TS")) + assert "STARTS WITH" not in py, "Python's path statement is scoped by id, deliberately" + assert j.count("STARTS WITH $prefix") == 3, "Java scopes both endpoints and the interior" + assert ts.count("STARTS WITH $p") == 1, "TypeScript scopes the interior only" From 4e100505073377e5503719f84879f5716075664d Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Wed, 9 Sep 2026 06:55:41 -0400 Subject: [PATCH 08/65] docs(plan): correct 4a Task 2's brace assertion, and pin which backend scopes what Two fixes from implementing it. The formatted-template test asserted the statement contains no braces. It must contain single ones: Cypher map literals need them, so {{id:$src}} resolving to {id:$src} is exactly what should happen. The property worth asserting is that no doubled brace survives, which would mean an unresolved escape and a statement the server would reject. And byte-equality alone does not pin which backend scopes what -- an edit that changed the generator and the arguments together would still pass. So Task 2 now adds a test asserting Python's statement carries no STARTS WITH, Java's carries three, and TypeScript's one. Python's omission is a measured decision sanctioned by the scope-kind audit, not a gap, and a test that says so fails with a reason instead of quietly absorbing a change to it. Expected count corrected to 29 passed, with --no-cov. --- .../2026-09-09-leg-4a-shared-path-skeleton.md | 30 ++++++++++++++++--- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/docs/design/plans/2026-09-09-leg-4a-shared-path-skeleton.md b/docs/design/plans/2026-09-09-leg-4a-shared-path-skeleton.md index c80192e6..d08263ba 100644 --- a/docs/design/plans/2026-09-09-leg-4a-shared-path-skeleton.md +++ b/docs/design/plans/2026-09-09-leg-4a-shared-path-skeleton.md @@ -238,11 +238,18 @@ def test_sdg_path_query_reproduces_the_backends_paths(P, backend): @pytest.mark.parametrize("P, backend", BACKENDS) def test_the_generated_template_still_formats(P, backend): """The result is a `.format()` template, not a finished statement: the runners pass `rels` and - `depth`. If the lift ever emitted a literal brace it would raise here rather than at runtime.""" + `depth`. + + **Corrected while implementing.** An earlier draft asserted the formatted statement contains no + braces. It must contain single ones — Cypher map literals need them, so `{{id:$src}}` resolving + to `{id:$src}` is the point. The property worth asserting is that no *doubled* brace survives, + which would mean an unresolved escape and a statement the server would reject. + """ from cldk.analysis.commons.graphs import sdg_rel_pattern out = sdg_path_query(P, **PATHS_ARGS[P]).format(rels=sdg_rel_pattern(P), depth="") - assert "{" not in out and "}" not in out.replace("{{", "").replace("}}", "") + assert "{{" not in out and "}}" not in out, "an escape survived formatting" + assert "{id:$src}" in out and "{id:$dst}" in out ``` - [ ] **Step 2: Run it to make sure it fails** @@ -289,8 +296,23 @@ def sdg_path_query(P: str, *, node_label: str, endpoint_scope: str = "", interio - [ ] **Step 4: Run the tests and make sure they pass** -Run: `uv run --all-groups pytest tests/analysis/commons/test_lifted_helpers.py -v` -Expected: 13 passed. +Run: `uv run --all-groups pytest tests/analysis/commons/test_lifted_helpers.py -q --no-cov` +Expected: 29 passed. + +Add one test the first draft of this plan did not have, because the equality assertions do not cover +it: **which** backend scopes what. + +```python +def test_the_scope_predicates_are_written_where_the_backend_writes_them(): + py, j, ts = (sdg_path_query(P, **PATHS_ARGS[P]) for P in ("PY", "J", "TS")) + assert "STARTS WITH" not in py, "Python's path statement is scoped by id, deliberately" + assert j.count("STARTS WITH $prefix") == 3, "Java scopes both endpoints and the interior" + assert ts.count("STARTS WITH $p") == 1, "TypeScript scopes the interior only" +``` + +Byte-equality would still pass if someone changed both the generator and the arguments together. +This fails with a reason instead, which matters because Python's omission is a measured decision and +not a gap. The `.replace('n.', 'a.')` on `endpoint_scope` is the one fragile line: it assumes the fragment names the node variable as `n.`. Only Java supplies one and it does. If a future backend supplies a From 3417c31778e5b8262e79581c0bd4d2eeb1e14d81 Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Wed, 9 Sep 2026 07:56:52 -0400 Subject: [PATCH 09/65] refactor(neo4j): build _PATHS from the shared generator in all three backends Replace each backend's own _VIA_CASE / _PATH_ORDER / _PATHS with calls to via_case, path_order and sdg_path_query in cldk.analysis.commons.graphs (lifted in the two prior commits). Python and TypeScript each had a second _PATH_ORDER consumer in _CALL_PATHS, now pointed at path_order("PY") / path_order("TS") inline since the attribute is gone. Java has no such statement. The generator's arguments are the ones verified byte-identical against each backend's old constant in test_lifted_helpers.py. No behaviour change: every generated statement is identical to what it replaces. --- cldk/analysis/java/neo4j/neo4j_backend.py | 28 +++++---------- cldk/analysis/python/neo4j/neo4j_backend.py | 35 ++++--------------- .../typescript/neo4j/neo4j_backend.py | 29 +++++---------- 3 files changed, 25 insertions(+), 67 deletions(-) diff --git a/cldk/analysis/java/neo4j/neo4j_backend.py b/cldk/analysis/java/neo4j/neo4j_backend.py index 2655d07f..11712203 100644 --- a/cldk/analysis/java/neo4j/neo4j_backend.py +++ b/cldk/analysis/java/neo4j/neo4j_backend.py @@ -117,7 +117,7 @@ import networkx as nx from cldk.analysis.commons.bounds import DEFAULT_PAGE_SIZE, EdgeOrder, check_page_size, cursor_params, encode_cursor, keyset_where -from cldk.analysis.commons.graphs import flow_path, slice_resolved +from cldk.analysis.commons.graphs import flow_path, sdg_path_query, slice_resolved from cldk.analysis.commons.results import EdgePage, FlowPaths, Slice, SliceNode from cldk.analysis.java.backend import ( CDG_ORDER, @@ -891,12 +891,6 @@ def _value_slice(self, root: SliceNode, *, backward: bool, depth: int | None, ma return Slice(nodes=nodes, roots=[root], resolved=slice_resolved([root]), total=row["total"]) # -----[ paths and the flow predicate ]----- - #: The caller's word for a hop, computed in Cypher so the ORDER BY below sorts by the same - #: vocabulary :func:`~cldk.analysis.commons.graphs.hop_sort_key` sorts by. Ordering by the raw - #: ``type(rel)`` instead would be just as deterministic and a *different* order, so the two - #: backends would truncate ``max_paths`` to different witnesses. - _VIA_CASE = "CASE type(relationships(p)[i]) " + " ".join(f"WHEN '{rel}' THEN '{word}'" for rel, word in VIA.items()) + " ELSE type(relationships(p)[i]) END" - #: One string per path, ordered exactly as Python would order the tuple ``hop_sort_key`` builds. #: ``U+0001`` is the separator rather than ``|`` for one reason: string comparison agrees with #: field-by-field comparison **only** when the separator sorts below every character a field can @@ -915,11 +909,7 @@ def _value_slice(self, root: SliceNode, *, backward: bool, depth: int | None, ma #: 22.5 MB daytrader8 ``-a 4`` payload (6,984 ``cfg``, 4,416 ``cdg`` and 5,434 ``ddg`` edges, #: every one with a distinct key within its callable). If an analyzer ever emits one, the fix #: is a fourth component both backends can compute, not an ``elementId`` only one of them has. - _PATH_ORDER = ( - "reduce(k = '', i IN range(0, length(p) - 1) | k + " + _VIA_CASE + " + '\\u0001' + coalesce(relationships(p)[i].var, '') " - "+ '\\u0001' + nodes(p)[i + 1].id + '\\u0001' + elementId(relationships(p)[i]) + '\\u0001')" - ) - + #: #: ``allShortestPaths`` and not a plain variable-length match: a variable-length pattern #: enumerates *trails*, which does not terminate on a real dependence graph, while #: ``allShortestPaths`` is a bidirectional BFS. ``$cap`` is ``max_paths + 1`` so one extra row @@ -930,13 +920,13 @@ def _value_slice(self, root: SliceNode, *, backward: bool, depth: int | None, ma #: application's nodes and come back. Leg 2.5b found exactly that leak twice in its own path #: enumerators; Neo4j inlines an ``all()`` node predicate into the shortest-path search itself, #: so it is a correctness win at no cost. - _PATHS = ( - "MATCH (a:JBodyNode {{id:$src}}) WHERE a.id STARTS WITH $prefix " - "MATCH (b:JBodyNode {{id:$dst}}) WHERE b.id STARTS WITH $prefix " - "MATCH p = allShortestPaths((a)-[:{rels}*1..{depth}]->(b)) WHERE all(n IN nodes(p) WHERE n.id STARTS WITH $prefix) " - "WITH p, " + _PATH_ORDER + " AS key ORDER BY length(p), key LIMIT $cap " - "RETURN [n IN nodes(p) | {{ref: n.id, kind: n.kind, line: n.start_line}}] AS ns, " - "[e IN relationships(p) | {{via: type(e), var: e.var, prov: e.prov}}] AS rs" + _PATHS = sdg_path_query( + "J", + node_label="JBodyNode", + endpoint_scope="n.id STARTS WITH $prefix", + interior_scope="n.id STARTS WITH $prefix", + projection="ref: n.id, kind: n.kind, line: n.start_line", + rel_var="e", ) def _value_paths(self, a: SliceNode, b: SliceNode, depth: int | None, max_paths: int) -> FlowPaths: diff --git a/cldk/analysis/python/neo4j/neo4j_backend.py b/cldk/analysis/python/neo4j/neo4j_backend.py index da227e32..0c217527 100644 --- a/cldk/analysis/python/neo4j/neo4j_backend.py +++ b/cldk/analysis/python/neo4j/neo4j_backend.py @@ -97,6 +97,7 @@ from codeanalyzer.schema.py_schema import PyEntrypointReport from cldk.analysis.commons.backend import semver as _semver +from cldk.analysis.commons.graphs import path_order, sdg_path_query from cldk.analysis.commons.keys import module_key_of from cldk.analysis.commons.resolve import CallableCandidate, body_node_kind, resolve_callable_signature, resolve_value_name, resolve_within, value_candidate from cldk.analysis.commons.results import BodyRef, CallableRef, Diagnostic, EdgePage, EntrypointCoverage, FlowPath, FlowPaths, LocateResult, ModuleRef, PathHop, Slice, SliceNode, TypeRef @@ -1582,26 +1583,6 @@ def callees_of(self, name: str, *, in_class: str | None = None, in_module: str | return [_call_neighbour(r, self._module_key) for r in self._run(self._CALLEES, sig=sig, prefix=self._scope_prefix)] # -----[ paths, mixed queries, hydration ]----- - #: The caller's word for a hop, computed in Cypher so the ORDER BY below sorts by the same - #: vocabulary :func:`~cldk.analysis.python.backend.hop_sort_key` sorts by. Ordering by the raw - #: ``type(r)`` instead would be just as deterministic and a *different* order (``PY_CDG`` before - #: ``PY_DDG`` before ``PY_PARAM_IN``, against ``argument`` before ``control`` before ``data``), - #: so the two backends would truncate ``max_paths`` to different witnesses. - _VIA_CASE = "CASE type(relationships(p)[i]) " + " ".join(f"WHEN '{rel}' THEN '{word}'" for rel, word in VIA.items()) + " ELSE type(relationships(p)[i]) END" - - #: One string per path, ordered exactly as Python would order the tuple - #: :func:`~cldk.analysis.python.backend.hop_sort_key` builds. ``\u0001`` is the separator - #: rather than ``|`` for that reason and only that reason: string comparison agrees with - #: field-by-field comparison **only** when the separator sorts below every character a field - #: can hold, and ``|`` (0x7C) sorts *above* every lowercase letter, which would order a - #: variable ``x`` after ``xy``. ``elementId`` is the last field of each hop and breaks the - #: tie between parallel relationships a caller cannot tell apart; it is stable for repeated - #: calls against one database and means nothing outside it. - _PATH_ORDER = ( - "reduce(k = '', i IN range(0, length(p) - 1) | k + " + _VIA_CASE + " + '\\u0001' + coalesce(relationships(p)[i].var, '') " - "+ '\\u0001' + nodes(p)[i + 1].id + '\\u0001' + elementId(relationships(p)[i]) + '\\u0001')" - ) - #: ``allShortestPaths`` and not a plain variable-length match. A variable-length pattern #: enumerates *trails*, which is the shape that never terminated in Task 6 (``EXISTS { (a)-[: #: PY_CALLS*1..]->(a) }``, killed at 600s); ``allShortestPaths`` is a bidirectional BFS, and @@ -1612,14 +1593,12 @@ def callees_of(self, name: str, *, in_class: str | None = None, in_module: str | #: ``$cap`` is ``max_paths + 1`` so one extra row is what reports the truncation, rather than a #: second ``count(p)`` traversal for a number the caller cannot act on (see #: :class:`~cldk.analysis.commons.results.FlowPaths`). - _PATHS = ( - "MATCH (a:PyBodyNode {{id:$src}}) MATCH (b:PyBodyNode {{id:$dst}}) " - "MATCH p = allShortestPaths((a)-[:{rels}*1..{depth}]->(b)) " - "WITH p, " + _PATH_ORDER + " AS key ORDER BY length(p), key LIMIT $cap " - "RETURN [n IN nodes(p) | {{ref: n.id, kind: n.kind, var: n.var, line: n.start_line, " + _PATHS = sdg_path_query( + "PY", + node_label="PyBodyNode", + projection="ref: n.id, kind: n.kind, var: n.var, line: n.start_line, " "callable: head([(c:PyCallable)-[:PY_HAS_BODY_NODE]->(n) | c.signature]), " - "c_line: head([(c:PyCallable)-[:PY_HAS_BODY_NODE]->(n) | c.start_line])}}] AS ns, " - "[r IN relationships(p) | {{via: type(r), var: r.var, prov: r.prov}}] AS rs" + "c_line: head([(c:PyCallable)-[:PY_HAS_BODY_NODE]->(n) | c.start_line])", ) #: The same query over the call graph. ``all(n IN nodes(p) WHERE n:PyCallable)`` keeps a @@ -1636,7 +1615,7 @@ def callees_of(self, name: str, *, in_class: str | None = None, in_module: str | "MATCH (a:PyCallable {{signature:$src}}) WHERE a.id STARTS WITH $prefix " "MATCH (b:PyCallable {{signature:$dst}}) WHERE b.id STARTS WITH $prefix " "MATCH p = allShortestPaths((a)-[:PY_CALLS*1..{depth}]->(b)) WHERE all(n IN nodes(p) WHERE n:PyCallable) " - "WITH p, " + _PATH_ORDER + " AS key ORDER BY length(p), key LIMIT $cap " + "WITH p, " + path_order("PY") + " AS key ORDER BY length(p), key LIMIT $cap " "RETURN [n IN nodes(p) | {{signature: n.signature, name: n.name, ref: n.id, " "line: n.start_line, module: n.module}}] AS ns, " "[r IN relationships(p) | {{via: type(r), var: null, prov: null}}] AS rs" diff --git a/cldk/analysis/typescript/neo4j/neo4j_backend.py b/cldk/analysis/typescript/neo4j/neo4j_backend.py index 70bfc3f0..bc57ed8d 100644 --- a/cldk/analysis/typescript/neo4j/neo4j_backend.py +++ b/cldk/analysis/typescript/neo4j/neo4j_backend.py @@ -133,7 +133,7 @@ class never writes and needs neither the analyzer binary nor the sources. encode_cursor, keyset_where, ) -from cldk.analysis.commons.graphs import cone_sinks, flow_path, slice_resolved +from cldk.analysis.commons.graphs import cone_sinks, flow_path, path_order, sdg_path_query, slice_resolved from cldk.analysis.commons.keys import body_key_column, module_key_of, resolve_module_key from cldk.analysis.commons.resolve import CallableCandidate, resolve_callable_signature, resolve_value_name, resolve_within from cldk.analysis.commons.results import ( @@ -1840,22 +1840,12 @@ def callees_of(self, name: str, *, in_class: str | None = None, in_module: str | return [self._call_vertex(r["v"]) for r in self._run(self._CALLEES, sig=sig, callable_kinds=sorted(CALLABLE_KINDS), **self._scope_params)] # -----[ paths and flow predicates ]----- - #: The caller's word for a hop, computed in Cypher so the ORDER BY below sorts by the same - #: vocabulary :func:`~cldk.analysis.commons.graphs.hop_sort_key` sorts by. Ordering by the raw - #: ``type(r)`` instead would be just as deterministic and a *different* order, so the two - #: backends would truncate ``max_paths`` to different witnesses. - _VIA_CASE = "CASE type(relationships(p)[i]) " + " ".join(f"WHEN '{rel}' THEN '{word}'" for rel, word in VIA.items()) + " ELSE type(relationships(p)[i]) END" - #: One string per path, ordered exactly as Python would order the tuple ``hop_sort_key`` builds. #: ``U+0001`` is the separator rather than ``|`` for one reason: string comparison agrees with #: field-by-field comparison **only** when the separator sorts below every character a field can #: hold, and ``|`` (0x7C) sorts *above* every lowercase letter. ``elementId`` is the last field #: of each hop and breaks the tie between parallel relationships a caller cannot tell apart. - _PATH_ORDER = ( - "reduce(k = '', i IN range(0, length(p) - 1) | k + " + _VIA_CASE + " + '\\u0001' + coalesce(relationships(p)[i].var, '') " - "+ '\\u0001' + nodes(p)[i + 1].id + '\\u0001' + elementId(relationships(p)[i]) + '\\u0001')" - ) - + #: #: ``allShortestPaths`` and not a plain variable-length match: a variable-length pattern #: enumerates *trails*, which does not terminate on a real dependence graph, while #: ``allShortestPaths`` is a bidirectional BFS. ``$cap`` is ``max_paths + 1`` so one extra row @@ -1864,14 +1854,13 @@ def callees_of(self, name: str, *, in_class: str | None = None, in_module: str | #: ``all(n IN nodes(p) …)`` puts the application-prefix predicate on **every** node of the path, not #: only on the two the ids pin: the SDG types are deliberately outside the audit's #: ``_KEEPS_SCOPE``, so an interior node reached over one is not provably this application's. - _PATHS = ( - "MATCH (a:CanNode:TSBodyNode {{id:$src}}) MATCH (b:CanNode:TSBodyNode {{id:$dst}}) " - "MATCH p = allShortestPaths((a)-[:{rels}*1..{depth}]->(b)) WHERE all(n IN nodes(p) WHERE " + _scoped("n") + ") " - "WITH p, " + _PATH_ORDER + " AS key ORDER BY length(p), key LIMIT $cap " - "RETURN [n IN nodes(p) | {{ref: n.id, kind: n.kind, of: n.of, line: n.start_line, " + _PATHS = sdg_path_query( + "TS", + node_label="CanNode:TSBodyNode", + interior_scope=_scoped("n"), + projection="ref: n.id, kind: n.kind, of: n.of, line: n.start_line, " "callable: head([(c:TSCallable)-[:TS_HAS_BODY_NODE]->(n) | c.signature]), " - "c_line: head([(c:TSCallable)-[:TS_HAS_BODY_NODE]->(n) | c.start_line])}}] AS ns, " - "[r IN relationships(p) | {{via: type(r), var: r.var, prov: r.prov}}] AS rs" + "c_line: head([(c:TSCallable)-[:TS_HAS_BODY_NODE]->(n) | c.start_line])", ) #: The same query over the call graph. The ``all()`` predicate carries **both** halves of what @@ -1885,7 +1874,7 @@ def callees_of(self, name: str, *, in_class: str | None = None, in_module: str | "MATCH (a:TSCallable {{signature:$src}}) WHERE " + _scoped("a") + " " "MATCH (b:TSCallable {{signature:$dst}}) WHERE " + _scoped("b") + " " "MATCH p = allShortestPaths((a)-[:TS_CALLS*1..{depth}]->(b)) WHERE all(n IN nodes(p) WHERE n:TSCallable AND " + _scoped("n") + ") " - "WITH p, " + _PATH_ORDER + " AS key ORDER BY length(p), key LIMIT $cap " + "WITH p, " + path_order("TS") + " AS key ORDER BY length(p), key LIMIT $cap " "RETURN [n IN nodes(p) | " + _vertex("n", escape=True) + "] AS ns, " "[r IN relationships(p) | {{via: type(r), var: null, prov: null}}] AS rs" ) From 9b6b36bc0766f9ab85dd1d53284e50ebd71fe4b8 Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Wed, 9 Sep 2026 07:56:52 -0400 Subject: [PATCH 10/65] test(commons): stop comparing against the now-deleted _VIA_CASE/_PATH_ORDER The two Task-1 regression tests asserted via_case(P) == backend._VIA_CASE and path_order(P) == backend._PATH_ORDER, which held while the backends still carried their own copies alongside the lifted functions. Task 3 deletes both attributes, so there is nothing left to compare against; assert the fragment is embedded in the backend's surviving _PATHS statement instead, which is what these tests actually care about. --- tests/analysis/commons/test_lifted_helpers.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/analysis/commons/test_lifted_helpers.py b/tests/analysis/commons/test_lifted_helpers.py index f1573c2c..ac5f25d2 100644 --- a/tests/analysis/commons/test_lifted_helpers.py +++ b/tests/analysis/commons/test_lifted_helpers.py @@ -216,11 +216,15 @@ def _path_backends(): @pytest.mark.parametrize("P", ["PY", "J", "TS"]) def test_via_case_reproduces_each_backends_constant(P): """Byte-identical, because a changed ``CASE`` arm changes which word a hop is reported under and - a changed ``ORDER BY`` term changes which paths ``max_paths`` keeps.""" + a changed ``ORDER BY`` term changes which paths ``max_paths`` keeps. + + Task 3 replaced the backends' own ``_VIA_CASE``/``_PATH_ORDER`` attributes with calls to these + same functions, so there is no longer a second copy of the string to compare against -- the + fragment now only exists once, inside the backend's surviving ``_PATHS``.""" from cldk.analysis.commons.graphs import via_case backend = dict(_path_backends())[P] - assert via_case(P) == backend._VIA_CASE + assert via_case(P) in backend._PATHS @pytest.mark.parametrize("P", ["PY", "J", "TS"]) @@ -228,7 +232,7 @@ def test_path_order_reproduces_each_backends_constant(P): from cldk.analysis.commons.graphs import path_order backend = dict(_path_backends())[P] - assert path_order(P) == backend._PATH_ORDER + assert path_order(P) in backend._PATHS def test_the_three_constants_differ_only_in_the_relationship_prefix(): From 23f092195f4f32fd3d0e506e346843b15d80f43c Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Wed, 9 Sep 2026 08:07:44 -0400 Subject: [PATCH 11/65] test(commons): replace the expired byte-identity guard with a digest tripwire test_via_case_reproduces_each_backends_constant and test_path_order_reproduces_each_backends_constant compared via_case(P)/path_order(P) against backend attributes that Task 3 deleted; my rewrite in the previous commit made them tautological (sdg_path_query always embeds path_order, which always embeds via_case, so membership held for any correctly-P-parametrized call regardless of the backend's own arguments) and is deleted rather than kept in any weakened form. test_sdg_path_query_reproduces_each_backends_paths no longer catches a regression in the generator itself either: _PATHS is now that same call, so both sides of the equality move together when sdg_path_query, path_order or via_case changes, and only a PATHS_ARGS/backend-argument mismatch can still fail it. PATHS_DIGESTS pins a SHA-256 prefix of each backend's current _PATHS so a change to the statement itself -- through any of those three layers -- still fails somewhere. --- tests/analysis/commons/test_lifted_helpers.py | 58 ++++++++++++------- 1 file changed, 36 insertions(+), 22 deletions(-) diff --git a/tests/analysis/commons/test_lifted_helpers.py b/tests/analysis/commons/test_lifted_helpers.py index ac5f25d2..dab0ae7e 100644 --- a/tests/analysis/commons/test_lifted_helpers.py +++ b/tests/analysis/commons/test_lifted_helpers.py @@ -1,4 +1,5 @@ # tests/analysis/commons/test_lifted_helpers.py +import hashlib import importlib, pytest LIFTED = { @@ -213,28 +214,6 @@ def _path_backends(): return [("PY", PyNeo4jBackend), ("J", JNeo4jBackend), ("TS", TSNeo4jBackend)] -@pytest.mark.parametrize("P", ["PY", "J", "TS"]) -def test_via_case_reproduces_each_backends_constant(P): - """Byte-identical, because a changed ``CASE`` arm changes which word a hop is reported under and - a changed ``ORDER BY`` term changes which paths ``max_paths`` keeps. - - Task 3 replaced the backends' own ``_VIA_CASE``/``_PATH_ORDER`` attributes with calls to these - same functions, so there is no longer a second copy of the string to compare against -- the - fragment now only exists once, inside the backend's surviving ``_PATHS``.""" - from cldk.analysis.commons.graphs import via_case - - backend = dict(_path_backends())[P] - assert via_case(P) in backend._PATHS - - -@pytest.mark.parametrize("P", ["PY", "J", "TS"]) -def test_path_order_reproduces_each_backends_constant(P): - from cldk.analysis.commons.graphs import path_order - - backend = dict(_path_backends())[P] - assert path_order(P) in backend._PATHS - - def test_the_three_constants_differ_only_in_the_relationship_prefix(): """What is and is not shared, stated exactly. @@ -328,3 +307,38 @@ def test_the_scope_predicates_are_written_where_the_backend_writes_them(): assert "STARTS WITH" not in py, "Python's path statement is scoped by id, deliberately" assert j.count("STARTS WITH $prefix") == 3, "Java scopes both endpoints and the interior" assert ts.count("STARTS WITH $p") == 1, "TypeScript scopes the interior only" + + +# ---------------------------------------------------------------------------------------------- +# Leg 4a, Task 3: the byte-identity guard expires the moment ``_PATHS`` becomes the call it used to +# be compared against. +# +# ``test_sdg_path_query_reproduces_each_backends_paths`` above compares +# ``sdg_path_query(P, **PATHS_ARGS[P])`` against ``backend._PATHS`` -- and after Task 3, ``_PATHS`` +# *is* ``sdg_path_query(P, **)``. The two sides no longer come from +# independent sources, so that test now only catches a divergence between ``PATHS_ARGS`` and the +# backend's own arguments; a change to ``sdg_path_query``, ``path_order`` or ``via_case`` moves both +# sides together and passes silently. The digest below is what still fails when the statement +# itself changes. +# ---------------------------------------------------------------------------------------------- + +#: A digest of each backend's `_PATHS`, pinned so that a change to the shared generator, to +#: `path_order`/`via_case`, or to a backend's own arguments cannot pass unnoticed. +#: +#: This replaces the byte-identity comparison leg 4a retired. While `_PATHS` was a hand-written +#: literal, comparing it against `sdg_path_query()` proved the generator reproduced it -- the two +#: sides were independent. Now `_PATHS` *is* that call, so both sides of that equality move +#: together and only a mismatch between `PATHS_ARGS` and the backend's own arguments can fail it. +#: A digest is what still fails when the statement itself changes. +#: +#: **When this fails:** the statement changed. Print +#: `sdg_path_query(P, **PATHS_ARGS[P])` and diff it against the previous value to see how, decide +#: whether the change was intended, and if it was, update the digest **in the same commit that +#: changed the statement** -- never in a separate one, or the two stop being reviewable together. +PATHS_DIGESTS = {"PY": "c1ea290360d42460", "J": "236a302937bcd98a", "TS": "a710986b595dc4df"} + + +@pytest.mark.parametrize("P", ["PY", "J", "TS"]) +def test_the_generated_statement_has_not_drifted(P): + backend = dict(_path_backends())[P] + assert hashlib.sha256(backend._PATHS.encode()).hexdigest()[:16] == PATHS_DIGESTS[P] From b2e0beb70bb7f0e2af3b3b32d63e0b776ff98a7b Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Wed, 9 Sep 2026 08:27:53 -0400 Subject: [PATCH 12/65] refactor(graphs): make sdg_path_query's scope params callables endpoint_scope and interior_scope were plain strings, spliced in with a textual .replace('n.', 'a.')/('n.', 'b.') -- a fragment containing fn.id would become fa.id. Both are now Callable[[str], str] | None, called with the variable name (endpoint_scope('a'), endpoint_scope('b'), interior_scope('n')) instead. Matches the shape TypeScript's _scoped(var) already had. Also drops the orphan #: comment block above via_case (attached to nothing -- it sat before a blank line and a def) and restores four measurements the old Python _PATHS comment carried that the docstring here lost: the repro query, the odoo-slim-19 corpus, the exact seed name, and the reachable-case timing. No generated statement changes; all three backends' _PATHS still hash to the pinned digests. --- cldk/analysis/commons/graphs.py | 34 +++++++++++++++------------------ 1 file changed, 15 insertions(+), 19 deletions(-) diff --git a/cldk/analysis/commons/graphs.py b/cldk/analysis/commons/graphs.py index 0f912787..47de828a 100644 --- a/cldk/analysis/commons/graphs.py +++ b/cldk/analysis/commons/graphs.py @@ -157,13 +157,6 @@ def via_table(P: str) -> dict[str, str]: } -#: The two fragments every SDG path statement is built from. Both were written out identically in -#: all three Neo4j backends, each computing its own language's value from that backend's ``VIA`` -#: table -- so the *expression* was triplicated while the *result* is per-language, which is why -#: these are functions of ``P`` and not constants. The strings are not interchangeable: they differ -#: in the relationship prefix and in nothing else. - - def via_case(P: str) -> str: """The Cypher ``CASE`` mapping a hop's relationship type to the caller's word for it (E6). @@ -201,22 +194,25 @@ def path_order(P: str) -> str: ) -def sdg_path_query(P: str, *, node_label: str, endpoint_scope: str = "", interior_scope: str = "", projection: str, rel_var: str = "r") -> str: +def sdg_path_query(P: str, *, node_label: str, endpoint_scope: Callable[[str], str] | None = None, interior_scope: Callable[[str], str] | None = None, projection: str, rel_var: str = "r") -> str: """The shortest-path statement every Neo4j backend issues for ``paths_between``. ``allShortestPaths`` and not a plain variable-length match. A variable-length pattern enumerates - *trails*, the shape that does not terminate on a real dependence graph (killed at 600 s on odoo); - ``allShortestPaths`` is a bidirectional BFS and answers the pathological cases in milliseconds -- - 0.08 s for an unreachable pair seeded in a 440,270-node forward cone. ``$cap`` is ``max_paths + 1`` - at the call site, so one extra row reports the truncation rather than a second ``count(p)`` - traversal for a number the caller cannot act on. + *trails*, the shape that does not terminate on a real dependence graph -- ``EXISTS { (a)-[: + PY_CALLS*1..]->(a) }`` ran 600 s without terminating on odoo-slim-19; ``allShortestPaths`` is a + bidirectional BFS and answers the pathological cases in milliseconds -- 0.08 s for an unreachable + pair seeded at ``Website.configurator_apply``'s ``kwargs`` (the 440,270-node forward cone), 0.06 s + for a reachable one with 405 distinct shortest paths. ``$cap`` is ``max_paths + 1`` at the call + site, so one extra row reports the truncation rather than a second ``count(p)`` traversal for a + number the caller cannot act on. Five things differ between the three backends, and all five are parameters: * ``node_label`` -- ``PyBodyNode`` / ``JBodyNode`` / ``CanNode:TSBodyNode``. - * ``endpoint_scope`` and ``interior_scope`` -- Cypher fragments over the node variable ``n``, - empty for a backend that does not write one. **Empty is not an oversight.** Python's statements - are keyed by a body-node ``id``, which embeds the application, and + * ``endpoint_scope`` and ``interior_scope`` -- callables taking the node variable's name and + returning the Cypher predicate for it, ``None`` for a backend that does not write one. + **None is not an oversight.** Python's statements are keyed by a body-node ``id``, which + embeds the application, and ``tests/analysis/python/test_neo4j_multi_application_scope.py`` sanctions id-keying as one of four scope kinds for a measured reason: the predicate there would mean testing 195,784 reached nodes against a list. Java writes it anyway under a stricter rule -- the audit judges the @@ -231,9 +227,9 @@ def sdg_path_query(P: str, *, node_label: str, endpoint_scope: str = "", interio Returns a ``.format()`` template still carrying ``{rels}`` and ``{depth}``, so the runner methods are unchanged. """ - a_scope = f" WHERE {endpoint_scope.replace('n.', 'a.')}" if endpoint_scope else "" - b_scope = f" WHERE {endpoint_scope.replace('n.', 'b.')}" if endpoint_scope else "" - interior = f" WHERE all(n IN nodes(p) WHERE {interior_scope})" if interior_scope else "" + a_scope = f" WHERE {endpoint_scope('a')}" if endpoint_scope else "" + b_scope = f" WHERE {endpoint_scope('b')}" if endpoint_scope else "" + interior = f" WHERE all(n IN nodes(p) WHERE {interior_scope('n')})" if interior_scope else "" return ( f"MATCH (a:{node_label} {{{{id:$src}}}}){a_scope} " f"MATCH (b:{node_label} {{{{id:$dst}}}}){b_scope} " From 787876a15302badd93c4f8e612a45cda35d340c6 Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Wed, 9 Sep 2026 08:28:15 -0400 Subject: [PATCH 13/65] refactor(neo4j): pass _scoped itself to sdg_path_query, not a call Java now passes _scoped for both endpoint_scope and interior_scope. TypeScript passes _scoped as interior_scope instead of _scoped("n") -- a called string, which the new callable-shaped parameter no longer accepts. Also trims the duplicated rationale above each backend's _PATHS: the U+0001/elementId paragraph and the allShortestPaths/$cap paragraph both restate path_order's own docstring almost verbatim, so both become a one-line pointer to it. Java's allShortestPaths/$cap paragraph is a straight duplicate and is removed outright (the string no longer appears anywhere in the file). Kept, verbatim: Java's CFG_ORDER/ CDG_ORDER/DDG_ORDER tie-break paragraph (Java-specific, re-opened to read after its now-removed predecessor) and its leg-2.5b scope-leak witness paragraph; TypeScript's _KEEPS_SCOPE paragraph. No generated statement changes. --- cldk/analysis/java/neo4j/neo4j_backend.py | 17 ++++------------- cldk/analysis/typescript/neo4j/neo4j_backend.py | 13 ++----------- 2 files changed, 6 insertions(+), 24 deletions(-) diff --git a/cldk/analysis/java/neo4j/neo4j_backend.py b/cldk/analysis/java/neo4j/neo4j_backend.py index 11712203..03556d6d 100644 --- a/cldk/analysis/java/neo4j/neo4j_backend.py +++ b/cldk/analysis/java/neo4j/neo4j_backend.py @@ -891,13 +891,9 @@ def _value_slice(self, root: SliceNode, *, backward: bool, depth: int | None, ma return Slice(nodes=nodes, roots=[root], resolved=slice_resolved([root]), total=row["total"]) # -----[ paths and the flow predicate ]----- - #: One string per path, ordered exactly as Python would order the tuple ``hop_sort_key`` builds. - #: ``U+0001`` is the separator rather than ``|`` for one reason: string comparison agrees with - #: field-by-field comparison **only** when the separator sorts below every character a field can - #: hold, and ``|`` (0x7C) sorts *above* every lowercase letter. ``elementId`` is the last field - #: of each hop and breaks the tie between parallel relationships a caller cannot tell apart. + #: See :func:`~cldk.analysis.commons.graphs.path_order`. #: - #: **The per-callable graph orders do not have this tie-break, and that asymmetry is deliberate + #: **The per-callable graph orders do not have that tie-break, and that asymmetry is deliberate #: but not free.** ``CFG_ORDER``/``CDG_ORDER``/``DDG_ORDER`` (``cldk/analysis/java/backend.py``) #: end at ``coalesce(kind,'')`` / ``dst`` / ``coalesce(prov,[])`` — no ``elementId``, because #: the key has to be *the same key the in-memory backend sorts by*, and there is no element id @@ -910,11 +906,6 @@ def _value_slice(self, root: SliceNode, *, backward: bool, depth: int | None, ma #: every one with a distinct key within its callable). If an analyzer ever emits one, the fix #: is a fourth component both backends can compute, not an ``elementId`` only one of them has. #: - #: ``allShortestPaths`` and not a plain variable-length match: a variable-length pattern - #: enumerates *trails*, which does not terminate on a real dependence graph, while - #: ``allShortestPaths`` is a bidirectional BFS. ``$cap`` is ``max_paths + 1`` so one extra row - #: reports the truncation, rather than a second traversal for a number the caller cannot act on. - #: #: ``all(n IN nodes(p) WHERE …)`` is the **interior** scope, and it is not optional: without it #: only the two endpoints carry the application prefix and a path could route through another #: application's nodes and come back. Leg 2.5b found exactly that leak twice in its own path @@ -923,8 +914,8 @@ def _value_slice(self, root: SliceNode, *, backward: bool, depth: int | None, ma _PATHS = sdg_path_query( "J", node_label="JBodyNode", - endpoint_scope="n.id STARTS WITH $prefix", - interior_scope="n.id STARTS WITH $prefix", + endpoint_scope=_scoped, + interior_scope=_scoped, projection="ref: n.id, kind: n.kind, line: n.start_line", rel_var="e", ) diff --git a/cldk/analysis/typescript/neo4j/neo4j_backend.py b/cldk/analysis/typescript/neo4j/neo4j_backend.py index bc57ed8d..957fc924 100644 --- a/cldk/analysis/typescript/neo4j/neo4j_backend.py +++ b/cldk/analysis/typescript/neo4j/neo4j_backend.py @@ -1840,16 +1840,7 @@ def callees_of(self, name: str, *, in_class: str | None = None, in_module: str | return [self._call_vertex(r["v"]) for r in self._run(self._CALLEES, sig=sig, callable_kinds=sorted(CALLABLE_KINDS), **self._scope_params)] # -----[ paths and flow predicates ]----- - #: One string per path, ordered exactly as Python would order the tuple ``hop_sort_key`` builds. - #: ``U+0001`` is the separator rather than ``|`` for one reason: string comparison agrees with - #: field-by-field comparison **only** when the separator sorts below every character a field can - #: hold, and ``|`` (0x7C) sorts *above* every lowercase letter. ``elementId`` is the last field - #: of each hop and breaks the tie between parallel relationships a caller cannot tell apart. - #: - #: ``allShortestPaths`` and not a plain variable-length match: a variable-length pattern - #: enumerates *trails*, which does not terminate on a real dependence graph, while - #: ``allShortestPaths`` is a bidirectional BFS. ``$cap`` is ``max_paths + 1`` so one extra row - #: reports the truncation, rather than a second traversal for a number the caller cannot act on. + #: See :func:`~cldk.analysis.commons.graphs.path_order`. #: #: ``all(n IN nodes(p) …)`` puts the application-prefix predicate on **every** node of the path, not #: only on the two the ids pin: the SDG types are deliberately outside the audit's @@ -1857,7 +1848,7 @@ def callees_of(self, name: str, *, in_class: str | None = None, in_module: str | _PATHS = sdg_path_query( "TS", node_label="CanNode:TSBodyNode", - interior_scope=_scoped("n"), + interior_scope=_scoped, projection="ref: n.id, kind: n.kind, of: n.of, line: n.start_line, " "callable: head([(c:TSCallable)-[:TS_HAS_BODY_NODE]->(n) | c.signature]), " "c_line: head([(c:TSCallable)-[:TS_HAS_BODY_NODE]->(n) | c.start_line])", From 25d55578b3043897d18db81101bc1eb8322a750f Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Wed, 9 Sep 2026 08:28:28 -0400 Subject: [PATCH 14/65] test(commons): drop PATHS_ARGS, judge the shipped statement instead PATHS_ARGS and test_sdg_path_query_reproduces_each_backends_paths compared generator(args A) against generator(args B) -- two hand-kept copies of the same argument list, on a branch whose point is removing duplicates kept in sync by hand. The digest test already catches every argument change, so both are deleted outright. test_the_generated_statement_still_formats and test_the_scope_predicates_are_written_where_the_backend_writes_them now assert against backend._PATHS directly instead of reconstructing a statement from PATHS_ARGS -- they judge the statement that ships, not a second copy of its inputs. Same assertions, same messages. Also gives the digest tripwire a useful failure message (backend._PATHS instead of two bare hashes) and updates both section header comments to stop describing PATHS_ARGS: the arguments live at each backend's own sdg_path_query(...) call site now. --- tests/analysis/commons/test_lifted_helpers.py | 77 +++++-------------- 1 file changed, 21 insertions(+), 56 deletions(-) diff --git a/tests/analysis/commons/test_lifted_helpers.py b/tests/analysis/commons/test_lifted_helpers.py index dab0ae7e..68b82a02 100644 --- a/tests/analysis/commons/test_lifted_helpers.py +++ b/tests/analysis/commons/test_lifted_helpers.py @@ -239,43 +239,12 @@ def test_the_three_constants_differ_only_in_the_relationship_prefix(): # this -- the relationship variable, which Java spells ``e`` where the other two spell ``r``. That # last one is semantically inert; it is a parameter so this lift can be byte-identical rather than a # judgement call. Normalising it is a separate, arguable change. +# +# The arguments themselves live at each backend's own ``sdg_path_query(...)`` call site, not here -- +# these tests judge ``backend._PATHS``, the statement that ships, rather than a reconstruction of it +# from a second, hand-kept copy of its arguments. # ---------------------------------------------------------------------------------------------- -#: The arguments that reproduce each backend's statement, written out rather than derived: a -#: derivation that produced the wrong string would also produce the wrong expectation. -PATHS_ARGS = { - "PY": dict( - node_label="PyBodyNode", - projection="ref: n.id, kind: n.kind, var: n.var, line: n.start_line, " - "callable: head([(c:PyCallable)-[:PY_HAS_BODY_NODE]->(n) | c.signature]), " - "c_line: head([(c:PyCallable)-[:PY_HAS_BODY_NODE]->(n) | c.start_line])", - ), - "J": dict( - node_label="JBodyNode", - endpoint_scope="n.id STARTS WITH $prefix", - interior_scope="n.id STARTS WITH $prefix", - projection="ref: n.id, kind: n.kind, line: n.start_line", - rel_var="e", - ), - "TS": dict( - node_label="CanNode:TSBodyNode", - interior_scope="(n.id STARTS WITH $p)", - projection="ref: n.id, kind: n.kind, of: n.of, line: n.start_line, " - "callable: head([(c:TSCallable)-[:TS_HAS_BODY_NODE]->(n) | c.signature]), " - "c_line: head([(c:TSCallable)-[:TS_HAS_BODY_NODE]->(n) | c.start_line])", - ), -} - - -@pytest.mark.parametrize("P", ["PY", "J", "TS"]) -def test_sdg_path_query_reproduces_each_backends_paths(P): - """Byte-identical, and that is the whole safety property of this lift: these three statements are - shipped code on a release branch mid-rc, so anything but equality is a behaviour change.""" - from cldk.analysis.commons.graphs import sdg_path_query - - backend = dict(_path_backends())[P] - assert sdg_path_query(P, **PATHS_ARGS[P]) == backend._PATHS - @pytest.mark.parametrize("P", ["PY", "J", "TS"]) def test_the_generated_statement_still_formats(P): @@ -287,23 +256,23 @@ def test_the_generated_statement_still_formats(P): brace, which would mean an escape the template never resolved and a statement the server would reject. """ - from cldk.analysis.commons.graphs import sdg_path_query, sdg_rel_pattern + from cldk.analysis.commons.graphs import sdg_rel_pattern - out = sdg_path_query(P, **PATHS_ARGS[P]).format(rels=sdg_rel_pattern(P), depth="") + backend = dict(_path_backends())[P] + out = backend._PATHS.format(rels=sdg_rel_pattern(P), depth="") assert "{{" not in out and "}}" not in out, "an escape survived formatting" assert "{id:$src}" in out and "{id:$dst}" in out assert out.count("allShortestPaths") == 1 def test_the_scope_predicates_are_written_where_the_backend_writes_them(): - """Not a restatement of the equality above: it pins *which* backend scopes what, so a future + """Not a restatement of the digest below: it pins *which* backend scopes what, so a future edit that moved Python onto the prefix predicate would fail here with a reason rather than silently changing a statement whose omission was measured and is sanctioned (see tests/analysis/python/test_neo4j_multi_application_scope.py -- id-keying is a scope kind). """ - from cldk.analysis.commons.graphs import sdg_path_query - - py, j, ts = (sdg_path_query(P, **PATHS_ARGS[P]) for P in ("PY", "J", "TS")) + backends = dict(_path_backends()) + py, j, ts = (backends[P]._PATHS for P in ("PY", "J", "TS")) assert "STARTS WITH" not in py, "Python's path statement is scoped by id, deliberately" assert j.count("STARTS WITH $prefix") == 3, "Java scopes both endpoints and the interior" assert ts.count("STARTS WITH $p") == 1, "TypeScript scopes the interior only" @@ -313,13 +282,10 @@ def test_the_scope_predicates_are_written_where_the_backend_writes_them(): # Leg 4a, Task 3: the byte-identity guard expires the moment ``_PATHS`` becomes the call it used to # be compared against. # -# ``test_sdg_path_query_reproduces_each_backends_paths`` above compares -# ``sdg_path_query(P, **PATHS_ARGS[P])`` against ``backend._PATHS`` -- and after Task 3, ``_PATHS`` -# *is* ``sdg_path_query(P, **)``. The two sides no longer come from -# independent sources, so that test now only catches a divergence between ``PATHS_ARGS`` and the -# backend's own arguments; a change to ``sdg_path_query``, ``path_order`` or ``via_case`` moves both -# sides together and passes silently. The digest below is what still fails when the statement -# itself changes. +# Once each backend's ``_PATHS`` became a call to ``sdg_path_query(...)`` with its own arguments, +# comparing that call's result against itself proved nothing: a change to ``sdg_path_query``, +# ``path_order`` or ``via_case`` moves both sides together and passes silently. The digest below is +# what still fails when the statement itself changes. # ---------------------------------------------------------------------------------------------- #: A digest of each backend's `_PATHS`, pinned so that a change to the shared generator, to @@ -327,18 +293,17 @@ def test_the_scope_predicates_are_written_where_the_backend_writes_them(): #: #: This replaces the byte-identity comparison leg 4a retired. While `_PATHS` was a hand-written #: literal, comparing it against `sdg_path_query()` proved the generator reproduced it -- the two -#: sides were independent. Now `_PATHS` *is* that call, so both sides of that equality move -#: together and only a mismatch between `PATHS_ARGS` and the backend's own arguments can fail it. -#: A digest is what still fails when the statement itself changes. +#: sides were independent. Once `_PATHS` became that call itself, both sides of that equality moved +#: together, so only a digest still fails when the statement itself changes. #: -#: **When this fails:** the statement changed. Print -#: `sdg_path_query(P, **PATHS_ARGS[P])` and diff it against the previous value to see how, decide -#: whether the change was intended, and if it was, update the digest **in the same commit that -#: changed the statement** -- never in a separate one, or the two stop being reviewable together. +#: **When this fails:** the statement changed. Print `backend._PATHS` and diff it against the +#: previous value to see how, decide whether the change was intended, and if it was, update the +#: digest **in the same commit that changed the statement** -- never in a separate one, or the two +#: stop being reviewable together. PATHS_DIGESTS = {"PY": "c1ea290360d42460", "J": "236a302937bcd98a", "TS": "a710986b595dc4df"} @pytest.mark.parametrize("P", ["PY", "J", "TS"]) def test_the_generated_statement_has_not_drifted(P): backend = dict(_path_backends())[P] - assert hashlib.sha256(backend._PATHS.encode()).hexdigest()[:16] == PATHS_DIGESTS[P] + assert hashlib.sha256(backend._PATHS.encode()).hexdigest()[:16] == PATHS_DIGESTS[P], backend._PATHS From 5444dab64b78f473c31f6df92ed197586faa5b29 Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Wed, 9 Sep 2026 13:39:40 -0400 Subject: [PATCH 15/65] feat(results): TaintResult, with exhausted named for the search and not the conclusion --- cldk/analysis/commons/results.py | 39 +++++++++++++++++++ .../analysis/commons/test_taint_semantics.py | 33 ++++++++++++++++ 2 files changed, 72 insertions(+) create mode 100644 tests/analysis/commons/test_taint_semantics.py diff --git a/cldk/analysis/commons/results.py b/cldk/analysis/commons/results.py index e1babf00..ee1c4c08 100644 --- a/cldk/analysis/commons/results.py +++ b/cldk/analysis/commons/results.py @@ -640,3 +640,42 @@ class FlowPaths(BoundedResult): def _items(self) -> list: return self.paths + + +class TaintResult(FlowPaths): + """Which of the requested (source, sink) pairs flow, which were searched to exhaustion, and what + stopped the rest. + + A subclass of :class:`FlowPaths` rather than a new shape: the witnesses *are* flow paths, each + already carrying ``weakest``, and ``complete`` already means "were all the witnesses returned". + Three fields are added and nothing is redefined. + + **What ``exhausted`` claims.** A pair is listed when all three hold: the call passed + ``depth=None``, the search found no path for it, and no :attr:`unresolved` diagnostic implicates + it. That is a claim about **the emitted graph plus the ledger, and never about the program** — + which is why the field is named for the search rather than for the conclusion. The step from + "exhausted" to "this alert is a false positive" is the caller's, deliberately: the DDG's + ``points-to`` edges over-approximate, which is the safe direction for an absence claim, but the + call structure *under*-approximates wherever dispatch is unresolved, so a missing call edge means + a real flow can exist with no path in the graph. Absence of path bounds the program only where the + frontier was fully resolved, and :attr:`unresolved` is what enumerates where it was not. + + **Why it is stored rather than derived.** It is computable — ``all_pairs − pairs_with_paths − + pairs_with_unresolved`` when ``depth is None``, and ``∅`` otherwise — and that is the reason not + to: the load-bearing answer must not be a subtraction the caller can get wrong, and a subtraction + with a mode switch in front of it is worse than one without. + + Attributes: + exhausted: The ``(source, sink)`` pairs searched to exhaustion with a clean ledger, named by + the same strings the caller passed — never a ``can://`` id. Empty whenever ``depth`` was + not ``None``. + roots: What each selector matched, so a conclusion is auditable rather than asserted. + resolved: The human-readable form of ``roots``, via ``slice_resolved``. + unresolved: The frontier ledger. Each diagnostic names the pair it affects, so a caller can + tell which of forty sources was blocked rather than only that one was. + """ + + exhausted: list[tuple[str, str]] + roots: list[SliceNode] + resolved: str + unresolved: list[Diagnostic] diff --git a/tests/analysis/commons/test_taint_semantics.py b/tests/analysis/commons/test_taint_semantics.py new file mode 100644 index 00000000..4e022ccc --- /dev/null +++ b/tests/analysis/commons/test_taint_semantics.py @@ -0,0 +1,33 @@ +# tests/analysis/commons/test_taint_semantics.py +from cldk.analysis.commons.results import Diagnostic, TaintResult + + +def _pair(a="a", b="b"): + return (a, b) + + +def test_empty_and_complete_is_an_exhausted_pair(): + """The verdict table's middle row: nothing found, and the search was whole.""" + r = TaintResult(paths=[], complete=True, exhausted=[_pair()], roots=[], resolved="", unresolved=[]) + assert not r # BoundedResult makes the payload's truth the list's + assert _pair() in r.exhausted + + +def test_empty_and_incomplete_is_not_exhausted(): + """The bottom row: nothing found, but something stopped the search, so no pair may be listed.""" + d = Diagnostic(code="unresolved_dispatch", message="x") + r = TaintResult(paths=[], complete=False, exhausted=[], roots=[], resolved="", unresolved=[d]) + assert not r + assert r.exhausted == [] + + +def test_a_result_behaves_as_its_path_list(): + """Inherited from BoundedResult: `for p in result` and `len(result)` are the paths, not fields.""" + r = TaintResult(paths=[], complete=True, exhausted=[], roots=[], resolved="", unresolved=[]) + assert list(r) == [] and len(r) == 0 + + +def test_exhausted_survives_a_round_trip(): + """A triage caller writes the result to JSON and another process reads the verdict back.""" + r = TaintResult(paths=[], complete=True, exhausted=[_pair()], roots=[], resolved="", unresolved=[]) + assert TaintResult.model_validate(r.model_dump()).exhausted == [_pair()] From 6f3aef6daf899ed66d2de403378407af437fe84e Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Wed, 9 Sep 2026 13:46:04 -0400 Subject: [PATCH 16/65] =?UTF-8?q?docs(results):=20TaintResult.unresolved?= =?UTF-8?q?=20=E2=80=94=20describe=20the=20pair=20association=20as=20messa?= =?UTF-8?q?ge=20prose,=20not=20a=20Diagnostic=20guarantee?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cldk/analysis/commons/results.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/cldk/analysis/commons/results.py b/cldk/analysis/commons/results.py index ee1c4c08..ad8f45b2 100644 --- a/cldk/analysis/commons/results.py +++ b/cldk/analysis/commons/results.py @@ -671,8 +671,17 @@ class TaintResult(FlowPaths): not ``None``. roots: What each selector matched, so a conclusion is auditable rather than asserted. resolved: The human-readable form of ``roots``, via ``slice_resolved``. - unresolved: The frontier ledger. Each diagnostic names the pair it affects, so a caller can - tell which of forty sources was blocked rather than only that one was. + unresolved: The frontier ledger. Each entry is a ``Diagnostic`` with ``code + ="unresolved_dispatch"`` (already in the closed vocabulary; no widening needed) and the + affected pair named in ``message`` prose — the same convention every other diagnostic in + this SDK follows (e.g. the Neo4j backend's ``module_scope`` message). ``Diagnostic`` has + no structured field for a pair today, so this is a human-readable explanation, not + something to compute with: the pair→diagnostic association ``exhausted`` needs is tracked + internally by the implementation before each ``Diagnostic`` is built, never recovered by + parsing ``message`` back out — that would resurrect the derivation this stored field + exists to avoid. A caller that needs the association programmatically wants an additive + ``subject`` field on ``Diagnostic``, which widens a published model contract + (``docs/agent-api-reference.md``) and should be requested rather than assumed here. """ exhausted: list[tuple[str, str]] From b0cd49ec2921f53d1abb44d6e6690a07f9b4c819 Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Wed, 9 Sep 2026 13:54:21 -0400 Subject: [PATCH 17/65] feat(graphs): shortest_walks filters in both passes, so a sanitized short route cannot hide a clean long one --- cldk/analysis/commons/graphs.py | 45 +++++++++++++++++-- .../analysis/commons/test_taint_semantics.py | 39 ++++++++++++++++ 2 files changed, 81 insertions(+), 3 deletions(-) diff --git a/cldk/analysis/commons/graphs.py b/cldk/analysis/commons/graphs.py index 47de828a..8d8a621c 100644 --- a/cldk/analysis/commons/graphs.py +++ b/cldk/analysis/commons/graphs.py @@ -273,7 +273,17 @@ def flow_path(nodes: Sequence[SliceNode], edges: Sequence[Tuple[str, "str | None return FlowPath(hops=[PathHop(frm=nodes[i], to=nodes[i + 1], via=via[rel], var=var, prov=list(prov or [])) for i, (rel, var, prov) in enumerate(edges)]) -def shortest_walks(edges: Mapping[str, Mapping[str, Sequence[tuple]]], src: str, dst: str, depth: int | None, limit: int, *, via: Mapping[str, str]) -> List[list]: +def shortest_walks( + edges: Mapping[str, Mapping[str, Sequence[tuple]]], + src: str, + dst: str, + depth: int | None, + limit: int, + *, + via: Mapping[str, str], + allow_edge: Callable[[str, "str | None"], bool] | None = None, + allow_node: Callable[[str], bool] | None = None, +) -> List[list]: """Up to ``limit`` shortest ``src``->``dst`` walks over ``edges``, in the documented order. The local backends' twin of the graph's ``allShortestPaths``, and only shortest walks for its @@ -284,6 +294,17 @@ def shortest_walks(edges: Mapping[str, Mapping[str, Sequence[tuple]]], src: str, ordinary -- one statement feeding one argument on several variables is several distinct paths -- and collapsing them would merge several pieces of evidence into one. + ``allow_edge`` and ``allow_node`` are the taint sanitizer cut: ``allow_edge(relationship type, + var)`` keeps a label, ``allow_node(node id)`` keeps a node (checked against ``src`` itself too, + so a source inside a cut callable yields no walk at all). Both default to ``None``, meaning no + filtering, so every caller that predates taint is unaffected. **They must be applied before the + breadth-first pass computes ``dist``, not only in the depth-first replay** -- see :func:`steps` + below, which both passes call. Filtering the replay alone would leave ``dist`` describing the + unfiltered graph: a sanitized 2-hop route would still pin ``dist[dst]`` to 2, and a clean 3-hop + route would never be visited, returning no walk at all for a pair that genuinely flows -- a false + refutation the Neo4j backend does not share, because its planner inlines the predicate into the + shortest-path search itself. + Two passes. The first is a breadth-first level walk keeping the hop count each node was *first* reached at; the second is a depth-first replay that only ever steps to a node whose recorded distance is exactly one more than the walk so far, so it visits shortest walks and nothing else. @@ -300,12 +321,30 @@ def shortest_walks(edges: Mapping[str, Mapping[str, Sequence[tuple]]], src: str, a second place for the two backends of a language -- or the backends of two languages -- to drift on what "shortest, in order" means. """ + if allow_node is not None and not allow_node(src): + return [] + + def steps(node: str): + """The ``(destination, labels)`` pairs the search may step to from ``node``, filtered. + + Used by **both** passes, and that is the whole point: filtering only the depth-first replay + would leave the breadth-first ``dist`` describing the unfiltered graph (see the module + docstring above for why that is a false refutation, not a performance shortcut). Filtering + here instead makes ``dist`` the shortest *satisfying* distance. + """ + for d, labels in edges.get(node, {}).items(): + if allow_node is not None and not allow_node(d): + continue + kept = [lab for lab in labels if allow_edge is None or allow_edge(lab[0], lab[1])] + if kept: + yield d, kept + dist, frontier, hops = {src: 0}, [src], 0 while frontier and dst not in dist and (depth is None or hops < depth): hops += 1 nxt = [] for s in frontier: - for d in edges.get(s, ()): + for d, _ in steps(s): if d not in dist: dist[d] = hops nxt.append(d) @@ -319,7 +358,7 @@ def walk(node: str, walked: list) -> None: if node == dst: out.append(list(walked)) return - options = sorted((via[rel], var or "", d, (rel, var, prov)) for d, labels in edges.get(node, {}).items() if dist.get(d) == len(walked) + 1 for rel, var, prov in labels) + options = sorted((via[rel], var or "", d, (rel, var, prov)) for d, labels in steps(node) if dist.get(d) == len(walked) + 1 for rel, var, prov in labels) for _, _, d, label in options: walked.append((d, label)) walk(d, walked) diff --git a/tests/analysis/commons/test_taint_semantics.py b/tests/analysis/commons/test_taint_semantics.py index 4e022ccc..396e0002 100644 --- a/tests/analysis/commons/test_taint_semantics.py +++ b/tests/analysis/commons/test_taint_semantics.py @@ -1,6 +1,45 @@ # tests/analysis/commons/test_taint_semantics.py +from cldk.analysis.commons.graphs import shortest_walks, via_table from cldk.analysis.commons.results import Diagnostic, TaintResult +VIA = via_table("PY") + +#: The shape that separates a correct filter from a plausible one: the SHORTEST route is sanitized +#: and a LONGER one is clean. Filtering the replay alone returns nothing here. +ADJ = { + "a": {"m": [("PY_DDG", "tainted", ["ssa"])], "n1": [("PY_DDG", "clean", ["ssa"])]}, + "m": {"b": [("PY_DDG", "tainted", ["ssa"])]}, + "n1": {"n2": [("PY_DDG", "clean", ["ssa"])]}, + "n2": {"b": [("PY_DDG", "clean", ["ssa"])]}, +} + + +def test_unfiltered_finds_the_short_route(): + walks = shortest_walks(ADJ, "a", "b", None, 10, via=VIA) + assert [len(w) for w in walks] == [2] + + +def test_a_sanitized_shortest_route_does_not_hide_a_clean_longer_one(): + """The local twin of the inlining result: the search must find the shortest *satisfying* walk, + not filter the shortest walk. If this returns [], the predicate was applied to the replay only + and every local taint refutation is unsound.""" + walks = shortest_walks(ADJ, "a", "b", None, 10, via=VIA, allow_edge=lambda rel, var: var != "tainted") + assert [len(w) for w in walks] == [3], "the clean 3-hop route was not found" + + +def test_a_null_var_hop_is_not_cut_by_a_variable_sanitizer(): + """PARAM_IN carries no var. A predicate that treats None as "not equal to anything" is fine; one + that treats it as unknown-and-therefore-excluded refutes every interprocedural flow.""" + adj = {"a": {"p": [("PY_DDG", "clean", ["ssa"])]}, "p": {"q": [("PY_PARAM_IN", None, None)]}, "q": {"b": [("PY_DDG", "clean", ["ssa"])]}} + walks = shortest_walks(adj, "a", "b", None, 10, via=VIA, allow_edge=lambda rel, var: var != "tainted") + assert [len(w) for w in walks] == [3] + + +def test_a_node_cut_removes_a_whole_callable(): + """The callable-granular sanitizer: every body node under the callable's id prefix is cut.""" + walks = shortest_walks(ADJ, "a", "b", None, 10, via=VIA, allow_node=lambda nid: not nid.startswith("m")) + assert [len(w) for w in walks] == [3] + def _pair(a="a", b="b"): return (a, b) From 606d548fba0c53ce977b3293f646c26630e190b3 Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Wed, 9 Sep 2026 14:04:50 -0400 Subject: [PATCH 18/65] test(graphs): guard the replay's own filter and the src cut in shortest_walks Adds the mirror of the sanitized-shortest-route test: a sanitized parallel edge must not ride along in a walk the unfiltered pass would still emit (false confirmation), and a source inside a cut callable must return no walk. Both were previously unguarded -- verified by watching each fail on its respective mutant before restoring. --- .../analysis/commons/test_taint_semantics.py | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/analysis/commons/test_taint_semantics.py b/tests/analysis/commons/test_taint_semantics.py index 396e0002..159633bd 100644 --- a/tests/analysis/commons/test_taint_semantics.py +++ b/tests/analysis/commons/test_taint_semantics.py @@ -41,6 +41,31 @@ def test_a_node_cut_removes_a_whole_callable(): assert [len(w) for w in walks] == [3] +PARALLEL = {"a": {"b": [("PY_DDG", "tainted", ["ssa"]), ("PY_DDG", "clean", ["ssa"])]}} + + +def test_a_sanitized_parallel_edge_is_not_reported_as_evidence(): + """The mirror of ``test_a_sanitized_shortest_route_does_not_hide_a_clean_longer_one``, and the + two only make sense read together: that test guards the BFS half against a false *refutation* + (a sanitized short route hiding a clean long one); this one guards the DFS replay half against a + false *confirmation*. Parallel edges between one pair are ordinary (see ``shortest_walks``'s own + docstring: "one statement feeding one argument on several variables is several distinct paths"), + so a var-sanitizer can cut one label of a pair and not its sibling. The clean label alone keeps + ``dist[b]`` at 1 no matter which pass filters, so a BFS-only filter cannot fail this case -- only + the replay's own filter keeps the sanitized label out of the walk it emits as taint evidence. + """ + walks = shortest_walks(PARALLEL, "a", "b", None, 10, via=VIA, allow_edge=lambda rel, var: var != "tainted") + assert [lab[1] for w in walks for _, lab in w] == ["clean"] + + +def test_a_cut_source_yields_no_walk(): + """A source inside a cut callable yields no walk at all -- checked against ``src`` up front, + since ``src`` is never itself a ``steps()`` destination for the BFS/DFS filtering to catch. Also + covers ``dst`` for free: ``b`` is only ever reached as a destination, so this graph's one walk + disappearing when either endpoint is disallowed exercises both.""" + assert shortest_walks(ADJ, "a", "b", None, 10, via=VIA, allow_node=lambda n: n != "a") == [] + + def _pair(a="a", b="b"): return (a, b) From 336995f68d9253849f620559879604c22b0091b4 Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Wed, 9 Sep 2026 14:14:39 -0400 Subject: [PATCH 19/65] feat(graphs): sdg_taint_query, with the sanitizer cut inside the pattern Multi-source, multi-sink shortest-path statement behind taint(). Mirrors sdg_path_query's callable-based endpoint_scope/interior_scope injection points rather than the brief's string-replace sketch, since a per-pair statement binds more variables than a/b and a textual .replace('n.', 'a.') would corrupt any fragment containing fn.. The sanitizer cut (cut_callables as an id-prefix test, cut_vars against coalesce(r.var, '')) lives inside the allShortestPaths pattern so it inlines into the ShortestPath operator instead of filtering rows after an unsanitized shortest path has already won. Cap is per pair via an ordered WITH + collect(p)[0..$cap], not a flat LIMIT. --- cldk/analysis/commons/graphs.py | 65 +++++++++++++++++++ tests/analysis/commons/test_lifted_helpers.py | 51 +++++++++++++++ 2 files changed, 116 insertions(+) diff --git a/cldk/analysis/commons/graphs.py b/cldk/analysis/commons/graphs.py index 8d8a621c..f73fffba 100644 --- a/cldk/analysis/commons/graphs.py +++ b/cldk/analysis/commons/graphs.py @@ -240,6 +240,71 @@ def sdg_path_query(P: str, *, node_label: str, endpoint_scope: Callable[[str], s ) +def sdg_taint_query(P: str, *, node_label: str, endpoint_scope: Callable[[str], str] | None = None, interior_scope: Callable[[str], str] | None = None, projection: str, rel_var: str = "r") -> str: + """The multi-source, multi-sink shortest-path statement behind ``taint()``. + + Differs from :func:`sdg_path_query` in four ways, each load-bearing: + + * ``$srcs`` / ``$dsts`` are lists, not a single ``$src`` / ``$dst``, so m sources against n + sinks is one round trip and one traversal per pair rather than m*n statements. + * A sanitizer cut lives **inside** the pattern -- ``$cut_callables`` (a list of ``can://`` + prefixes; a body-node id *is* ``@``, so cutting a callable is a + prefix test and needs no join) and ``$cut_vars`` (a list of variable names) -- rather than + being applied to the rows a first, unfiltered match returns. Measured on Neo4j 5.26.30: an + ``all()`` over ``relationships(p)`` inlines into the ``ShortestPath`` operator, so the search + itself returns the shortest *unsanitized* path. Filtering afterwards would report no flow for + a source whose shortest paths are all sanitized and whose next one is not -- a false + refutation, which in this design closes a live security alert. The predicate has to stay in + an inlinable form: a subquery or an aggregate here reintroduces the exhaustive fallback, + which is trail enumeration and does not terminate on a real dependence graph. + * ``coalesce({rel_var}.var, '')`` is mandatory, and for three reasons that all still hold even + though the emitter has improved: ``CDG`` and ``SUMMARY`` carry no properties at all; + ``PARAM_IN``/``PARAM_OUT`` are written by the emitter as ``prune({{"var": e.var}})``, so the + key is simply absent whenever the formal has none; and both Neo4j backends attach to graphs + emitted below the current floor, where measurement found ``PY_PARAM_IN`` 0 of 4 and + ``PY_PARAM_OUT`` 0 of 6 carrying ``var`` at all (a floor-raising codeanalyzer-python 1.5.1 / + java 3.1.2 / typescript 1.5.3 measured 4 of 4 and 6 of 6 -- better, not universal, and no + floor-raise touches ``CDG``/``SUMMARY``, which carry no properties by construction). A bare + ``{rel_var}.var <> $v`` is ``null`` on any hop missing the property, ``all()`` over a ``null`` + term is ``null``, and the path is silently excluded -- the same false-refutation failure mode + as above, reached a different way. + + **The empty string must never be a legal member of ``$cut_vars``.** ``coalesce`` maps a null + ``var`` to ``''``, so if ``''`` reached ``$cut_vars`` the predicate would read as "cut every + hop whose var is null" -- which, given the paragraph above, is most control, summary and + pre-1.5.1 param hops in the application. One malformed sanitizer would silently sever most of + the graph and turn found flows into confident refutations. This function does not enforce + the rejection; a caller upstream of it must. + * The cap is **per pair** -- ``collect(p)[0..$cap]`` after an ordered ``WITH a, b`` -- rather + than a flat ``LIMIT``, because with one sink and forty sources a flat cap lets one prolific + pair starve the other thirty-nine, and in triage the per-source witness is the answer. + + Returns ``a.id AS src`` and ``b.id AS dst`` alongside the path projection, so a caller can tell + which source reached which sink -- the m*n batching above is only useful if the grouping survives + it. + + Same injection points as :func:`sdg_path_query` -- ``node_label``, ``endpoint_scope``, + ``interior_scope``, ``projection``, ``rel_var`` -- and the same ``.format()`` template still + carrying ``{rels}`` and ``{depth}``. + """ + a_scope = f" AND {endpoint_scope('a')}" if endpoint_scope else "" + b_scope = f" AND {endpoint_scope('b')}" if endpoint_scope else "" + interior = f" AND all(n IN nodes(p) WHERE {interior_scope('n')})" if interior_scope else "" + return ( + f"MATCH (a:{node_label}) WHERE a.id IN $srcs{a_scope} " + f"MATCH (b:{node_label}) WHERE b.id IN $dsts{b_scope} " + "MATCH p = allShortestPaths((a)-[:{rels}*1..{depth}]->(b)) " + "WHERE all(n IN nodes(p) WHERE NOT any(q IN $cut_callables WHERE n.id STARTS WITH q))" + + interior + " " + f"AND all({rel_var} IN relationships(p) WHERE NOT coalesce({rel_var}.var, '') IN $cut_vars) " + "WITH a, b, p, " + path_order(P) + " AS key ORDER BY length(p), key " + "WITH a, b, collect(p)[0..$cap] AS ps " + "UNWIND ps AS p " + f"RETURN a.id AS src, b.id AS dst, [n IN nodes(p) | {{{{{projection}}}}}] AS ns, " + f"[{rel_var} IN relationships(p) | {{{{via: type({rel_var}), var: {rel_var}.var, prov: {rel_var}.prov}}}}] AS rs" + ) + + def hop_sort_key(hops: Sequence[PathHop]) -> Tuple: """The order two paths are compared in, in the caller's *own* vocabulary. diff --git a/tests/analysis/commons/test_lifted_helpers.py b/tests/analysis/commons/test_lifted_helpers.py index 68b82a02..235479c7 100644 --- a/tests/analysis/commons/test_lifted_helpers.py +++ b/tests/analysis/commons/test_lifted_helpers.py @@ -307,3 +307,54 @@ def test_the_scope_predicates_are_written_where_the_backend_writes_them(): def test_the_generated_statement_has_not_drifted(P): backend = dict(_path_backends())[P] assert hashlib.sha256(backend._PATHS.encode()).hexdigest()[:16] == PATHS_DIGESTS[P], backend._PATHS + + +# ---------------------------------------------------------------------------------------------- +# Leg 4b, Task 3: sdg_taint_query() -- the multi-source, multi-sink statement behind taint(). +# ---------------------------------------------------------------------------------------------- + + +def test_the_taint_query_is_null_safe_and_caps_per_pair(): + """Three properties, each of which has a specific failure mode if absent.""" + from cldk.analysis.commons.graphs import sdg_taint_query + + q = sdg_taint_query("PY", node_label="PyBodyNode", projection="ref: n.id") + assert "coalesce(r.var, '')" in q, "a bare r.var <> $v refutes every interprocedural flow" + assert "collect(p)[0..$cap]" in q, "a flat LIMIT lets one prolific pair starve the rest" + assert "a.id IN $srcs" in q and "b.id IN $dsts" in q, "taint is m x n in one statement" + assert "allShortestPaths" in q, "a variable-length pattern enumerates trails and will not finish" + + +def test_the_taint_query_groups_by_pair(): + """The result must say which source reached which sink; a caller cannot recover it otherwise.""" + from cldk.analysis.commons.graphs import sdg_taint_query + + q = sdg_taint_query("PY", node_label="PyBodyNode", projection="ref: n.id") + assert "a.id AS src" in q and "b.id AS dst" in q + + +def test_the_taint_query_uses_callables_not_string_replace(): + """The scope injection points are callables, mirroring ``sdg_path_query`` -- a textual + ``.replace('n.', 'a.')`` would corrupt any fragment containing ``fn.`` (-> ``fa.``).""" + from cldk.analysis.commons.graphs import sdg_taint_query + + q = sdg_taint_query( + "J", + node_label="JBodyNode", + endpoint_scope=lambda v: f"{v}.id STARTS WITH $prefix", + interior_scope=lambda v: f"{v}.id STARTS WITH $prefix", + projection="ref: n.id", + rel_var="e", + ) + assert q.count("STARTS WITH $prefix") == 3 + assert "AND a.id STARTS WITH $prefix" in q + assert "AND b.id STARTS WITH $prefix" in q + + +def test_the_taint_query_still_formats(): + from cldk.analysis.commons.graphs import sdg_rel_pattern, sdg_taint_query + + q = sdg_taint_query("PY", node_label="PyBodyNode", projection="ref: n.id") + out = q.format(rels=sdg_rel_pattern("PY"), depth="") + assert "{{" not in out and "}}" not in out, "an escape survived formatting" + assert out.count("allShortestPaths") == 1 From ed14b3d8ad1bfd321d0e640f74ba85e950099193 Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Wed, 9 Sep 2026 15:47:10 -0400 Subject: [PATCH 20/65] feat(resolve): sanitizer selectors route by shape, scoped to their callable resolve_sanitizers() turns caller-written sanitizer selectors (a bare callable name, or a (variable, within) pair) into what the amended sdg_taint_query predicate needs: a list of callable-id prefixes to cut entirely, and a list of {var, prefix} cuts scoped to the callable named by `within`. Departs from the original brief in four ways, all load-bearing: - A variable selector is validated by checking it against the variable names actually present on SDG edges within the named callable, via an injected edge_vars_in callable, not resolve_value. resolve_value only covers formal_in parameters, which would reject a real, resolvable sanitizer target that is a local (a DDG edge var that is not a parameter). - The cut carries the resolved callable's ref as `prefix`, so the predicate can scope the cut with startNode(r).id STARTS WITH c.prefix instead of removing a variable name everywhere in the application. - An empty or whitespace-only variable selector raises: the predicate's coalesce(r.var, '') would otherwise let '' cut every hop with no var in that callable. - Selector shape and resolution now fully agree: a bare name that fails callable resolution raises there, never falls back to variable resolution, and a (name, within) pair whose name happens to also be a callable is still resolved as a pair. One resolver's own failure is the error; no shape is retried as the other. sdg_taint_query's cut predicate is amended to match: $cut_vars (flat, unscoped) becomes $cuts, a list of {var, prefix} maps, and the relation filter becomes all(r IN relationships(p) WHERE NOT any(c IN $cuts WHERE coalesce(r.var, '') = c.var AND startNode(r).id STARTS WITH c.prefix)) startNode(r) rather than either endpoint is deliberate: under-cutting is the safe failure direction, since over-cutting closes a live alert. Verified against the live leg4a graph that the amended predicate still inlines into ShortestPath (no separate Filter, no ExhaustiveShortestPath), and that a real (non-EXPLAIN) run with a concrete cuts entry lengthens the shortest src/dst path from 2 to 3 as expected, while an empty cuts list reproduces the uncut length-2 path. --- cldk/analysis/commons/graphs.py | 44 +++++-- cldk/analysis/commons/resolve.py | 99 ++++++++++++++- .../analysis/commons/test_taint_semantics.py | 116 ++++++++++++++++++ 3 files changed, 246 insertions(+), 13 deletions(-) diff --git a/cldk/analysis/commons/graphs.py b/cldk/analysis/commons/graphs.py index f73fffba..fff8c821 100644 --- a/cldk/analysis/commons/graphs.py +++ b/cldk/analysis/commons/graphs.py @@ -249,14 +249,31 @@ def sdg_taint_query(P: str, *, node_label: str, endpoint_scope: Callable[[str], sinks is one round trip and one traversal per pair rather than m*n statements. * A sanitizer cut lives **inside** the pattern -- ``$cut_callables`` (a list of ``can://`` prefixes; a body-node id *is* ``@``, so cutting a callable is a - prefix test and needs no join) and ``$cut_vars`` (a list of variable names) -- rather than - being applied to the rows a first, unfiltered match returns. Measured on Neo4j 5.26.30: an - ``all()`` over ``relationships(p)`` inlines into the ``ShortestPath`` operator, so the search - itself returns the shortest *unsanitized* path. Filtering afterwards would report no flow for - a source whose shortest paths are all sanitized and whose next one is not -- a false + prefix test and needs no join) and ``$cuts`` (a list of ``{var, prefix}`` maps) -- rather + than being applied to the rows a first, unfiltered match returns. Measured on Neo4j 5.26.30: + an ``all()`` over ``relationships(p)`` inlines into the ``ShortestPath`` operator, so the + search itself returns the shortest *unsanitized* path. Filtering afterwards would report no + flow for a source whose shortest paths are all sanitized and whose next one is not -- a false refutation, which in this design closes a live security alert. The predicate has to stay in an inlinable form: a subquery or an aggregate here reintroduces the exhaustive fallback, which is trail enumeration and does not terminate on a real dependence graph. + * ``$cuts`` is **scoped**, not a flat list of variable names: each member names the callable + the caller wrote the sanitizer's ``within=`` as (its ``prefix``, a ``can://`` id) alongside + the variable (``var``). A pair sanitizer ``("cleaned", "Handler.handle")`` therefore only + cuts the hops whose ``var`` is ``"cleaned"`` *and* whose start node's id falls under + ``Handler.handle``'s prefix, not every ``"cleaned"`` in the application. Variable names like + ``result``, ``answer`` and ``token`` recur across callables in any real program; an unscoped + cut on one of them would sever flows the caller never named, and over-cutting produces false + refutations -- the one output this design exists to refuse. Measured to still inline into + ``ShortestPath`` with no separate ``Filter`` and no ``ExhaustiveShortestPath``, which is the + only reason the flat form was tempting. + + ``startNode({rel_var})`` and not either endpoint is deliberate. A ``PARAM_IN`` edge starts in + the caller and ends in the callee, so a cut named for the *callee's* formal will not sever + that crossing edge -- the cut under-scopes rather than over-scopes at a call boundary. That + under-cuts, and under-cutting only over-reports: the caller investigates a flow that was in + fact sanitized. Scoping on either endpoint would over-cut -- closing a live alert -- so the + safe direction is the one written here, on purpose, not the one that reads more symmetric. * ``coalesce({rel_var}.var, '')`` is mandatory, and for three reasons that all still hold even though the emitter has improved: ``CDG`` and ``SUMMARY`` carry no properties at all; ``PARAM_IN``/``PARAM_OUT`` are written by the emitter as ``prune({{"var": e.var}})``, so the @@ -269,12 +286,14 @@ def sdg_taint_query(P: str, *, node_label: str, endpoint_scope: Callable[[str], term is ``null``, and the path is silently excluded -- the same false-refutation failure mode as above, reached a different way. - **The empty string must never be a legal member of ``$cut_vars``.** ``coalesce`` maps a null - ``var`` to ``''``, so if ``''`` reached ``$cut_vars`` the predicate would read as "cut every - hop whose var is null" -- which, given the paragraph above, is most control, summary and - pre-1.5.1 param hops in the application. One malformed sanitizer would silently sever most of - the graph and turn found flows into confident refutations. This function does not enforce - the rejection; a caller upstream of it must. + **The empty string must never be a legal member of any ``$cuts`` entry's ``var``.** + ``coalesce`` maps a null ``var`` to ``''``, so if ``''`` reached ``$cuts`` the predicate would + read as "cut every hop whose var is null, inside that prefix" -- which, given the paragraph + above, is most control and summary hops in the named callable, and (below the current floor) + its param hops too. One malformed sanitizer would silently sever most of a callable's graph + and turn found flows into confident refutations. This function does not enforce the + rejection; a caller upstream of it must (:func:`~cldk.analysis.commons.resolve.resolve_sanitizers` + does). * The cap is **per pair** -- ``collect(p)[0..$cap]`` after an ordered ``WITH a, b`` -- rather than a flat ``LIMIT``, because with one sink and forty sources a flat cap lets one prolific pair starve the other thirty-nine, and in triage the per-source witness is the answer. @@ -296,7 +315,8 @@ def sdg_taint_query(P: str, *, node_label: str, endpoint_scope: Callable[[str], "MATCH p = allShortestPaths((a)-[:{rels}*1..{depth}]->(b)) " "WHERE all(n IN nodes(p) WHERE NOT any(q IN $cut_callables WHERE n.id STARTS WITH q))" + interior + " " - f"AND all({rel_var} IN relationships(p) WHERE NOT coalesce({rel_var}.var, '') IN $cut_vars) " + f"AND all({rel_var} IN relationships(p) WHERE NOT any(c IN $cuts WHERE " + f"coalesce({rel_var}.var, '') = c.var AND startNode({rel_var}).id STARTS WITH c.prefix)) " "WITH a, b, p, " + path_order(P) + " AS key ORDER BY length(p), key " "WITH a, b, collect(p)[0..$cap] AS ps " "UNWIND ps AS p " diff --git a/cldk/analysis/commons/resolve.py b/cldk/analysis/commons/resolve.py index 6c180d8c..9c1960b0 100644 --- a/cldk/analysis/commons/resolve.py +++ b/cldk/analysis/commons/resolve.py @@ -48,7 +48,7 @@ from __future__ import annotations -from typing import Callable, List, NamedTuple, Optional, Sequence, Tuple, TypeVar +from typing import Callable, Collection, Dict, List, NamedTuple, Optional, Sequence, Tuple, TypeVar, Union from cldk.analysis.commons.keys import module_dotted from cldk.utils.exceptions import AmbiguousName, SelectorNotInGraph @@ -409,3 +409,100 @@ def body_node_kind(kind: str, var: Optional[str]) -> tuple[str, Optional[str], O return _PASSING_KINDS[kind], None, None return _PASSING_KINDS[kind], value_candidate(var).leaf, None return kind, None, None + + +def resolve_sanitizers( + sanitizers: Sequence[Union[str, Tuple[str, str]]], + *, + resolve_callable: Callable[[str], "T"], + edge_vars_in: Callable[[str], Collection[str]], +) -> Tuple[List[Dict[str, str]], List[str]]: + """Turn caller-written sanitizer selectors into what :func:`~cldk.analysis.commons.graphs.sdg_taint_query` + needs: ``(cuts, cut_callable_ids)``, bound respectively to its ``$cuts`` and ``$cut_callables``. + + Two shapes, two meanings (spec T6) -- a bare string cuts a **callable** (every body node under + it is off-limits), a ``(name, within)`` pair cuts a **variable**, scoped to the callable + ``within`` names. They are different mechanisms for a reason: a validating guard + (``if not re.match(...): abort``) never sits on the data path, so only a variable cut severs + it; a transforming sanitizer (``html.escape(x)``) does sit on the path and is naturally named + as the function it is. + + **The shape decides which resolver runs, and neither is a fallback for the other.** A bare + name that :func:`resolve_callable` cannot resolve raises -- it is never retried as a variable + selector, because that would need a ``within=`` this shape does not carry, and inventing one + (or guessing the caller meant something else) is exactly the confident-wrong-answer failure + the addressing layer exists to prevent. Symmetrically, a pair whose name *does* resolve as a + callable is not "upgraded" into a bare-string cut; the caller wrote a pair, so it is resolved + as one, and if that fails it fails loudly. + + A variable selector is **not** validated with ``resolve_value``. Measured on a live graph: + ``resolve_value`` addresses only ``formal_in`` port vertices -- parameters -- while a cut + matches ``r.var`` on *edges*, most of which are locals (``cleaned``, ``result``, ``answer`` + and the like never appear as a ``formal_in``). Routing a variable selector through + ``resolve_value`` would raise :class:`~cldk.utils.exceptions.SelectorNotInGraph` for a + legitimate sanitizer that appears on real edges, telling the caller it does not exist when it + does. So the existence check is against ``edge_vars_in(prefix)`` -- the variable names actually + carried by SDG edges scoped to the named callable -- which is the same domain the amended + ``sdg_taint_query`` predicate matches against, and which both backends can answer cheaply (one + ``DISTINCT r.var`` query on Neo4j scoped by the callable's id prefix; the adjacency the local + backends already build, for the in-process side). + + The scoping is not cosmetic: the amended predicate matches a cut's ``var`` only on edges whose + start node falls under the resolved callable's ``prefix`` (its ``ref``), so ``cuts`` carries + ``{"var": ..., "prefix": ...}`` maps rather than a flat list of names -- a global cut on a + common name like ``result`` or ``token`` would sever flows the caller never named in every + *other* callable, over-cutting into a false refutation. + + Args: + sanitizers: Each entry is either a bare callable name (cuts the callable) or a + ``(name, within)`` pair (cuts the variable ``name``, scoped to callable ``within``). + resolve_callable: A bound ``resolve_callable(name)`` -- see + :meth:`~cldk.analysis.python.backend.PythonAnalysisBackend.resolve_callable`. Called + with the bare name directly for a callable cut, and with ``within`` (via + :func:`resolve_within`) for a variable cut's scope. + edge_vars_in: Given a resolved callable's ``prefix``, the variable names present on SDG + edges scoped to it -- the domain a variable selector is checked against (Ruling A; + never ``resolve_value``, which addresses parameters, not the locals a real edge var + usually is). + + Returns: + ``(cuts, cut_callable_ids)`` -- ``cuts`` is a list of ``{"var": str, "prefix": str}`` maps, + ready to bind as ``sdg_taint_query``'s ``$cuts``; ``cut_callable_ids`` is a list of + ``can://`` ids, ready to bind as its ``$cut_callables``. + + Raises: + ValueError: A pair's variable name is empty or whitespace-only. The amended predicate + matches a cut's ``var`` against ``coalesce(r.var, '')``, so an empty string would read + as "cut every hop with no var, in that callable" -- most control and summary edges, + and (below the analyzer floor) every param edge too. Refused here rather than passed + through silently. + SelectorNotInGraph: A bare name did not resolve as a callable, a pair's ``within`` did not + resolve as a callable, or a pair's variable does not appear on any SDG edge scoped to + ``within``. Each resolver's own failure is the error -- there is no catch-and-retry + under the other shape. + AmbiguousName: A bare name, or a pair's ``within``, matched more than one callable. + """ + cuts: List[Dict[str, str]] = [] + cut_callable_ids: List[str] = [] + for sanitizer in sanitizers: + if isinstance(sanitizer, str): + cut_callable_ids.append(resolve_callable(sanitizer).ref) + continue + name, within = sanitizer + if not name or not name.strip(): + raise ValueError( + f"a sanitizer variable must not be empty or whitespace-only (within={within!r}): " + "the taint predicate reads a blank var as \"cut every hop with no var\", which " + "would sever most control/summary edges in that callable rather than the one " + "variable intended" + ) + owner = resolve_within(resolve_callable, within) + if name not in edge_vars_in(owner.ref): + raise SelectorNotInGraph( + "variable", + [name], + 1, + detail=f"no SDG edge scoped to {within!r} carries this variable; resolve_value only addresses parameters, not locals", + ) + cuts.append({"var": name, "prefix": owner.ref}) + return cuts, cut_callable_ids diff --git a/tests/analysis/commons/test_taint_semantics.py b/tests/analysis/commons/test_taint_semantics.py index 159633bd..7cabd214 100644 --- a/tests/analysis/commons/test_taint_semantics.py +++ b/tests/analysis/commons/test_taint_semantics.py @@ -1,6 +1,10 @@ # tests/analysis/commons/test_taint_semantics.py +import pytest + from cldk.analysis.commons.graphs import shortest_walks, via_table +from cldk.analysis.commons.resolve import resolve_sanitizers from cldk.analysis.commons.results import Diagnostic, TaintResult +from cldk.utils.exceptions.exceptions import SelectorNotInGraph VIA = via_table("PY") @@ -95,3 +99,115 @@ def test_exhausted_survives_a_round_trip(): """A triage caller writes the result to JSON and another process reads the verdict back.""" r = TaintResult(paths=[], complete=True, exhausted=[_pair()], roots=[], resolved="", unresolved=[]) assert TaintResult.model_validate(r.model_dump()).exhausted == [_pair()] + + +# ---------------------------------------------------------------------------------------------- +# Leg 4b, Task 4: resolve_sanitizers() -- T6's shape/resolution agreement, and the four rulings +# that amend the brief (variable existence is checked against edge vars, never resolve_value; the +# variable cut is scoped to `within`; an empty/whitespace variable is refused; shape mismatches +# raise rather than falling back to the other resolver). +# ---------------------------------------------------------------------------------------------- + + +def _node(ref, name): + from cldk.analysis.commons.results import SliceNode + + return SliceNode(file="h.py", line=3, callable=name, kind="callable", name=name, ref=ref) + + +def _raises(*_a, **_k): + raise SelectorNotInGraph("callable", [_a[0] if _a else "?"], 1) + + +def _unused(*_a, **_k): + raise AssertionError("this resolver must not be called for this selector's shape") + + +#: `resolve_callable` resolves `Handler.handle` (the pair's `within`) and `html.escape` (the bare +#: cut); `edge_vars_in` stands in for the domain Ruling A checks against -- the vars an amended +#: `sdg_taint_query` predicate can actually match on edges scoped to a callable, not +#: `resolve_value`'s parameter-only domain. +_HANDLE_REF = "can://app/python/h.py/handle@3:4" +_ESCAPE_REF = "can://app/python/h.py/escape" + + +def _resolve_callable(name, **_kw): + if name == "Handler.handle": + return _node(ref=_HANDLE_REF, name=name) + if name == "html.escape": + return _node(ref=_ESCAPE_REF, name=name) + raise SelectorNotInGraph("callable", [name], 1) + + +def _edge_vars_in(prefix): + assert prefix == _HANDLE_REF + # 'cleaned' is a real PY_DDG edge var that is NOT a formal_in parameter, so it would be missed + # by resolve_value (measured on the live graph -- see Ruling A). 'token' is on the same graph. + return {"token", "cleaned", "handler::query"} + + +def test_a_pair_cuts_a_variable_and_a_bare_name_cuts_a_callable(): + cuts, callables = resolve_sanitizers( + [("token", "Handler.handle"), "html.escape"], + resolve_callable=_resolve_callable, + edge_vars_in=_edge_vars_in, + ) + assert cuts == [{"var": "token", "prefix": _HANDLE_REF}] + assert callables == [_ESCAPE_REF] + + +def test_ruling_a_a_variable_absent_from_resolve_value_but_present_on_an_edge_resolves(): + """'cleaned' is not a formal_in parameter (resolve_value would miss it), but it is a real edge + var. This is Ruling A's whole point: validating through resolve_value would raise here, and + that would be a legitimate sanitizer told it does not exist.""" + cuts, _callables = resolve_sanitizers( + [("cleaned", "Handler.handle")], + resolve_callable=_resolve_callable, + edge_vars_in=_edge_vars_in, + ) + assert cuts == [{"var": "cleaned", "prefix": _HANDLE_REF}] + + +def test_ruling_b_the_cut_carries_the_within_callables_prefix_for_scoping(): + """The cut is not a bare variable name -- it carries the resolved callable's ref as `prefix`, + which is what lets the amended predicate scope `startNode(r).id STARTS WITH c.prefix` instead + of cutting the name everywhere in the application.""" + cuts, _callables = resolve_sanitizers( + [("token", "Handler.handle")], + resolve_callable=_resolve_callable, + edge_vars_in=_edge_vars_in, + ) + assert cuts == [{"var": "token", "prefix": _HANDLE_REF}] + + +def test_a_bare_name_that_is_not_a_callable_raises_rather_than_cutting_a_variable(): + """T6. One signature carries two semantics, so the accident of omitting `within` must be loud + -- silently cutting the other thing is how a caller gets a confident wrong answer.""" + with pytest.raises(SelectorNotInGraph): + resolve_sanitizers(["token"], resolve_callable=_raises, edge_vars_in=_unused) + + +def test_a_pair_whose_name_is_a_callable_raises_too(): + """The mirror of the above: a pair is resolved as a pair, never silently treated as a bare + callable name just because its first element happens to also be one.""" + with pytest.raises(SelectorNotInGraph): + resolve_sanitizers([("html.escape", "Handler.handle")], resolve_callable=_resolve_callable, edge_vars_in=lambda _p: set()) + + +def test_ruling_c_an_empty_variable_selector_raises(): + """The predicate's `coalesce(r.var, '')` makes '' cut every hop with no var in that callable -- + most control/summary edges. Refused rather than passed through.""" + with pytest.raises(ValueError): + resolve_sanitizers([("", "Handler.handle")], resolve_callable=_resolve_callable, edge_vars_in=_unused) + + +def test_ruling_c_a_whitespace_only_variable_selector_raises(): + with pytest.raises(ValueError): + resolve_sanitizers([(" ", "Handler.handle")], resolve_callable=_resolve_callable, edge_vars_in=_unused) + + +def test_a_variable_absent_from_every_edge_in_scope_raises(): + """A variable that is not a parameter AND not on any edge scoped to `within` is genuinely + unresolvable -- not everything is Ruling A's exception.""" + with pytest.raises(SelectorNotInGraph): + resolve_sanitizers([("nonexistent", "Handler.handle")], resolve_callable=_resolve_callable, edge_vars_in=_edge_vars_in) From da0f1d9d39e2c55f59aef963b17c8ff39f991107 Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Thu, 10 Sep 2026 05:49:04 -0400 Subject: [PATCH 21/65] feat(python): taint() on the ABC, where the order of the checks is the contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The policy layer, concrete on the contract exactly as describe() already is: bounds before any name, names before any sanitizer, sanitizers before the walk — so a typo in depth costs no round trip and is not reported second. The traversal and the edge-var lookup are private hooks, so the ordered contract is written once per language instead of once per backend. Two divergences from paths_between, both deliberate. A same-position pair is skipped with a diagnostic rather than raised: raising would discard a forty-pair batch over one degenerate pair that a caller assembling sources programmatically produces by accident. And an explicit depth yields no exhausted pair at all — a pair with no path within five hops is unmeasured, not refuted, and exhausted is the field a caller closes an alert on. The hooks ship as stubs rather than abstract methods. ABCMeta refuses to instantiate a class with an unimplemented abstract method, so declaring them now would make every backend in every language un-instantiable until the last implementation lands; they are called, not constructed, so a missing one is refused at the call. The level gate is not in this body: _require_dataflow is a local backend's method, and asking the graph backends a question their attach probe never measures would buy one better message for two implementations that cannot honestly answer. It opens each local _taint_walk instead. --- cldk/analysis/python/backend.py | 191 ++++++++++++++++++++- tests/analysis/python/test_python_taint.py | 190 ++++++++++++++++++++ 2 files changed, 379 insertions(+), 2 deletions(-) create mode 100644 tests/analysis/python/test_python_taint.py diff --git a/cldk/analysis/python/backend.py b/cldk/analysis/python/backend.py index 1d253a29..61e805f5 100644 --- a/cldk/analysis/python/backend.py +++ b/cldk/analysis/python/backend.py @@ -30,7 +30,7 @@ from __future__ import annotations from abc import abstractmethod -from typing import Dict, List, Sequence, Tuple +from typing import Collection, Dict, List, Mapping, Sequence, Tuple import networkx as nx @@ -71,7 +71,8 @@ via_table, ) from cldk.analysis.commons.keys import body_key_column, call_graph_scope, resolve_module_key, scope_paths -from cldk.analysis.commons.results import EdgePage, EntrypointCoverage, FlowPaths, LocateResult, Slice, SliceNode +from cldk.analysis.commons.resolve import resolve_sanitizers +from cldk.analysis.commons.results import Diagnostic, EdgePage, EntrypointCoverage, FlowPath, FlowPaths, LocateResult, Slice, SliceNode, TaintResult from cldk.models.python import ( CdgEdge, CfgEdge, @@ -1039,6 +1040,192 @@ def flows_to_argument(self, src: str, callee: str, arg: str, *, within: str, dep ValueError: ``depth`` is not a positive ``int``. """ + # -----[ taint: many sources, many sinks, one traversal ]----- + def taint( + self, + sources: Sequence[Tuple[str, str]], + sinks: Sequence[Tuple[str, str]], + sanitizers: Sequence[Tuple[str, str] | str] = (), + *, + depth: int | None = None, + max_paths: int = DEFAULT_MAX_PATHS, + ) -> TaintResult: + """Which of these sources reach which of these sinks, and what to make of the ones that do not. + + The verb triage needs, and the one nothing else on this surface stands in for. + :meth:`paths_between` *proves* a flow; a caller who gets ``[]`` back from it cannot tell "no + flow exists" from "the flow left the resolved graph". ``taint()`` runs m sources against n + sinks in one traversal and reports that distinction **per pair**: the witnesses in + :attr:`~cldk.analysis.commons.results.FlowPaths.paths`, the pairs an absence claim can be + built on in :attr:`~cldk.analysis.commons.results.TaintResult.exhausted`, and everything + else explained in :attr:`~cldk.analysis.commons.results.TaintResult.unresolved`. + + **Sources, sinks and sanitizers are the caller's to supply.** This SDK ships no framework + catalogue and derives no default set: a per-language vocabulary of taint sources is policy + that rots, and this accessor is the mechanism. + + **A sanitizer is two mechanisms wearing one word**, told apart by shape. A bare ``str`` cuts + a *callable* on the path -- what a transforming sanitizer (``html.escape``, ``shlex.quote``) + is, since it sits on the data path and is naturally named as the function it is. A + ``(name, within)`` pair cuts a *variable* inside that callable, which is the only thing that + severs a *validating* guard, because a guard never appears on the data path at all. Both + cuts are applied **inside** the search rather than to the rows it returns, so what comes back + is the shortest *unsanitized* route: filtering afterwards would report nothing for a source + whose ten shortest paths are sanitized and whose eleventh is not -- a false refutation, the + one output this accessor must never produce, because in triage it closes a live alert. + + **The order of the checks is part of the contract**, cheapest-to-be-wrong-about first: the + bounds before any name, the names before any sanitizer, the sanitizers before the walk. A + caller with a typo in ``depth`` hears about ``depth`` rather than about a name, and pays for + no round trip to learn it. + + **Two deliberate divergences from :meth:`paths_between`.** A pair whose source and sink are + the *same position* is skipped with a diagnostic rather than raised -- ``paths_between`` + raises there (:func:`~cldk.analysis.commons.bounds.check_distinct_endpoints`), and raising + would discard a forty-pair batch over one degenerate pair that a caller assembling sources + programmatically produces by accident. And an explicit ``depth`` yields **no** ``exhausted`` + pair, ever: a pair with no path within five hops is not refuted, it is unmeasured, and + conflating the two is the bounded-boolean error. + + **The level gate belongs to the walk, not here.** ``_require_dataflow`` is a *local* + backend's method -- a graph backend has no shallow mode to guard against -- so each + :meth:`_taint_walk` opens with it rather than this body asking every backend a question two + of them cannot answer. + + Args: + sources: The values taint enters at, each ``(name, within)`` -- the addressing + :meth:`resolve_value` and :meth:`paths_between` already use. + sinks: The values it must not reach, addressed the same way. + sanitizers: Bare names cut callables; ``(name, within)`` pairs cut variables (above). + depth: Most hops a path may take; ``None`` (the default) for no bound, because a bound + turns a refutation into an artefact of the budget -- and ``exhausted`` is empty + whenever it is set. + max_paths: Most witnesses **per pair**, not per call: with one sink and forty sources a + flat cap lets one prolific pair starve the other thirty-nine, and in triage the + per-source witness is the answer. + + Returns: + A :class:`~cldk.analysis.commons.results.TaintResult`: ``paths`` are the witnesses, + ``exhausted`` the pairs searched to exhaustion with a clean ledger, ``roots`` and + ``resolved`` what every name matched, ``unresolved`` the ledger, and ``complete`` is + ``True`` only when nothing was truncated and no pair was skipped. A pair is named in + ``exhausted`` by the two strings the caller passed, so two sources sharing a name in + different callables read as one pair there -- ``roots`` is what tells them apart. + + Raises: + AmbiguousName: A name, or a sanitizer's ``within``, matched more than one thing. + SelectorNotInGraph: A name matched nothing, or a sanitizer's shape disagrees with what it + resolves to. + TypeError: ``sources`` or ``sinks`` is a bare string, which would unpack into a pair. + ValueError: ``depth`` is not a positive ``int``, ``max_paths`` is below 1, ``sources`` or + ``sinks`` is empty (refused, not answered ``[]``), or a sanitizer names a blank + variable. + """ + check_depth(depth) + check_max_paths(max_paths) + reject_bare_string("sources", sources) + reject_bare_string("sinks", sinks) + if not sources: + raise ValueError("sources= names nothing to taint from; pass at least one (name, within) pair") + if not sinks: + raise ValueError("sinks= names nothing to taint to; pass at least one (name, within) pair") + srcs = [self.resolve_value(name, within=within) for name, within in sources] + dsts = [self.resolve_value(name, within=within) for name, within in sinks] + cuts, cut_callables = resolve_sanitizers(sanitizers, resolve_callable=self.resolve_callable, edge_vars_in=self._edge_vars_in) + rows, blocked = self._taint_walk(srcs, dsts, cuts=cuts, cut_callables=cut_callables, depth=depth, max_paths=max_paths) + found: Dict[Tuple[str, str], List[FlowPath]] = {} + for src_ref, dst_ref, path in rows: + found.setdefault((src_ref, dst_ref), []).append(path) + # The verdict is assembled by looking each *requested* pair up, never by consuming the rows: + # the walk sees flat source and sink lists, so its m*n cross product can contain a + # combination this loop refuses to answer (a value that is both a source and a sink of two + # different pairs), and a row for one is simply never read. + paths: List[FlowPath] = [] + exhausted: List[Tuple[str, str]] = [] + ledger: List[Diagnostic] = [] + truncated = False + for (source, _), a in zip(sources, srcs): + for (sink, _), b in zip(sinks, dsts): + if a.ref == b.ref: + # ``Diagnostic.code`` is a closed vocabulary with no member for a degenerate + # pair. ``no_match`` is the nearest true thing it can say -- there is no answer + # for this pair -- where ``unresolved_dispatch`` would falsely implicate the call + # frontier, which is the one signal ``exhausted`` reduces to. + ledger.append( + Diagnostic( + code="no_match", + message=( + f"{source!r} and {sink!r} name the same position within {a.callable!r}, so that pair is skipped rather than " + f"searched; a value reaches itself only through recursion, which reaches({a.callable!r}, {a.callable!r}) answers" + ), + ) + ) + continue + stopped = list(blocked.get((a.ref, b.ref), [])) + witnesses = found.get((a.ref, b.ref), []) + ledger.extend(stopped) + paths.extend(witnesses[:max_paths]) + truncated = truncated or len(witnesses) > max_paths + # The three conditions, in one place: unbounded search, no witness, clean ledger for + # this pair. The pair association comes from ``blocked``'s key and never from + # reading a diagnostic's message back out. + if depth is None and not witnesses and not stopped: + exhausted.append((source, sink)) + roots = list({node.ref: node for node in [*srcs, *dsts]}.values()) + return TaintResult( + paths=paths, + complete=not truncated and not ledger, + exhausted=exhausted, + roots=roots, + resolved=slice_resolved(roots), + unresolved=ledger, + ) + + def _taint_walk( + self, + srcs: Sequence[SliceNode], + dsts: Sequence[SliceNode], + *, + cuts: List[Dict[str, str]], + cut_callables: List[str], + depth: int | None, + max_paths: int, + ) -> Tuple[List[Tuple[str, str, FlowPath]], Mapping[Tuple[str, str], List[Diagnostic]]]: + """The sanitized shortest walks between every source and every sink, and what stopped a pair. + + Rows are ``(source ref, sink ref, path)`` triples, shortest-first within a pair and in + :func:`~cldk.analysis.commons.graphs.hop_sort_key` order among equals, capped at + ``max_paths + 1`` **per pair** -- the extra row is what lets :meth:`taint` report truncation + without a second counting traversal, and the per-pair cap is why one prolific pair cannot + starve the rest. Grouping and trimming are :meth:`taint`'s, so a walk returns what it found. + + The second element is the frontier ledger, **keyed by the ``(source ref, sink ref)`` pair it + implicates**. The key is the association, not the message: ``exhausted`` is decided from + these keys, and recovering a pair by parsing prose back out of a ``Diagnostic`` would + resurrect exactly the derivation that field is stored to avoid. + + A local backend opens with ``self._require_dataflow()``: the graph backends do not measure + the analysis level (their attach probe never looks at the dependence relationships), so the + gate lives in the implementations that can answer rather than in :meth:`taint`. + + A stub rather than an ``@abstractmethod`` while the implementations land, so a backend + without one is refused when it is *called* rather than when it is constructed. + """ + raise NotImplementedError + + def _edge_vars_in(self, callable_id: str) -> Collection[str]: + """The variable names carried by SDG edges scoped to this callable -- the domain a variable + sanitizer is checked against. + + Not :meth:`resolve_value`, which addresses ``formal_in`` port vertices only: most real edge + variables are locals (``cleaned``, ``answer``, ``result``), so validating a sanitizer through + the resolver would refuse a legitimate one for not being a parameter. One ``DISTINCT r.var`` + query on the graph side, the adjacency already built on the local side. + + A stub rather than an ``@abstractmethod`` for :meth:`_taint_walk`'s reason. + """ + raise NotImplementedError + def describe(self, nodes: Sequence[object]) -> List[SliceNode]: """Fill in :attr:`~cldk.analysis.commons.results.SliceNode.source` for these positions. diff --git a/tests/analysis/python/test_python_taint.py b/tests/analysis/python/test_python_taint.py new file mode 100644 index 00000000..77e650ac --- /dev/null +++ b/tests/analysis/python/test_python_taint.py @@ -0,0 +1,190 @@ +################################################################################ +# Copyright IBM Corporation 2026 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +################################################################################ + +"""``taint()``'s ordered contract on the Python backend ABC -- offline, no analyzer and no graph. + +Everything ``taint()`` does before and after the walk is policy that lives on the ABC, so it is +testable against a backend whose walk only records what it was asked. What is pinned here is the +*order* of the checks (a malformed bound is judged before a name is looked up, a sanitizer before +the walk), the two deliberate divergences from ``paths_between`` (a same-position pair is skipped +rather than raised; a bounded ``depth`` yields no ``exhausted`` pair), and the three membership +conditions of ``exhausted``. +""" + +import pytest + +from cldk.analysis.commons.results import Diagnostic, FlowPath, PathHop, SliceNode +from cldk.analysis.python.backend import PythonAnalysisBackend +from cldk.utils.exceptions.exceptions import SelectorNotInGraph + + +def _value(name: str, within: str) -> SliceNode: + """A resolved value, addressed as this surface addresses one: a name plus the callable it enters.""" + return SliceNode(file="app.py", line=1, callable=within, kind="parameter", name=name, ref=f"can://app/python/app.py/{within}#{name}") + + +def _witness(frm: SliceNode, to: SliceNode) -> FlowPath: + return FlowPath(hops=[PathHop(frm=frm, to=to, via="data", var="answer", prov=["ssa"])]) + + +class _Recording(PythonAnalysisBackend): + """The contract with only the four methods ``taint()`` touches, and a walk that records its call. + + ``__abstractmethods__`` is cleared below rather than the other fifty-odd methods being stubbed: + what is under test is one concrete body, and anything else this backend could answer would only + be a way for these tests to fail for an unrelated reason. + """ + + def __init__(self, rows=(), blocked=None, edge_vars=("answer",)): + self._rows = list(rows) + self._blocked = dict(blocked or {}) + self._edge_vars = set(edge_vars) + self.resolved = [] + self.walks = [] + + def resolve_value(self, name, *, within): + self.resolved.append((name, within)) + return _value(name, within) + + def resolve_callable(self, name, *, in_class=None, in_module=None): + return SliceNode(file="app.py", line=1, callable=name, kind="callable", name=None, ref=f"can://app/python/app.py/{name}") + + def _edge_vars_in(self, callable_id): + return self._edge_vars + + def _taint_walk(self, srcs, dsts, *, cuts, cut_callables, depth, max_paths): + self.walks.append({"srcs": [n.ref for n in srcs], "dsts": [n.ref for n in dsts], "cuts": cuts, "cut_callables": cut_callables, "depth": depth, "max_paths": max_paths}) + return self._rows, self._blocked + + +_Recording.__abstractmethods__ = frozenset() + + +def test_the_arguments_are_judged_before_any_resolution(): + """A malformed bound is a ``ValueError`` before a name is looked up, as on every sibling + accessor: a typo in ``depth`` must not cost a round trip, and must not be reported second.""" + backend = _Recording() + with pytest.raises(ValueError, match="depth"): + backend.taint([("x", "f")], [("y", "g")], depth=0) + with pytest.raises(ValueError, match="max_paths"): + backend.taint([("x", "f")], [("y", "g")], max_paths=0) + assert backend.resolved == [] and backend.walks == [] + + +def test_empty_sources_or_sinks_is_refused_not_answered_empty(): + """``cone_sinks`` already refuses the two ways of naming nothing, and here the stakes are the + reason: an empty answer would be indistinguishable from a refutation.""" + backend = _Recording() + with pytest.raises(ValueError): + backend.taint([], [("y", "g")]) + with pytest.raises(ValueError): + backend.taint([("x", "f")], []) + assert backend.walks == [] + + +def test_a_bare_string_is_refused_rather_than_unpacked_into_a_pair(): + """``sources="xy"`` is a sequence -- of two characters -- so it would unpack to the pair + ``("x", "y")`` and resolve a value nobody named. ``reject_bare_string`` is why it is a + ``TypeError``.""" + backend = _Recording() + with pytest.raises(TypeError): + backend.taint("xy", [("y", "g")]) + with pytest.raises(TypeError): + backend.taint([("x", "f")], "xy") + + +def test_a_found_flow_carries_its_witnesses_the_roots_and_the_audit_line(): + a, b = _value("x", "f"), _value("y", "g") + backend = _Recording(rows=[(a.ref, b.ref, _witness(a, b))]) + result = backend.taint([("x", "f")], [("y", "g")]) + assert len(result.paths) == 1 and result.complete + assert result.exhausted == [] and result.unresolved == [] + assert [n.ref for n in result.roots] == [a.ref, b.ref] + assert result.resolved == "f parameter 'x', g parameter 'y'" + assert backend.walks == [{"srcs": [a.ref], "dsts": [b.ref], "cuts": [], "cut_callables": [], "depth": None, "max_paths": 10}] + + +def test_a_pair_with_no_route_is_exhausted_only_when_the_search_was_unbounded(): + """Condition 1 of the spec's section 5, a rule and not a tendency: under a bound "no path found" + is not evidence of absence, so the bounded call reports nothing rather than a refutation a + caller could close a live alert on.""" + backend = _Recording() + assert backend.taint([("x", "f")], [("y", "g")]).exhausted == [("x", "y")] + assert backend.taint([("x", "f")], [("y", "g")], depth=5).exhausted == [] + + +def test_the_same_position_pair_is_skipped_with_a_diagnostic_not_raised(): + """A deliberate divergence from ``paths_between``, which raises via ``check_distinct_endpoints``: + raising would discard a forty-pair batch for one degenerate pair, and a caller assembling + sources programmatically will hit that by accident.""" + backend = _Recording() + result = backend.taint([("x", "f"), ("y", "h")], [("x", "f")]) + assert result.paths == [] + assert any("same position" in d.message for d in result.unresolved) + assert result.exhausted == [("y", "x")], "the degenerate pair is skipped; the rest of the batch still answers" + assert not result.complete + + +def test_a_pair_a_diagnostic_implicates_is_neither_proved_nor_refuted(): + """Condition 3: a pair whose route crossed an unresolved dispatch appears in neither list, and + the association is the walk's to report -- ``exhausted`` is never computed by reading a message + back out.""" + a, b = _value("x", "f"), _value("y", "g") + blocked = Diagnostic(code="unresolved_dispatch", message="'x' in f to 'y' in g crosses an unresolved dispatch") + backend = _Recording(blocked={(a.ref, b.ref): [blocked]}) + result = backend.taint([("x", "f")], [("y", "g")]) + assert result.exhausted == [] and result.unresolved == [blocked] and not result.complete + + +def test_paths_are_trimmed_per_pair_and_completeness_says_the_cap_fired(): + """The walk caps each pair at ``max_paths + 1``, so the extra row reports truncation without a + second counting traversal -- and the trim is per pair, so a prolific pair cannot starve a + sparse one out of its witness.""" + a, b, c = _value("x", "f"), _value("y", "g"), _value("z", "h") + rows = [(a.ref, b.ref, _witness(a, b))] * 3 + [(a.ref, c.ref, _witness(a, c))] + result = _Recording(rows=rows).taint([("x", "f")], [("y", "g"), ("z", "h")], max_paths=2) + assert len(result.paths) == 3, "two of the prolific pair's three, and the sparse pair's one" + assert not result.complete and result.exhausted == [] + whole = _Recording(rows=rows).taint([("x", "f")], [("y", "g"), ("z", "h")], max_paths=3) + assert len(whole.paths) == 4 and whole.complete + + +def test_the_sanitizer_selectors_are_resolved_through_the_edge_vars_hook(): + """Step 4 of the order: the two shapes reach the walk as ``$cuts`` and ``$cut_callables``, and a + variable selector is checked against the vars on real SDG edges rather than ``resolve_value``, + which addresses only parameters.""" + backend = _Recording() + backend.taint([("x", "f")], [("y", "g")], sanitizers=[("answer", "f"), "scrub"]) + assert backend.walks[0]["cuts"] == [{"var": "answer", "prefix": "can://app/python/app.py/f"}] + assert backend.walks[0]["cut_callables"] == ["can://app/python/app.py/scrub"] + + +def test_a_sanitizer_that_does_not_resolve_raises_before_the_walk(): + backend = _Recording(edge_vars=()) + with pytest.raises(SelectorNotInGraph): + backend.taint([("x", "f")], [("y", "g")], sanitizers=[("answer", "f")]) + assert backend.walks == [], "sanitizers are resolved before the traversal, not applied after it" + + +def test_the_two_walk_hooks_are_stubs_rather_than_abstract_methods(): + """Ruling G: an abstract method here would make every concrete backend un-instantiable until the + last implementation lands, so they raise instead. Task 7 flips them, and this test is what says + the stub is still a stub.""" + assert not {"_taint_walk", "_edge_vars_in"} & PythonAnalysisBackend.__abstractmethods__ + with pytest.raises(NotImplementedError): + PythonAnalysisBackend._taint_walk(None, [], [], cuts=[], cut_callables=[], depth=None, max_paths=1) + with pytest.raises(NotImplementedError): + PythonAnalysisBackend._edge_vars_in(None, "can://app/python/app.py/f") From cad6b78153bef2ebfde92b2084c8d54368564b48 Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Thu, 10 Sep 2026 05:50:33 -0400 Subject: [PATCH 22/65] feat(typescript): taint() on the ABC, the same ordered contract in TypeScript's vocabulary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Python leg's body, unchanged where the policy is language-neutral — which is all of it except what the docstring must say here: a transforming sanitizer is encodeURIComponent or DOMPurify.sanitize, and every hop cants establishes carries reaching-defs, so weakest caps every TypeScript witness at one tier. That is a fact to state, not a gap to close, and a caller comparing two flows here compares hops rather than provenance. Same two hooks, same stubs, same reason: the level gate opens each local _taint_walk because the graph backend's attach probe never measures the dependence relationships, and an abstract method would make every backend un-instantiable until the last implementation lands. --- cldk/analysis/typescript/backend.py | 201 +++++++++++++++++- .../typescript/test_typescript_taint.py | 190 +++++++++++++++++ 2 files changed, 388 insertions(+), 3 deletions(-) create mode 100644 tests/analysis/typescript/test_typescript_taint.py diff --git a/cldk/analysis/typescript/backend.py b/cldk/analysis/typescript/backend.py index a69a58ed..fe5f85a2 100644 --- a/cldk/analysis/typescript/backend.py +++ b/cldk/analysis/typescript/backend.py @@ -43,7 +43,7 @@ from abc import abstractmethod from functools import partial -from typing import ClassVar, Dict, List, Sequence, Set, Tuple +from typing import ClassVar, Collection, Dict, List, Mapping, Sequence, Set, Tuple import networkx as nx @@ -54,10 +54,14 @@ DEFAULT_MAX_PATHS, DEFAULT_PAGE_SIZE, EdgeOrder, + check_depth, + check_max_paths, + reject_bare_string, ) -from cldk.analysis.commons.graphs import as_slice_node, edge_sort_key, sdg_rel_pattern, sdg_rels, via_table +from cldk.analysis.commons.graphs import as_slice_node, edge_sort_key, sdg_rel_pattern, sdg_rels, slice_resolved, via_table from cldk.analysis.commons.keys import module_dotted -from cldk.analysis.commons.results import EdgePage, EntrypointCoverage, FlowPaths, LocateResult, Slice, SliceNode +from cldk.analysis.commons.resolve import resolve_sanitizers +from cldk.analysis.commons.results import Diagnostic, EdgePage, EntrypointCoverage, FlowPath, FlowPaths, LocateResult, Slice, SliceNode, TaintResult from cldk.models.typescript import ( TSApplication, TSCallable, @@ -908,6 +912,197 @@ def flows_to_argument(self, src: str, callee: str, arg: str, *, within: str, dep ValueError: ``depth`` is not a positive ``int``. """ + # -----[ taint: many sources, many sinks, one traversal ]----- + def taint( + self, + sources: Sequence[Tuple[str, str]], + sinks: Sequence[Tuple[str, str]], + sanitizers: Sequence[Tuple[str, str] | str] = (), + *, + depth: int | None = None, + max_paths: int = DEFAULT_MAX_PATHS, + ) -> TaintResult: + """Which of these sources reach which of these sinks, and what to make of the ones that do not. + + The verb triage needs, and the one nothing else on this surface stands in for. + :meth:`paths_between` *proves* a flow; a caller who gets ``[]`` back from it cannot tell "no + flow exists" from "the flow left the resolved graph". ``taint()`` runs m sources against n + sinks in one traversal and reports that distinction **per pair**: the witnesses in + :attr:`~cldk.analysis.commons.results.FlowPaths.paths`, the pairs an absence claim can be + built on in :attr:`~cldk.analysis.commons.results.TaintResult.exhausted`, and everything + else explained in :attr:`~cldk.analysis.commons.results.TaintResult.unresolved`. + + **Sources, sinks and sanitizers are the caller's to supply.** This SDK ships no framework + catalogue and derives no default set: a per-language vocabulary of taint sources is policy + that rots, and this accessor is the mechanism. + + **A sanitizer is two mechanisms wearing one word**, told apart by shape. A bare ``str`` cuts + a *callable* on the path -- what a transforming sanitizer (``encodeURIComponent``, ``DOMPurify.sanitize``) + is, since it sits on the data path and is naturally named as the function it is. A + ``(name, within)`` pair cuts a *variable* inside that callable, which is the only thing that + severs a *validating* guard, because a guard never appears on the data path at all. Both + cuts are applied **inside** the search rather than to the rows it returns, so what comes back + is the shortest *unsanitized* route: filtering afterwards would report nothing for a source + whose ten shortest paths are sanitized and whose eleventh is not -- a false refutation, the + one output this accessor must never produce, because in triage it closes a live alert. + + **Every hop's provenance is ``reaching-defs``** (see :meth:`get_ddg`), so every witness's + ``weakest`` caps at the same tier here. That is a fact about what cants establishes, not a + gap in this accessor, and it is why a TypeScript flow is argued from its *hops* rather than + from a provenance comparison between two of them. + + **The order of the checks is part of the contract**, cheapest-to-be-wrong-about first: the + bounds before any name, the names before any sanitizer, the sanitizers before the walk. A + caller with a typo in ``depth`` hears about ``depth`` rather than about a name, and pays for + no round trip to learn it. + + **Two deliberate divergences from :meth:`paths_between`.** A pair whose source and sink are + the *same position* is skipped with a diagnostic rather than raised -- ``paths_between`` + raises there (:func:`~cldk.analysis.commons.bounds.check_distinct_endpoints`), and raising + would discard a forty-pair batch over one degenerate pair that a caller assembling sources + programmatically produces by accident. And an explicit ``depth`` yields **no** ``exhausted`` + pair, ever: a pair with no path within five hops is not refuted, it is unmeasured, and + conflating the two is the bounded-boolean error. + + **The level gate belongs to the walk, not here.** ``_require_dataflow`` is a *local* + backend's method -- a graph backend has no shallow mode to guard against -- so each + :meth:`_taint_walk` opens with it rather than this body asking every backend a question two + of them cannot answer. + + Args: + sources: The values taint enters at, each ``(name, within)`` -- the addressing + :meth:`resolve_value` and :meth:`paths_between` already use. + sinks: The values it must not reach, addressed the same way. + sanitizers: Bare names cut callables; ``(name, within)`` pairs cut variables (above). + depth: Most hops a path may take; ``None`` (the default) for no bound, because a bound + turns a refutation into an artefact of the budget -- and ``exhausted`` is empty + whenever it is set. + max_paths: Most witnesses **per pair**, not per call: with one sink and forty sources a + flat cap lets one prolific pair starve the other thirty-nine, and in triage the + per-source witness is the answer. + + Returns: + A :class:`~cldk.analysis.commons.results.TaintResult`: ``paths`` are the witnesses, + ``exhausted`` the pairs searched to exhaustion with a clean ledger, ``roots`` and + ``resolved`` what every name matched, ``unresolved`` the ledger, and ``complete`` is + ``True`` only when nothing was truncated and no pair was skipped. A pair is named in + ``exhausted`` by the two strings the caller passed, so two sources sharing a name in + different callables read as one pair there -- ``roots`` is what tells them apart. + + Raises: + AmbiguousName: A name, or a sanitizer's ``within``, matched more than one thing. + SelectorNotInGraph: A name matched nothing, or a sanitizer's shape disagrees with what it + resolves to. + TypeError: ``sources`` or ``sinks`` is a bare string, which would unpack into a pair. + ValueError: ``depth`` is not a positive ``int``, ``max_paths`` is below 1, ``sources`` or + ``sinks`` is empty (refused, not answered ``[]``), or a sanitizer names a blank + variable. + """ + check_depth(depth) + check_max_paths(max_paths) + reject_bare_string("sources", sources) + reject_bare_string("sinks", sinks) + if not sources: + raise ValueError("sources= names nothing to taint from; pass at least one (name, within) pair") + if not sinks: + raise ValueError("sinks= names nothing to taint to; pass at least one (name, within) pair") + srcs = [self.resolve_value(name, within=within) for name, within in sources] + dsts = [self.resolve_value(name, within=within) for name, within in sinks] + cuts, cut_callables = resolve_sanitizers(sanitizers, resolve_callable=self.resolve_callable, edge_vars_in=self._edge_vars_in) + rows, blocked = self._taint_walk(srcs, dsts, cuts=cuts, cut_callables=cut_callables, depth=depth, max_paths=max_paths) + found: Dict[Tuple[str, str], List[FlowPath]] = {} + for src_ref, dst_ref, path in rows: + found.setdefault((src_ref, dst_ref), []).append(path) + # The verdict is assembled by looking each *requested* pair up, never by consuming the rows: + # the walk sees flat source and sink lists, so its m*n cross product can contain a + # combination this loop refuses to answer (a value that is both a source and a sink of two + # different pairs), and a row for one is simply never read. + paths: List[FlowPath] = [] + exhausted: List[Tuple[str, str]] = [] + ledger: List[Diagnostic] = [] + truncated = False + for (source, _), a in zip(sources, srcs): + for (sink, _), b in zip(sinks, dsts): + if a.ref == b.ref: + # ``Diagnostic.code`` is a closed vocabulary with no member for a degenerate + # pair. ``no_match`` is the nearest true thing it can say -- there is no answer + # for this pair -- where ``unresolved_dispatch`` would falsely implicate the call + # frontier, which is the one signal ``exhausted`` reduces to. + ledger.append( + Diagnostic( + code="no_match", + message=( + f"{source!r} and {sink!r} name the same position within {a.callable!r}, so that pair is skipped rather than " + f"searched; a value reaches itself only through recursion, which reaches({a.callable!r}, {a.callable!r}) answers" + ), + ) + ) + continue + stopped = list(blocked.get((a.ref, b.ref), [])) + witnesses = found.get((a.ref, b.ref), []) + ledger.extend(stopped) + paths.extend(witnesses[:max_paths]) + truncated = truncated or len(witnesses) > max_paths + # The three conditions, in one place: unbounded search, no witness, clean ledger for + # this pair. The pair association comes from ``blocked``'s key and never from + # reading a diagnostic's message back out. + if depth is None and not witnesses and not stopped: + exhausted.append((source, sink)) + roots = list({node.ref: node for node in [*srcs, *dsts]}.values()) + return TaintResult( + paths=paths, + complete=not truncated and not ledger, + exhausted=exhausted, + roots=roots, + resolved=slice_resolved(roots), + unresolved=ledger, + ) + + def _taint_walk( + self, + srcs: Sequence[SliceNode], + dsts: Sequence[SliceNode], + *, + cuts: List[Dict[str, str]], + cut_callables: List[str], + depth: int | None, + max_paths: int, + ) -> Tuple[List[Tuple[str, str, FlowPath]], Mapping[Tuple[str, str], List[Diagnostic]]]: + """The sanitized shortest walks between every source and every sink, and what stopped a pair. + + Rows are ``(source ref, sink ref, path)`` triples, shortest-first within a pair and in + :func:`~cldk.analysis.commons.graphs.hop_sort_key` order among equals, capped at + ``max_paths + 1`` **per pair** -- the extra row is what lets :meth:`taint` report truncation + without a second counting traversal, and the per-pair cap is why one prolific pair cannot + starve the rest. Grouping and trimming are :meth:`taint`'s, so a walk returns what it found. + + The second element is the frontier ledger, **keyed by the ``(source ref, sink ref)`` pair it + implicates**. The key is the association, not the message: ``exhausted`` is decided from + these keys, and recovering a pair by parsing prose back out of a ``Diagnostic`` would + resurrect exactly the derivation that field is stored to avoid. + + A local backend opens with ``self._require_dataflow()``: the graph backends do not measure + the analysis level (their attach probe never looks at the dependence relationships), so the + gate lives in the implementations that can answer rather than in :meth:`taint`. + + A stub rather than an ``@abstractmethod`` while the implementations land, so a backend + without one is refused when it is *called* rather than when it is constructed. + """ + raise NotImplementedError + + def _edge_vars_in(self, callable_id: str) -> Collection[str]: + """The variable names carried by SDG edges scoped to this callable -- the domain a variable + sanitizer is checked against. + + Not :meth:`resolve_value`, which addresses ``formal_in`` port vertices only: most real edge + variables are locals (``cleaned``, ``answer``, ``result``), so validating a sanitizer through + the resolver would refuse a legitimate one for not being a parameter. One ``DISTINCT r.var`` + query on the graph side, the adjacency already built on the local side. + + A stub rather than an ``@abstractmethod`` for :meth:`_taint_walk`'s reason. + """ + raise NotImplementedError + def describe(self, nodes: Sequence[object]) -> List[SliceNode]: """Fill in :attr:`~cldk.analysis.commons.results.SliceNode.source` for these positions. diff --git a/tests/analysis/typescript/test_typescript_taint.py b/tests/analysis/typescript/test_typescript_taint.py new file mode 100644 index 00000000..3483edac --- /dev/null +++ b/tests/analysis/typescript/test_typescript_taint.py @@ -0,0 +1,190 @@ +################################################################################ +# Copyright IBM Corporation 2026 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +################################################################################ + +"""``taint()``'s ordered contract on the TypeScript backend ABC -- offline, no analyzer and no graph. + +Everything ``taint()`` does before and after the walk is policy that lives on the ABC, so it is +testable against a backend whose walk only records what it was asked. What is pinned here is the +*order* of the checks (a malformed bound is judged before a name is looked up, a sanitizer before +the walk), the two deliberate divergences from ``paths_between`` (a same-position pair is skipped +rather than raised; a bounded ``depth`` yields no ``exhausted`` pair), and the three membership +conditions of ``exhausted``. +""" + +import pytest + +from cldk.analysis.commons.results import Diagnostic, FlowPath, PathHop, SliceNode +from cldk.analysis.typescript.backend import TSAnalysisBackend +from cldk.utils.exceptions.exceptions import SelectorNotInGraph + + +def _value(name: str, within: str) -> SliceNode: + """A resolved value, addressed as this surface addresses one: a name plus the callable it enters.""" + return SliceNode(file="app.ts", line=1, callable=within, kind="parameter", name=name, ref=f"can://app/typescript/app.ts/{within}#{name}") + + +def _witness(frm: SliceNode, to: SliceNode) -> FlowPath: + return FlowPath(hops=[PathHop(frm=frm, to=to, via="data", var="answer", prov=["reaching-defs"])]) + + +class _Recording(TSAnalysisBackend): + """The contract with only the four methods ``taint()`` touches, and a walk that records its call. + + ``__abstractmethods__`` is cleared below rather than the other fifty-odd methods being stubbed: + what is under test is one concrete body, and anything else this backend could answer would only + be a way for these tests to fail for an unrelated reason. + """ + + def __init__(self, rows=(), blocked=None, edge_vars=("answer",)): + self._rows = list(rows) + self._blocked = dict(blocked or {}) + self._edge_vars = set(edge_vars) + self.resolved = [] + self.walks = [] + + def resolve_value(self, name, *, within): + self.resolved.append((name, within)) + return _value(name, within) + + def resolve_callable(self, name, *, in_class=None, in_module=None): + return SliceNode(file="app.ts", line=1, callable=name, kind="callable", name=None, ref=f"can://app/typescript/app.ts/{name}") + + def _edge_vars_in(self, callable_id): + return self._edge_vars + + def _taint_walk(self, srcs, dsts, *, cuts, cut_callables, depth, max_paths): + self.walks.append({"srcs": [n.ref for n in srcs], "dsts": [n.ref for n in dsts], "cuts": cuts, "cut_callables": cut_callables, "depth": depth, "max_paths": max_paths}) + return self._rows, self._blocked + + +_Recording.__abstractmethods__ = frozenset() + + +def test_the_arguments_are_judged_before_any_resolution(): + """A malformed bound is a ``ValueError`` before a name is looked up, as on every sibling + accessor: a typo in ``depth`` must not cost a round trip, and must not be reported second.""" + backend = _Recording() + with pytest.raises(ValueError, match="depth"): + backend.taint([("x", "f")], [("y", "g")], depth=0) + with pytest.raises(ValueError, match="max_paths"): + backend.taint([("x", "f")], [("y", "g")], max_paths=0) + assert backend.resolved == [] and backend.walks == [] + + +def test_empty_sources_or_sinks_is_refused_not_answered_empty(): + """``cone_sinks`` already refuses the two ways of naming nothing, and here the stakes are the + reason: an empty answer would be indistinguishable from a refutation.""" + backend = _Recording() + with pytest.raises(ValueError): + backend.taint([], [("y", "g")]) + with pytest.raises(ValueError): + backend.taint([("x", "f")], []) + assert backend.walks == [] + + +def test_a_bare_string_is_refused_rather_than_unpacked_into_a_pair(): + """``sources="xy"`` is a sequence -- of two characters -- so it would unpack to the pair + ``("x", "y")`` and resolve a value nobody named. ``reject_bare_string`` is why it is a + ``TypeError``.""" + backend = _Recording() + with pytest.raises(TypeError): + backend.taint("xy", [("y", "g")]) + with pytest.raises(TypeError): + backend.taint([("x", "f")], "xy") + + +def test_a_found_flow_carries_its_witnesses_the_roots_and_the_audit_line(): + a, b = _value("x", "f"), _value("y", "g") + backend = _Recording(rows=[(a.ref, b.ref, _witness(a, b))]) + result = backend.taint([("x", "f")], [("y", "g")]) + assert len(result.paths) == 1 and result.complete + assert result.exhausted == [] and result.unresolved == [] + assert [n.ref for n in result.roots] == [a.ref, b.ref] + assert result.resolved == "f parameter 'x', g parameter 'y'" + assert backend.walks == [{"srcs": [a.ref], "dsts": [b.ref], "cuts": [], "cut_callables": [], "depth": None, "max_paths": 10}] + + +def test_a_pair_with_no_route_is_exhausted_only_when_the_search_was_unbounded(): + """Condition 1 of the spec's section 5, a rule and not a tendency: under a bound "no path found" + is not evidence of absence, so the bounded call reports nothing rather than a refutation a + caller could close a live alert on.""" + backend = _Recording() + assert backend.taint([("x", "f")], [("y", "g")]).exhausted == [("x", "y")] + assert backend.taint([("x", "f")], [("y", "g")], depth=5).exhausted == [] + + +def test_the_same_position_pair_is_skipped_with_a_diagnostic_not_raised(): + """A deliberate divergence from ``paths_between``, which raises via ``check_distinct_endpoints``: + raising would discard a forty-pair batch for one degenerate pair, and a caller assembling + sources programmatically will hit that by accident.""" + backend = _Recording() + result = backend.taint([("x", "f"), ("y", "h")], [("x", "f")]) + assert result.paths == [] + assert any("same position" in d.message for d in result.unresolved) + assert result.exhausted == [("y", "x")], "the degenerate pair is skipped; the rest of the batch still answers" + assert not result.complete + + +def test_a_pair_a_diagnostic_implicates_is_neither_proved_nor_refuted(): + """Condition 3: a pair whose route crossed an unresolved dispatch appears in neither list, and + the association is the walk's to report -- ``exhausted`` is never computed by reading a message + back out.""" + a, b = _value("x", "f"), _value("y", "g") + blocked = Diagnostic(code="unresolved_dispatch", message="'x' in f to 'y' in g crosses an unresolved dispatch") + backend = _Recording(blocked={(a.ref, b.ref): [blocked]}) + result = backend.taint([("x", "f")], [("y", "g")]) + assert result.exhausted == [] and result.unresolved == [blocked] and not result.complete + + +def test_paths_are_trimmed_per_pair_and_completeness_says_the_cap_fired(): + """The walk caps each pair at ``max_paths + 1``, so the extra row reports truncation without a + second counting traversal -- and the trim is per pair, so a prolific pair cannot starve a + sparse one out of its witness.""" + a, b, c = _value("x", "f"), _value("y", "g"), _value("z", "h") + rows = [(a.ref, b.ref, _witness(a, b))] * 3 + [(a.ref, c.ref, _witness(a, c))] + result = _Recording(rows=rows).taint([("x", "f")], [("y", "g"), ("z", "h")], max_paths=2) + assert len(result.paths) == 3, "two of the prolific pair's three, and the sparse pair's one" + assert not result.complete and result.exhausted == [] + whole = _Recording(rows=rows).taint([("x", "f")], [("y", "g"), ("z", "h")], max_paths=3) + assert len(whole.paths) == 4 and whole.complete + + +def test_the_sanitizer_selectors_are_resolved_through_the_edge_vars_hook(): + """Step 4 of the order: the two shapes reach the walk as ``$cuts`` and ``$cut_callables``, and a + variable selector is checked against the vars on real SDG edges rather than ``resolve_value``, + which addresses only parameters.""" + backend = _Recording() + backend.taint([("x", "f")], [("y", "g")], sanitizers=[("answer", "f"), "scrub"]) + assert backend.walks[0]["cuts"] == [{"var": "answer", "prefix": "can://app/typescript/app.ts/f"}] + assert backend.walks[0]["cut_callables"] == ["can://app/typescript/app.ts/scrub"] + + +def test_a_sanitizer_that_does_not_resolve_raises_before_the_walk(): + backend = _Recording(edge_vars=()) + with pytest.raises(SelectorNotInGraph): + backend.taint([("x", "f")], [("y", "g")], sanitizers=[("answer", "f")]) + assert backend.walks == [], "sanitizers are resolved before the traversal, not applied after it" + + +def test_the_two_walk_hooks_are_stubs_rather_than_abstract_methods(): + """Ruling G: an abstract method here would make every concrete backend un-instantiable until the + last implementation lands, so they raise instead. Task 7 flips them, and this test is what says + the stub is still a stub.""" + assert not {"_taint_walk", "_edge_vars_in"} & TSAnalysisBackend.__abstractmethods__ + with pytest.raises(NotImplementedError): + TSAnalysisBackend._taint_walk(None, [], [], cuts=[], cut_callables=[], depth=None, max_paths=1) + with pytest.raises(NotImplementedError): + TSAnalysisBackend._edge_vars_in(None, "can://app/typescript/app.ts/f") From 6daead78370d8203f920d058fc7e06fa83e49973 Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Thu, 10 Sep 2026 05:57:14 -0400 Subject: [PATCH 23/65] feat(java): taint() on the ABC, with the port-lattice gate after the names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The same ordered body as the other two ABCs plus Java's one extra clause: _require_connected_ports("taint") sits after resolution, where the four existing forward value accessors put it. A caller with a typo in a parameter name hears about their typo; only a caller whose names all resolve hears that this analysis's port lattice carries no dependence edge — which is the order every sibling accessor already answers in, and the reason taint could not simply gate first and skip the resolution work. The gate is still asked of the data, never of an analyzer version, so output that joins the port layer to the statement dependence graph makes taint answer with no change here. Same two hooks, same stubs, same reason as the Python and TypeScript legs. The level gate stays out of this body: it opens each local _taint_walk, because the graph backend's attach probe never measures the dependence relationships. _require_connected_ports' own docstring drops the count — it now guards five accessors, and the number was only ever prose. --- cldk/analysis/java/backend.py | 207 ++++++++++++++++++++++- tests/analysis/java/test_java_taint.py | 222 +++++++++++++++++++++++++ 2 files changed, 426 insertions(+), 3 deletions(-) create mode 100644 tests/analysis/java/test_java_taint.py diff --git a/cldk/analysis/java/backend.py b/cldk/analysis/java/backend.py index 8b41975a..2ef99238 100644 --- a/cldk/analysis/java/backend.py +++ b/cldk/analysis/java/backend.py @@ -50,7 +50,7 @@ import re from abc import abstractmethod from functools import cached_property -from typing import ClassVar, Dict, Iterable, List, Mapping, NamedTuple, Optional, Sequence, Set, Tuple, Union +from typing import ClassVar, Collection, Dict, Iterable, List, Mapping, NamedTuple, Optional, Sequence, Set, Tuple, Union import networkx as nx @@ -65,22 +65,25 @@ check_distinct_endpoints, check_max_nodes, check_max_paths, + reject_bare_string, ) from cldk.analysis.commons.graphs import as_slice_node, call_reaches, cone_sinks, edge_sort_key, flow_path, sdg_rel_pattern, sdg_rels, shortest_walks, slice_resolved, via_table from cldk.analysis.commons.keys import body_key_column, resolve_module_key -from cldk.analysis.commons.resolve import CallableCandidate, resolve_callable_signature, resolve_value_name, resolve_within +from cldk.analysis.commons.resolve import CallableCandidate, resolve_callable_signature, resolve_sanitizers, resolve_value_name, resolve_within from cldk.analysis.commons.results import ( BodyRef, CallableRef, Diagnostic, EdgePage, EntrypointCoverage, + FlowPath, FlowPaths, LocateResult, ModuleRef, Slice, SliceNode, Span, + TaintResult, TypeRef, ) from cldk.analysis.commons.treesitter import TreesitterJava @@ -1991,6 +1994,204 @@ def _value_reaches(self, src: str, dsts: Sequence[str], depth: int | None) -> bo ``flows_to_call`` a fact about their *targets* rather than an agreement between two walks. """ + # -----[ taint: many sources, many sinks, one traversal ]----- + def taint( + self, + sources: Sequence[Tuple[str, str]], + sinks: Sequence[Tuple[str, str]], + sanitizers: Sequence[Tuple[str, str] | str] = (), + *, + depth: int | None = None, + max_paths: int = DEFAULT_MAX_PATHS, + ) -> TaintResult: + """Which of these sources reach which of these sinks, and what to make of the ones that do not. + + The verb triage needs, and the one nothing else on this surface stands in for. + :meth:`paths_between` *proves* a flow; a caller who gets ``[]`` back from it cannot tell "no + flow exists" from "the flow left the resolved graph". ``taint()`` runs m sources against n + sinks in one traversal and reports that distinction **per pair**: the witnesses in + :attr:`~cldk.analysis.commons.results.FlowPaths.paths`, the pairs an absence claim can be + built on in :attr:`~cldk.analysis.commons.results.TaintResult.exhausted`, and everything + else explained in :attr:`~cldk.analysis.commons.results.TaintResult.unresolved`. + + **Sources, sinks and sanitizers are the caller's to supply.** This SDK ships no framework + catalogue and derives no default set: a per-language vocabulary of taint sources is policy + that rots, and this accessor is the mechanism. + + **A sanitizer is two mechanisms wearing one word**, told apart by shape. A bare ``str`` cuts + a *callable* on the path -- what a transforming sanitizer (``StringEscapeUtils.escapeHtml4``, + a parameterised ``PreparedStatement`` bind) is, since it sits on the data path and is + naturally named as the function it is. A + ``(name, within)`` pair cuts a *variable* inside that callable, which is the only thing that + severs a *validating* guard, because a guard never appears on the data path at all. Both + cuts are applied **inside** the search rather than to the rows it returns, so what comes back + is the shortest *unsanitized* route: filtering afterwards would report nothing for a source + whose ten shortest paths are sanitized and whose eleventh is not -- a false refutation, the + one output this accessor must never produce, because in triage it closes a live alert. + + **The order of the checks is part of the contract**, cheapest-to-be-wrong-about first: the + bounds before any name, the names before any sanitizer, the sanitizers before the walk. A + caller with a typo in ``depth`` hears about ``depth`` rather than about a name, and pays for + no round trip to learn it. + + **Two deliberate divergences from :meth:`paths_between`.** A pair whose source and sink are + the *same position* is skipped with a diagnostic rather than raised -- ``paths_between`` + raises there (:func:`~cldk.analysis.commons.bounds.check_distinct_endpoints`), and raising + would discard a forty-pair batch over one degenerate pair that a caller assembling sources + programmatically produces by accident. And an explicit ``depth`` yields **no** ``exhausted`` + pair, ever: a pair with no path within five hops is not refuted, it is unmeasured, and + conflating the two is the bounded-boolean error. + + **Refused on a disconnected port lattice**, as the other four forward value accessors are + (:data:`PORTS_DISCONNECTED`), and *after* the arguments and the names are judged: a caller + learns about their own typo before they learn about a gap in the analysis. The gate is asked + of the data -- whether this application's ``formal_in`` vertices have any outgoing SDG edge + at all -- and never of the analyzer's version, so output that connects the two layers makes + this answer with no change here. + + **The level gate belongs to the walk, not here.** Each :meth:`_taint_walk` opens with + ``self._require_dataflow()``, which is a no-op on the graph backend and the real check on the + in-memory one, so the gate lives in the same place in all three languages rather than in a + body two of the five backends would have to be asked a question they do not measure. + + Args: + sources: The values taint enters at, each ``(name, within)`` -- the addressing + :meth:`resolve_value` and :meth:`paths_between` already use. + sinks: The values it must not reach, addressed the same way. + sanitizers: Bare names cut callables; ``(name, within)`` pairs cut variables (above). + depth: Most hops a path may take; ``None`` (the default) for no bound, because a bound + turns a refutation into an artefact of the budget -- and ``exhausted`` is empty + whenever it is set. + max_paths: Most witnesses **per pair**, not per call: with one sink and forty sources a + flat cap lets one prolific pair starve the other thirty-nine, and in triage the + per-source witness is the answer. + + Returns: + A :class:`~cldk.analysis.commons.results.TaintResult`: ``paths`` are the witnesses, + ``exhausted`` the pairs searched to exhaustion with a clean ledger, ``roots`` and + ``resolved`` what every name matched, ``unresolved`` the ledger, and ``complete`` is + ``True`` only when nothing was truncated and no pair was skipped. A pair is named in + ``exhausted`` by the two strings the caller passed, so two sources sharing a name in + different callables read as one pair there -- ``roots`` is what tells them apart. + + Raises: + AmbiguousName: A name, or a sanitizer's ``within``, matched more than one thing. + CodeanalyzerExecutionException: :data:`PORTS_DISCONNECTED` -- this analysis's port lattice + carries no dependence edge, so every pair would come back refuted for a reason that + has nothing to do with the program. + SelectorNotInGraph: A name matched nothing, or a sanitizer's shape disagrees with what it + resolves to. + TypeError: ``sources`` or ``sinks`` is a bare string, which would unpack into a pair. + ValueError: ``depth`` is not a positive ``int``, ``max_paths`` is below 1, ``sources`` or + ``sinks`` is empty (refused, not answered ``[]``), or a sanitizer names a blank + variable. + """ + check_depth(depth) + check_max_paths(max_paths) + reject_bare_string("sources", sources) + reject_bare_string("sinks", sinks) + if not sources: + raise ValueError("sources= names nothing to taint from; pass at least one (name, within) pair") + if not sinks: + raise ValueError("sinks= names nothing to taint to; pass at least one (name, within) pair") + srcs = [self.resolve_value(name, within=within) for name, within in sources] + dsts = [self.resolve_value(name, within=within) for name, within in sinks] + cuts, cut_callables = resolve_sanitizers(sanitizers, resolve_callable=self.resolve_callable, edge_vars_in=self._edge_vars_in) + self._require_connected_ports("taint") + rows, blocked = self._taint_walk(srcs, dsts, cuts=cuts, cut_callables=cut_callables, depth=depth, max_paths=max_paths) + found: Dict[Tuple[str, str], List[FlowPath]] = {} + for src_ref, dst_ref, path in rows: + found.setdefault((src_ref, dst_ref), []).append(path) + # The verdict is assembled by looking each *requested* pair up, never by consuming the rows: + # the walk sees flat source and sink lists, so its m*n cross product can contain a + # combination this loop refuses to answer (a value that is both a source and a sink of two + # different pairs), and a row for one is simply never read. + paths: List[FlowPath] = [] + exhausted: List[Tuple[str, str]] = [] + ledger: List[Diagnostic] = [] + truncated = False + for (source, _), a in zip(sources, srcs): + for (sink, _), b in zip(sinks, dsts): + if a.ref == b.ref: + # ``Diagnostic.code`` is a closed vocabulary with no member for a degenerate + # pair. ``no_match`` is the nearest true thing it can say -- there is no answer + # for this pair -- where ``unresolved_dispatch`` would falsely implicate the call + # frontier, which is the one signal ``exhausted`` reduces to. + ledger.append( + Diagnostic( + code="no_match", + message=( + f"{source!r} and {sink!r} name the same position within {a.callable!r}, so that pair is skipped rather than " + f"searched; a value reaches itself only through recursion, which reaches({a.callable!r}, {a.callable!r}) answers" + ), + ) + ) + continue + stopped = list(blocked.get((a.ref, b.ref), [])) + witnesses = found.get((a.ref, b.ref), []) + ledger.extend(stopped) + paths.extend(witnesses[:max_paths]) + truncated = truncated or len(witnesses) > max_paths + # The three conditions, in one place: unbounded search, no witness, clean ledger for + # this pair. The pair association comes from ``blocked``'s key and never from + # reading a diagnostic's message back out. + if depth is None and not witnesses and not stopped: + exhausted.append((source, sink)) + roots = list({node.ref: node for node in [*srcs, *dsts]}.values()) + return TaintResult( + paths=paths, + complete=not truncated and not ledger, + exhausted=exhausted, + roots=roots, + resolved=slice_resolved(roots), + unresolved=ledger, + ) + + def _taint_walk( + self, + srcs: Sequence[SliceNode], + dsts: Sequence[SliceNode], + *, + cuts: List[Dict[str, str]], + cut_callables: List[str], + depth: int | None, + max_paths: int, + ) -> Tuple[List[Tuple[str, str, FlowPath]], Mapping[Tuple[str, str], List[Diagnostic]]]: + """The sanitized shortest walks between every source and every sink, and what stopped a pair. + + Rows are ``(source ref, sink ref, path)`` triples, shortest-first within a pair and in + :func:`~cldk.analysis.commons.graphs.hop_sort_key` order among equals, capped at + ``max_paths + 1`` **per pair** -- the extra row is what lets :meth:`taint` report truncation + without a second counting traversal, and the per-pair cap is why one prolific pair cannot + starve the rest. Grouping and trimming are :meth:`taint`'s, so a walk returns what it found. + + The second element is the frontier ledger, **keyed by the ``(source ref, sink ref)`` pair it + implicates**. The key is the association, not the message: ``exhausted`` is decided from + these keys, and recovering a pair by parsing prose back out of a ``Diagnostic`` would + resurrect exactly the derivation that field is stored to avoid. + + A local backend opens with ``self._require_dataflow()``: the graph backends do not measure + the analysis level (their attach probe never looks at the dependence relationships), so the + gate lives in the implementations that can answer rather than in :meth:`taint`. + + A stub rather than an ``@abstractmethod`` while the implementations land, so a backend + without one is refused when it is *called* rather than when it is constructed. + """ + raise NotImplementedError + + def _edge_vars_in(self, callable_id: str) -> Collection[str]: + """The variable names carried by SDG edges scoped to this callable -- the domain a variable + sanitizer is checked against. + + Not :meth:`resolve_value`, which addresses ``formal_in`` port vertices only: most real edge + variables are locals (``cleaned``, ``answer``, ``result``), so validating a sanitizer through + the resolver would refuse a legitimate one for not being a parameter. One ``DISTINCT r.var`` + query on the graph side, the adjacency already built on the local side. + + A stub rather than an ``@abstractmethod`` for :meth:`_taint_walk`'s reason. + """ + raise NotImplementedError + # -----[ the two facts a backend supplies about its own analysis ]----- def _require_dataflow(self) -> None: """Refuse when this analysis was built below the pass that computes cfg/cdg/ddg. @@ -2014,7 +2215,7 @@ def _require_explicit(self, key: str, tail: str) -> None: raise CodeanalyzerUsageException(IMPLICIT_CALLABLE.format(key=key, tail=tail)) def _require_connected_ports(self, accessor: str) -> None: - """Refuse the four forward value accessors while the port lattice carries no dependence + """Refuse the forward value accessors while the port lattice carries no dependence edge (:data:`PORTS_DISCONNECTED`). Both backends raise the same type with the same message, which names the accessor and the application and no ``can://`` id (E6).""" if not self._ports_carry_dependence: diff --git a/tests/analysis/java/test_java_taint.py b/tests/analysis/java/test_java_taint.py new file mode 100644 index 00000000..512220ec --- /dev/null +++ b/tests/analysis/java/test_java_taint.py @@ -0,0 +1,222 @@ +################################################################################ +# Copyright IBM Corporation 2026 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +################################################################################ + +"""``taint()``'s ordered contract on the Java backend ABC -- offline, no analyzer and no graph. + +Everything ``taint()`` does before and after the walk is policy that lives on the ABC, so it is +testable against a backend whose walk only records what it was asked. What is pinned here is the +*order* of the checks (a malformed bound is judged before a name is looked up, a name before the +port-lattice gate, a sanitizer before the walk), the two deliberate divergences from +``paths_between`` (a same-position pair is skipped rather than raised; a bounded ``depth`` yields no +``exhausted`` pair), and the three membership conditions of ``exhausted``. + +Java's extra clause is the gate: ``_require_connected_ports`` sits *after* resolution, so a caller +with a typo hears about their typo and not about a gap in the analysis. +""" + +import pytest + +from cldk.analysis.commons.results import Diagnostic, FlowPath, PathHop, SliceNode +from cldk.analysis.java.backend import JavaAnalysisBackend +from cldk.utils.exceptions.exceptions import CodeanalyzerExecutionException, SelectorNotInGraph + +HANDLE = "com.acme.Svc.handle(java.lang.String)" +STORE = "com.acme.Dao.store(java.lang.String)" +LOG = "com.acme.Log.write(java.lang.String)" + + +def _value(name: str, within: str) -> SliceNode: + """A resolved value, addressed as this surface addresses one: a parameter plus the callable it + enters, whose ``ref`` is the ``formal_in`` vertex's own id.""" + return SliceNode(file="Svc.java", line=1, callable=within, kind="parameter", name=name, ref=f"can://java/acme/{within}@formal_in:0#{name}") + + +def _witness(frm: SliceNode, to: SliceNode) -> FlowPath: + return FlowPath(hops=[PathHop(frm=frm, to=to, via="data", var="answer", prov=["ssa"])]) + + +class _Recording(JavaAnalysisBackend): + """The contract with only the methods ``taint()`` touches, and a walk that records its call. + + ``__abstractmethods__`` is cleared below rather than the other fifty-odd methods being stubbed: + what is under test is one concrete body, and anything else this backend could answer would only + be a way for these tests to fail for an unrelated reason. ``_ports_carry_dependence`` and + ``_ports_carry_dependence`` stays a property because the real one is (a plain attribute would + not shadow a data descriptor at all), and ``_application_name`` is a bare string because the real + property reads an application view this fake does not have. + """ + + _application_name = "acme" + + def __init__(self, rows=(), blocked=None, edge_vars=("answer",), ports=True): + self._rows = list(rows) + self._blocked = dict(blocked or {}) + self._edge_vars = set(edge_vars) + self._ports = ports + self.resolved = [] + self.walks = [] + + @property + def _ports_carry_dependence(self): + return self._ports + + def resolve_value(self, name, *, within): + self.resolved.append((name, within)) + return _value(name, within) + + def resolve_callable(self, name, *, in_class=None, in_module=None): + return SliceNode(file="Svc.java", line=1, callable=name, kind="callable", name=name.rpartition(".")[2].partition("(")[0], ref=f"can://java/acme/{name}") + + def _edge_vars_in(self, callable_id): + return self._edge_vars + + def _taint_walk(self, srcs, dsts, *, cuts, cut_callables, depth, max_paths): + self.walks.append({"srcs": [n.ref for n in srcs], "dsts": [n.ref for n in dsts], "cuts": cuts, "cut_callables": cut_callables, "depth": depth, "max_paths": max_paths}) + return self._rows, self._blocked + + +_Recording.__abstractmethods__ = frozenset() + + +def test_the_arguments_are_judged_before_any_resolution(): + """A malformed bound is a ``ValueError`` before a name is looked up, as on every sibling + accessor: a typo in ``depth`` must not cost a round trip, and must not be reported second.""" + backend = _Recording() + with pytest.raises(ValueError, match="depth"): + backend.taint([("in", HANDLE)], [("sql", STORE)], depth=0) + with pytest.raises(ValueError, match="max_paths"): + backend.taint([("in", HANDLE)], [("sql", STORE)], max_paths=0) + assert backend.resolved == [] and backend.walks == [] + + +def test_empty_sources_or_sinks_is_refused_not_answered_empty(): + """``cone_sinks`` already refuses the two ways of naming nothing, and here the stakes are the + reason: an empty answer would be indistinguishable from a refutation.""" + backend = _Recording() + with pytest.raises(ValueError): + backend.taint([], [("sql", STORE)]) + with pytest.raises(ValueError): + backend.taint([("in", HANDLE)], []) + assert backend.walks == [] + + +def test_a_bare_string_is_refused_rather_than_unpacked_into_a_pair(): + """``sources="xy"`` is a sequence -- of two characters -- so it would unpack to the pair + ``("x", "y")`` and resolve a value nobody named. ``reject_bare_string`` is why it is a + ``TypeError``.""" + backend = _Recording() + with pytest.raises(TypeError): + backend.taint("xy", [("sql", STORE)]) + with pytest.raises(TypeError): + backend.taint([("in", HANDLE)], "xy") + + +def test_a_disconnected_port_lattice_refuses_after_resolution_and_before_the_walk(): + """The Java clause. ``taint`` joins the forward value accessors in refusing on a port lattice + with no dependence edge (every pair would come back refuted for a reason that has nothing to do + with the program), and it refuses in the same place they do: after the arguments and the names, + before the traversal.""" + backend = _Recording(ports=False) + with pytest.raises(CodeanalyzerExecutionException, match="taint"): + backend.taint([("in", HANDLE)], [("sql", STORE)]) + assert backend.resolved == [("in", HANDLE), ("sql", STORE)], "the names are judged first, so a typo is reported as a typo" + assert backend.walks == [] + with pytest.raises(ValueError): + backend.taint([("in", HANDLE)], [("sql", STORE)], depth=0) + + +def test_a_found_flow_carries_its_witnesses_the_roots_and_the_audit_line(): + a, b = _value("in", HANDLE), _value("sql", STORE) + backend = _Recording(rows=[(a.ref, b.ref, _witness(a, b))]) + result = backend.taint([("in", HANDLE)], [("sql", STORE)]) + assert len(result.paths) == 1 and result.complete + assert result.exhausted == [] and result.unresolved == [] + assert [n.ref for n in result.roots] == [a.ref, b.ref] + assert result.resolved == f"{HANDLE} parameter 'in', {STORE} parameter 'sql'" + assert backend.walks == [{"srcs": [a.ref], "dsts": [b.ref], "cuts": [], "cut_callables": [], "depth": None, "max_paths": 10}] + + +def test_a_pair_with_no_route_is_exhausted_only_when_the_search_was_unbounded(): + """Condition 1 of the spec's section 5, a rule and not a tendency: under a bound "no path found" + is not evidence of absence, so the bounded call reports nothing rather than a refutation a + caller could close a live alert on.""" + backend = _Recording() + assert backend.taint([("in", HANDLE)], [("sql", STORE)]).exhausted == [("in", "sql")] + assert backend.taint([("in", HANDLE)], [("sql", STORE)], depth=5).exhausted == [] + + +def test_the_same_position_pair_is_skipped_with_a_diagnostic_not_raised(): + """A deliberate divergence from ``paths_between``, which raises via ``check_distinct_endpoints``: + raising would discard a forty-pair batch for one degenerate pair, and a caller assembling + sources programmatically will hit that by accident.""" + backend = _Recording() + result = backend.taint([("in", HANDLE), ("msg", LOG)], [("in", HANDLE)]) + assert result.paths == [] + assert any("same position" in d.message for d in result.unresolved) + assert result.exhausted == [("msg", "in")], "the degenerate pair is skipped; the rest of the batch still answers" + assert not result.complete + + +def test_a_pair_a_diagnostic_implicates_is_neither_proved_nor_refuted(): + """Condition 3: a pair whose route crossed an unresolved dispatch appears in neither list, and + the association is the walk's to report -- ``exhausted`` is never computed by reading a message + back out.""" + a, b = _value("in", HANDLE), _value("sql", STORE) + blocked = Diagnostic(code="unresolved_dispatch", message=f"'in' in {HANDLE} to 'sql' in {STORE} crosses an unresolved dispatch") + backend = _Recording(blocked={(a.ref, b.ref): [blocked]}) + result = backend.taint([("in", HANDLE)], [("sql", STORE)]) + assert result.exhausted == [] and result.unresolved == [blocked] and not result.complete + + +def test_paths_are_trimmed_per_pair_and_completeness_says_the_cap_fired(): + """The walk caps each pair at ``max_paths + 1``, so the extra row reports truncation without a + second counting traversal -- and the trim is per pair, so a prolific pair cannot starve a + sparse one out of its witness.""" + a, b, c = _value("in", HANDLE), _value("sql", STORE), _value("msg", LOG) + rows = [(a.ref, b.ref, _witness(a, b))] * 3 + [(a.ref, c.ref, _witness(a, c))] + result = _Recording(rows=rows).taint([("in", HANDLE)], [("sql", STORE), ("msg", LOG)], max_paths=2) + assert len(result.paths) == 3, "two of the prolific pair's three, and the sparse pair's one" + assert not result.complete and result.exhausted == [] + whole = _Recording(rows=rows).taint([("in", HANDLE)], [("sql", STORE), ("msg", LOG)], max_paths=3) + assert len(whole.paths) == 4 and whole.complete + + +def test_the_sanitizer_selectors_are_resolved_through_the_edge_vars_hook(): + """Step 4 of the order: the two shapes reach the walk as ``$cuts`` and ``$cut_callables``, and a + variable selector is checked against the vars on real SDG edges rather than ``resolve_value``, + which addresses only parameters.""" + backend = _Recording() + backend.taint([("in", HANDLE)], [("sql", STORE)], sanitizers=[("answer", HANDLE), "escapeHtml4"]) + assert backend.walks[0]["cuts"] == [{"var": "answer", "prefix": f"can://java/acme/{HANDLE}"}] + assert backend.walks[0]["cut_callables"] == ["can://java/acme/escapeHtml4"] + + +def test_a_sanitizer_that_does_not_resolve_raises_before_the_walk(): + backend = _Recording(edge_vars=()) + with pytest.raises(SelectorNotInGraph): + backend.taint([("in", HANDLE)], [("sql", STORE)], sanitizers=[("answer", HANDLE)]) + assert backend.walks == [], "sanitizers are resolved before the traversal, not applied after it" + + +def test_the_two_walk_hooks_are_stubs_rather_than_abstract_methods(): + """Ruling G: an abstract method here would make every concrete backend un-instantiable until the + last implementation lands, so they raise instead. Task 7 flips them, and this test is what says + the stub is still a stub.""" + assert not {"_taint_walk", "_edge_vars_in"} & JavaAnalysisBackend.__abstractmethods__ + with pytest.raises(NotImplementedError): + JavaAnalysisBackend._taint_walk(None, [], [], cuts=[], cut_callables=[], depth=None, max_paths=1) + with pytest.raises(NotImplementedError): + JavaAnalysisBackend._edge_vars_in(None, f"can://java/acme/{HANDLE}") From 6fa92a0b8c5f61fe1b921f1e1df1be782cf157fa Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Thu, 10 Sep 2026 06:19:26 -0400 Subject: [PATCH 24/65] fix(taint): every ledger key is read, so a blocked pair cannot be certified exhausted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pair loop read blocked.get((a.ref, b.ref)) and that was the only read, so a diagnostic filed under any other key — a reversed pair, one arm of a callable frontier, a combination the caller did not request — was discarded, and the pair it named came back in exhausted with a clean ledger. Measured before the fix: a walk keying its entry (dst, src) yielded unresolved=[], exhausted=[('x', 'y')], complete=True. That is a certified refutation of a flow that was in fact blocked, which is the one output this accessor exists to refuse, and a bound reported as nothing. Fixed in taint() rather than in five walks, because the walks are where the same mistake gets made five times: the loop now claims each key as it reads it, and every unclaimed key is swept into the ledger afterwards. Nothing there can attribute a stray key to a requested pair, so exhausted is emptied rather than trusted — refusing to certify is the direction that cannot close a live alert, and unresolved says why. The fan-out rule the signature cannot express is stated in _taint_walk's docstring instead: an unresolved dispatch is a property of a callable frontier, the mapping has no key meaning "every pair", so a walk files the same diagnostic under each key it affects. Under-filing no longer loses the entry, but it does cost every pair its certification, which is the cost worth naming. --- cldk/analysis/java/backend.py | 24 +++++++++++++++++++ cldk/analysis/python/backend.py | 24 +++++++++++++++++++ cldk/analysis/typescript/backend.py | 24 +++++++++++++++++++ tests/analysis/java/test_java_taint.py | 13 ++++++++++ tests/analysis/python/test_python_taint.py | 13 ++++++++++ .../typescript/test_typescript_taint.py | 13 ++++++++++ 6 files changed, 111 insertions(+) diff --git a/cldk/analysis/java/backend.py b/cldk/analysis/java/backend.py index 2ef99238..8e141388 100644 --- a/cldk/analysis/java/backend.py +++ b/cldk/analysis/java/backend.py @@ -2110,6 +2110,7 @@ def taint( exhausted: List[Tuple[str, str]] = [] ledger: List[Diagnostic] = [] truncated = False + claimed: set[Tuple[str, str]] = set() for (source, _), a in zip(sources, srcs): for (sink, _), b in zip(sinks, dsts): if a.ref == b.ref: @@ -2127,6 +2128,7 @@ def taint( ) ) continue + claimed.add((a.ref, b.ref)) stopped = list(blocked.get((a.ref, b.ref), [])) witnesses = found.get((a.ref, b.ref), []) ledger.extend(stopped) @@ -2137,6 +2139,18 @@ def taint( # reading a diagnostic's message back out. if depth is None and not witnesses and not stopped: exhausted.append((source, sink)) + # Every key the walk filed under is read, whether or not a requested pair claimed it. The + # loop above reads one key per pair, so a diagnostic keyed any other way -- a reversed pair, + # one arm of a callable frontier, a combination this caller did not request -- would be read + # by nobody, and the pair it named would come back in ``exhausted`` with a clean ledger: + # a certified refutation of a flow that was in fact blocked, which is the one output this + # accessor exists to refuse (E5, "a bound is never silent"). Nothing here can attribute a + # stray key to a requested pair, so no pair keeps its certification -- refusing to certify is + # the safe direction, and the ledger says why. + unclaimed = [d for key, stopped in blocked.items() if key not in claimed for d in stopped] + if unclaimed: + ledger.extend(unclaimed) + exhausted = [] roots = list({node.ref: node for node in [*srcs, *dsts]}.values()) return TaintResult( paths=paths, @@ -2170,6 +2184,16 @@ def _taint_walk( these keys, and recovering a pair by parsing prose back out of a ``Diagnostic`` would resurrect exactly the derivation that field is stored to avoid. + **An unresolved dispatch is a property of a callable frontier, not of a pair**, and this + mapping has no key meaning "every pair" -- so a walk that meets one files the same diagnostic + under *each* ``(source ref, sink ref)`` key it affects, not under one of them and not under a + key of its own devising. Filing under one arm is not equivalent and the difference is not + laxity: :meth:`taint` reads every key it is handed, so nothing is dropped either way, but a + key no requested pair claims costs **every** pair its ``exhausted`` certification, because + nothing on the receiving side can attribute a stray key to a pair. Filing per affected pair + is what keeps the verdict as precise as the walk's own knowledge; the signature cannot say + so, which is why it is said here. + A local backend opens with ``self._require_dataflow()``: the graph backends do not measure the analysis level (their attach probe never looks at the dependence relationships), so the gate lives in the implementations that can answer rather than in :meth:`taint`. diff --git a/cldk/analysis/python/backend.py b/cldk/analysis/python/backend.py index 61e805f5..3f92148d 100644 --- a/cldk/analysis/python/backend.py +++ b/cldk/analysis/python/backend.py @@ -1144,6 +1144,7 @@ def taint( exhausted: List[Tuple[str, str]] = [] ledger: List[Diagnostic] = [] truncated = False + claimed: set[Tuple[str, str]] = set() for (source, _), a in zip(sources, srcs): for (sink, _), b in zip(sinks, dsts): if a.ref == b.ref: @@ -1161,6 +1162,7 @@ def taint( ) ) continue + claimed.add((a.ref, b.ref)) stopped = list(blocked.get((a.ref, b.ref), [])) witnesses = found.get((a.ref, b.ref), []) ledger.extend(stopped) @@ -1171,6 +1173,18 @@ def taint( # reading a diagnostic's message back out. if depth is None and not witnesses and not stopped: exhausted.append((source, sink)) + # Every key the walk filed under is read, whether or not a requested pair claimed it. The + # loop above reads one key per pair, so a diagnostic keyed any other way -- a reversed pair, + # one arm of a callable frontier, a combination this caller did not request -- would be read + # by nobody, and the pair it named would come back in ``exhausted`` with a clean ledger: + # a certified refutation of a flow that was in fact blocked, which is the one output this + # accessor exists to refuse (E5, "a bound is never silent"). Nothing here can attribute a + # stray key to a requested pair, so no pair keeps its certification -- refusing to certify is + # the safe direction, and the ledger says why. + unclaimed = [d for key, stopped in blocked.items() if key not in claimed for d in stopped] + if unclaimed: + ledger.extend(unclaimed) + exhausted = [] roots = list({node.ref: node for node in [*srcs, *dsts]}.values()) return TaintResult( paths=paths, @@ -1204,6 +1218,16 @@ def _taint_walk( these keys, and recovering a pair by parsing prose back out of a ``Diagnostic`` would resurrect exactly the derivation that field is stored to avoid. + **An unresolved dispatch is a property of a callable frontier, not of a pair**, and this + mapping has no key meaning "every pair" -- so a walk that meets one files the same diagnostic + under *each* ``(source ref, sink ref)`` key it affects, not under one of them and not under a + key of its own devising. Filing under one arm is not equivalent and the difference is not + laxity: :meth:`taint` reads every key it is handed, so nothing is dropped either way, but a + key no requested pair claims costs **every** pair its ``exhausted`` certification, because + nothing on the receiving side can attribute a stray key to a pair. Filing per affected pair + is what keeps the verdict as precise as the walk's own knowledge; the signature cannot say + so, which is why it is said here. + A local backend opens with ``self._require_dataflow()``: the graph backends do not measure the analysis level (their attach probe never looks at the dependence relationships), so the gate lives in the implementations that can answer rather than in :meth:`taint`. diff --git a/cldk/analysis/typescript/backend.py b/cldk/analysis/typescript/backend.py index fe5f85a2..27a4d6de 100644 --- a/cldk/analysis/typescript/backend.py +++ b/cldk/analysis/typescript/backend.py @@ -1021,6 +1021,7 @@ def taint( exhausted: List[Tuple[str, str]] = [] ledger: List[Diagnostic] = [] truncated = False + claimed: set[Tuple[str, str]] = set() for (source, _), a in zip(sources, srcs): for (sink, _), b in zip(sinks, dsts): if a.ref == b.ref: @@ -1038,6 +1039,7 @@ def taint( ) ) continue + claimed.add((a.ref, b.ref)) stopped = list(blocked.get((a.ref, b.ref), [])) witnesses = found.get((a.ref, b.ref), []) ledger.extend(stopped) @@ -1048,6 +1050,18 @@ def taint( # reading a diagnostic's message back out. if depth is None and not witnesses and not stopped: exhausted.append((source, sink)) + # Every key the walk filed under is read, whether or not a requested pair claimed it. The + # loop above reads one key per pair, so a diagnostic keyed any other way -- a reversed pair, + # one arm of a callable frontier, a combination this caller did not request -- would be read + # by nobody, and the pair it named would come back in ``exhausted`` with a clean ledger: + # a certified refutation of a flow that was in fact blocked, which is the one output this + # accessor exists to refuse (E5, "a bound is never silent"). Nothing here can attribute a + # stray key to a requested pair, so no pair keeps its certification -- refusing to certify is + # the safe direction, and the ledger says why. + unclaimed = [d for key, stopped in blocked.items() if key not in claimed for d in stopped] + if unclaimed: + ledger.extend(unclaimed) + exhausted = [] roots = list({node.ref: node for node in [*srcs, *dsts]}.values()) return TaintResult( paths=paths, @@ -1081,6 +1095,16 @@ def _taint_walk( these keys, and recovering a pair by parsing prose back out of a ``Diagnostic`` would resurrect exactly the derivation that field is stored to avoid. + **An unresolved dispatch is a property of a callable frontier, not of a pair**, and this + mapping has no key meaning "every pair" -- so a walk that meets one files the same diagnostic + under *each* ``(source ref, sink ref)`` key it affects, not under one of them and not under a + key of its own devising. Filing under one arm is not equivalent and the difference is not + laxity: :meth:`taint` reads every key it is handed, so nothing is dropped either way, but a + key no requested pair claims costs **every** pair its ``exhausted`` certification, because + nothing on the receiving side can attribute a stray key to a pair. Filing per affected pair + is what keeps the verdict as precise as the walk's own knowledge; the signature cannot say + so, which is why it is said here. + A local backend opens with ``self._require_dataflow()``: the graph backends do not measure the analysis level (their attach probe never looks at the dependence relationships), so the gate lives in the implementations that can answer rather than in :meth:`taint`. diff --git a/tests/analysis/java/test_java_taint.py b/tests/analysis/java/test_java_taint.py index 512220ec..ca020cea 100644 --- a/tests/analysis/java/test_java_taint.py +++ b/tests/analysis/java/test_java_taint.py @@ -181,6 +181,19 @@ def test_a_pair_a_diagnostic_implicates_is_neither_proved_nor_refuted(): assert result.exhausted == [] and result.unresolved == [blocked] and not result.complete +def test_a_ledger_entry_no_requested_pair_claims_is_still_reported(): + """An unresolved dispatch belongs to a *callable frontier*, not to one pair, so a walk may key it + in a way this body does not look up -- a reversed pair, one arm of a frontier, a combination the + caller never requested. Reading only the requested keys would drop the entry and hand the pair it + named back as ``exhausted``: a certified refutation of a flow that was in fact blocked, which is + the one output this accessor exists to refuse.""" + a, b = _value("in", HANDLE), _value("sql", STORE) + stray = Diagnostic(code="unresolved_dispatch", message=f"'sql' in {STORE} to 'in' in {HANDLE} crosses an unresolved dispatch") + result = _Recording(blocked={(b.ref, a.ref): [stray]}).taint([("in", HANDLE)], [("sql", STORE)]) + assert result.unresolved == [stray], "the entry survives a key no requested pair claimed" + assert result.exhausted == [], "nothing can attribute a stray key to a pair, so no pair is certified" + assert not result.complete + def test_paths_are_trimmed_per_pair_and_completeness_says_the_cap_fired(): """The walk caps each pair at ``max_paths + 1``, so the extra row reports truncation without a second counting traversal -- and the trim is per pair, so a prolific pair cannot starve a diff --git a/tests/analysis/python/test_python_taint.py b/tests/analysis/python/test_python_taint.py index 77e650ac..a70f94c5 100644 --- a/tests/analysis/python/test_python_taint.py +++ b/tests/analysis/python/test_python_taint.py @@ -149,6 +149,19 @@ def test_a_pair_a_diagnostic_implicates_is_neither_proved_nor_refuted(): assert result.exhausted == [] and result.unresolved == [blocked] and not result.complete +def test_a_ledger_entry_no_requested_pair_claims_is_still_reported(): + """An unresolved dispatch belongs to a *callable frontier*, not to one pair, so a walk may key it + in a way this body does not look up -- a reversed pair, one arm of a frontier, a combination the + caller never requested. Reading only the requested keys would drop the entry and hand the pair it + named back as ``exhausted``: a certified refutation of a flow that was in fact blocked, which is + the one output this accessor exists to refuse.""" + a, b = _value("x", "f"), _value("y", "g") + stray = Diagnostic(code="unresolved_dispatch", message="'y' in g to 'x' in f crosses an unresolved dispatch") + result = _Recording(blocked={(b.ref, a.ref): [stray]}).taint([("x", "f")], [("y", "g")]) + assert result.unresolved == [stray], "the entry survives a key no requested pair claimed" + assert result.exhausted == [], "nothing can attribute a stray key to a pair, so no pair is certified" + assert not result.complete + def test_paths_are_trimmed_per_pair_and_completeness_says_the_cap_fired(): """The walk caps each pair at ``max_paths + 1``, so the extra row reports truncation without a second counting traversal -- and the trim is per pair, so a prolific pair cannot starve a diff --git a/tests/analysis/typescript/test_typescript_taint.py b/tests/analysis/typescript/test_typescript_taint.py index 3483edac..0a9dbcbc 100644 --- a/tests/analysis/typescript/test_typescript_taint.py +++ b/tests/analysis/typescript/test_typescript_taint.py @@ -149,6 +149,19 @@ def test_a_pair_a_diagnostic_implicates_is_neither_proved_nor_refuted(): assert result.exhausted == [] and result.unresolved == [blocked] and not result.complete +def test_a_ledger_entry_no_requested_pair_claims_is_still_reported(): + """An unresolved dispatch belongs to a *callable frontier*, not to one pair, so a walk may key it + in a way this body does not look up -- a reversed pair, one arm of a frontier, a combination the + caller never requested. Reading only the requested keys would drop the entry and hand the pair it + named back as ``exhausted``: a certified refutation of a flow that was in fact blocked, which is + the one output this accessor exists to refuse.""" + a, b = _value("x", "f"), _value("y", "g") + stray = Diagnostic(code="unresolved_dispatch", message="'y' in g to 'x' in f crosses an unresolved dispatch") + result = _Recording(blocked={(b.ref, a.ref): [stray]}).taint([("x", "f")], [("y", "g")]) + assert result.unresolved == [stray], "the entry survives a key no requested pair claimed" + assert result.exhausted == [], "nothing can attribute a stray key to a pair, so no pair is certified" + assert not result.complete + def test_paths_are_trimmed_per_pair_and_completeness_says_the_cap_fired(): """The walk caps each pair at ``max_paths + 1``, so the extra row reports truncation without a second counting traversal -- and the trim is per pair, so a prolific pair cannot starve a From 126c2dae204ca1d6099a23a238cc4e1b8ddad801 Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Thu, 10 Sep 2026 06:20:59 -0400 Subject: [PATCH 25/65] fix(taint): one pair per resolved position, so a duplicated selector cannot double the cap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit max_paths is documented as most witnesses per pair, and roots has always deduplicated by ref, but the pair loop ran the raw m*n cross product: sources=[("x","f"),("x","f")] with one witness returned 2 paths, and with max_paths=2 over three rows it returned 4 for one distinct pair — its own contract contradicted by its own loop. The pairs are now keyed by (a.ref, b.ref) in first-seen order, which is the same rule roots follows one line later. The same-position skip exists because a caller assembling sources programmatically hits that by accident; a duplicated entry is that accident, and consistency is the whole reason the skip is not a raise. Keeping the first spelling means exhausted still names what the caller wrote. --- cldk/analysis/java/backend.py | 69 +++++++++++-------- cldk/analysis/python/backend.py | 69 +++++++++++-------- cldk/analysis/typescript/backend.py | 69 +++++++++++-------- tests/analysis/java/test_java_taint.py | 14 ++++ tests/analysis/python/test_python_taint.py | 14 ++++ .../typescript/test_typescript_taint.py | 14 ++++ 6 files changed, 165 insertions(+), 84 deletions(-) diff --git a/cldk/analysis/java/backend.py b/cldk/analysis/java/backend.py index 8e141388..c427c3dc 100644 --- a/cldk/analysis/java/backend.py +++ b/cldk/analysis/java/backend.py @@ -2064,7 +2064,9 @@ def taint( whenever it is set. max_paths: Most witnesses **per pair**, not per call: with one sink and forty sources a flat cap lets one prolific pair starve the other thirty-nine, and in triage the - per-source witness is the answer. + per-source witness is the answer. A pair is a pair of *resolved positions*, so two + selectors naming the same one are one pair and neither double the witnesses nor the + cap. Returns: A :class:`~cldk.analysis.commons.results.TaintResult`: ``paths`` are the witnesses, @@ -2106,39 +2108,50 @@ def taint( # the walk sees flat source and sink lists, so its m*n cross product can contain a # combination this loop refuses to answer (a value that is both a source and a sink of two # different pairs), and a row for one is simply never read. + # + # The pairs are deduplicated by the *positions* they resolved to, in first-seen order. A pair + # is a pair of positions, which is what ``max_paths``' "per pair" and ``exhausted``'s verdict + # are both about: without this, a duplicated selector -- the accident that also produces a + # degenerate pair, a caller assembling sources programmatically -- would repeat its witnesses + # and let a cap of m yield 2m. ``roots`` has always deduplicated by ``ref``; this is the same + # rule one line later. The first spelling wins, so ``exhausted`` still names what the caller + # wrote. + pairs: Dict[Tuple[str, str], Tuple[str, str, SliceNode]] = {} + for (source, _), a in zip(sources, srcs): + for (sink, _), b in zip(sinks, dsts): + pairs.setdefault((a.ref, b.ref), (source, sink, a)) paths: List[FlowPath] = [] exhausted: List[Tuple[str, str]] = [] ledger: List[Diagnostic] = [] truncated = False claimed: set[Tuple[str, str]] = set() - for (source, _), a in zip(sources, srcs): - for (sink, _), b in zip(sinks, dsts): - if a.ref == b.ref: - # ``Diagnostic.code`` is a closed vocabulary with no member for a degenerate - # pair. ``no_match`` is the nearest true thing it can say -- there is no answer - # for this pair -- where ``unresolved_dispatch`` would falsely implicate the call - # frontier, which is the one signal ``exhausted`` reduces to. - ledger.append( - Diagnostic( - code="no_match", - message=( - f"{source!r} and {sink!r} name the same position within {a.callable!r}, so that pair is skipped rather than " - f"searched; a value reaches itself only through recursion, which reaches({a.callable!r}, {a.callable!r}) answers" - ), - ) + for (src_ref, dst_ref), (source, sink, a) in pairs.items(): + if src_ref == dst_ref: + # ``Diagnostic.code`` is a closed vocabulary with no member for a degenerate + # pair. ``no_match`` is the nearest true thing it can say -- there is no answer + # for this pair -- where ``unresolved_dispatch`` would falsely implicate the call + # frontier, which is the one signal ``exhausted`` reduces to. + ledger.append( + Diagnostic( + code="no_match", + message=( + f"{source!r} and {sink!r} name the same position within {a.callable!r}, so that pair is skipped rather than " + f"searched; a value reaches itself only through recursion, which reaches({a.callable!r}, {a.callable!r}) answers" + ), ) - continue - claimed.add((a.ref, b.ref)) - stopped = list(blocked.get((a.ref, b.ref), [])) - witnesses = found.get((a.ref, b.ref), []) - ledger.extend(stopped) - paths.extend(witnesses[:max_paths]) - truncated = truncated or len(witnesses) > max_paths - # The three conditions, in one place: unbounded search, no witness, clean ledger for - # this pair. The pair association comes from ``blocked``'s key and never from - # reading a diagnostic's message back out. - if depth is None and not witnesses and not stopped: - exhausted.append((source, sink)) + ) + continue + claimed.add((src_ref, dst_ref)) + stopped = list(blocked.get((src_ref, dst_ref), [])) + witnesses = found.get((src_ref, dst_ref), []) + ledger.extend(stopped) + paths.extend(witnesses[:max_paths]) + truncated = truncated or len(witnesses) > max_paths + # The three conditions, in one place: unbounded search, no witness, clean ledger for + # this pair. The pair association comes from ``blocked``'s key and never from + # reading a diagnostic's message back out. + if depth is None and not witnesses and not stopped: + exhausted.append((source, sink)) # Every key the walk filed under is read, whether or not a requested pair claimed it. The # loop above reads one key per pair, so a diagnostic keyed any other way -- a reversed pair, # one arm of a callable frontier, a combination this caller did not request -- would be read diff --git a/cldk/analysis/python/backend.py b/cldk/analysis/python/backend.py index 3f92148d..123066b6 100644 --- a/cldk/analysis/python/backend.py +++ b/cldk/analysis/python/backend.py @@ -1102,7 +1102,9 @@ def taint( whenever it is set. max_paths: Most witnesses **per pair**, not per call: with one sink and forty sources a flat cap lets one prolific pair starve the other thirty-nine, and in triage the - per-source witness is the answer. + per-source witness is the answer. A pair is a pair of *resolved positions*, so two + selectors naming the same one are one pair and neither double the witnesses nor the + cap. Returns: A :class:`~cldk.analysis.commons.results.TaintResult`: ``paths`` are the witnesses, @@ -1140,39 +1142,50 @@ def taint( # the walk sees flat source and sink lists, so its m*n cross product can contain a # combination this loop refuses to answer (a value that is both a source and a sink of two # different pairs), and a row for one is simply never read. + # + # The pairs are deduplicated by the *positions* they resolved to, in first-seen order. A pair + # is a pair of positions, which is what ``max_paths``' "per pair" and ``exhausted``'s verdict + # are both about: without this, a duplicated selector -- the accident that also produces a + # degenerate pair, a caller assembling sources programmatically -- would repeat its witnesses + # and let a cap of m yield 2m. ``roots`` has always deduplicated by ``ref``; this is the same + # rule one line later. The first spelling wins, so ``exhausted`` still names what the caller + # wrote. + pairs: Dict[Tuple[str, str], Tuple[str, str, SliceNode]] = {} + for (source, _), a in zip(sources, srcs): + for (sink, _), b in zip(sinks, dsts): + pairs.setdefault((a.ref, b.ref), (source, sink, a)) paths: List[FlowPath] = [] exhausted: List[Tuple[str, str]] = [] ledger: List[Diagnostic] = [] truncated = False claimed: set[Tuple[str, str]] = set() - for (source, _), a in zip(sources, srcs): - for (sink, _), b in zip(sinks, dsts): - if a.ref == b.ref: - # ``Diagnostic.code`` is a closed vocabulary with no member for a degenerate - # pair. ``no_match`` is the nearest true thing it can say -- there is no answer - # for this pair -- where ``unresolved_dispatch`` would falsely implicate the call - # frontier, which is the one signal ``exhausted`` reduces to. - ledger.append( - Diagnostic( - code="no_match", - message=( - f"{source!r} and {sink!r} name the same position within {a.callable!r}, so that pair is skipped rather than " - f"searched; a value reaches itself only through recursion, which reaches({a.callable!r}, {a.callable!r}) answers" - ), - ) + for (src_ref, dst_ref), (source, sink, a) in pairs.items(): + if src_ref == dst_ref: + # ``Diagnostic.code`` is a closed vocabulary with no member for a degenerate + # pair. ``no_match`` is the nearest true thing it can say -- there is no answer + # for this pair -- where ``unresolved_dispatch`` would falsely implicate the call + # frontier, which is the one signal ``exhausted`` reduces to. + ledger.append( + Diagnostic( + code="no_match", + message=( + f"{source!r} and {sink!r} name the same position within {a.callable!r}, so that pair is skipped rather than " + f"searched; a value reaches itself only through recursion, which reaches({a.callable!r}, {a.callable!r}) answers" + ), ) - continue - claimed.add((a.ref, b.ref)) - stopped = list(blocked.get((a.ref, b.ref), [])) - witnesses = found.get((a.ref, b.ref), []) - ledger.extend(stopped) - paths.extend(witnesses[:max_paths]) - truncated = truncated or len(witnesses) > max_paths - # The three conditions, in one place: unbounded search, no witness, clean ledger for - # this pair. The pair association comes from ``blocked``'s key and never from - # reading a diagnostic's message back out. - if depth is None and not witnesses and not stopped: - exhausted.append((source, sink)) + ) + continue + claimed.add((src_ref, dst_ref)) + stopped = list(blocked.get((src_ref, dst_ref), [])) + witnesses = found.get((src_ref, dst_ref), []) + ledger.extend(stopped) + paths.extend(witnesses[:max_paths]) + truncated = truncated or len(witnesses) > max_paths + # The three conditions, in one place: unbounded search, no witness, clean ledger for + # this pair. The pair association comes from ``blocked``'s key and never from + # reading a diagnostic's message back out. + if depth is None and not witnesses and not stopped: + exhausted.append((source, sink)) # Every key the walk filed under is read, whether or not a requested pair claimed it. The # loop above reads one key per pair, so a diagnostic keyed any other way -- a reversed pair, # one arm of a callable frontier, a combination this caller did not request -- would be read diff --git a/cldk/analysis/typescript/backend.py b/cldk/analysis/typescript/backend.py index 27a4d6de..6096f042 100644 --- a/cldk/analysis/typescript/backend.py +++ b/cldk/analysis/typescript/backend.py @@ -979,7 +979,9 @@ def taint( whenever it is set. max_paths: Most witnesses **per pair**, not per call: with one sink and forty sources a flat cap lets one prolific pair starve the other thirty-nine, and in triage the - per-source witness is the answer. + per-source witness is the answer. A pair is a pair of *resolved positions*, so two + selectors naming the same one are one pair and neither double the witnesses nor the + cap. Returns: A :class:`~cldk.analysis.commons.results.TaintResult`: ``paths`` are the witnesses, @@ -1017,39 +1019,50 @@ def taint( # the walk sees flat source and sink lists, so its m*n cross product can contain a # combination this loop refuses to answer (a value that is both a source and a sink of two # different pairs), and a row for one is simply never read. + # + # The pairs are deduplicated by the *positions* they resolved to, in first-seen order. A pair + # is a pair of positions, which is what ``max_paths``' "per pair" and ``exhausted``'s verdict + # are both about: without this, a duplicated selector -- the accident that also produces a + # degenerate pair, a caller assembling sources programmatically -- would repeat its witnesses + # and let a cap of m yield 2m. ``roots`` has always deduplicated by ``ref``; this is the same + # rule one line later. The first spelling wins, so ``exhausted`` still names what the caller + # wrote. + pairs: Dict[Tuple[str, str], Tuple[str, str, SliceNode]] = {} + for (source, _), a in zip(sources, srcs): + for (sink, _), b in zip(sinks, dsts): + pairs.setdefault((a.ref, b.ref), (source, sink, a)) paths: List[FlowPath] = [] exhausted: List[Tuple[str, str]] = [] ledger: List[Diagnostic] = [] truncated = False claimed: set[Tuple[str, str]] = set() - for (source, _), a in zip(sources, srcs): - for (sink, _), b in zip(sinks, dsts): - if a.ref == b.ref: - # ``Diagnostic.code`` is a closed vocabulary with no member for a degenerate - # pair. ``no_match`` is the nearest true thing it can say -- there is no answer - # for this pair -- where ``unresolved_dispatch`` would falsely implicate the call - # frontier, which is the one signal ``exhausted`` reduces to. - ledger.append( - Diagnostic( - code="no_match", - message=( - f"{source!r} and {sink!r} name the same position within {a.callable!r}, so that pair is skipped rather than " - f"searched; a value reaches itself only through recursion, which reaches({a.callable!r}, {a.callable!r}) answers" - ), - ) + for (src_ref, dst_ref), (source, sink, a) in pairs.items(): + if src_ref == dst_ref: + # ``Diagnostic.code`` is a closed vocabulary with no member for a degenerate + # pair. ``no_match`` is the nearest true thing it can say -- there is no answer + # for this pair -- where ``unresolved_dispatch`` would falsely implicate the call + # frontier, which is the one signal ``exhausted`` reduces to. + ledger.append( + Diagnostic( + code="no_match", + message=( + f"{source!r} and {sink!r} name the same position within {a.callable!r}, so that pair is skipped rather than " + f"searched; a value reaches itself only through recursion, which reaches({a.callable!r}, {a.callable!r}) answers" + ), ) - continue - claimed.add((a.ref, b.ref)) - stopped = list(blocked.get((a.ref, b.ref), [])) - witnesses = found.get((a.ref, b.ref), []) - ledger.extend(stopped) - paths.extend(witnesses[:max_paths]) - truncated = truncated or len(witnesses) > max_paths - # The three conditions, in one place: unbounded search, no witness, clean ledger for - # this pair. The pair association comes from ``blocked``'s key and never from - # reading a diagnostic's message back out. - if depth is None and not witnesses and not stopped: - exhausted.append((source, sink)) + ) + continue + claimed.add((src_ref, dst_ref)) + stopped = list(blocked.get((src_ref, dst_ref), [])) + witnesses = found.get((src_ref, dst_ref), []) + ledger.extend(stopped) + paths.extend(witnesses[:max_paths]) + truncated = truncated or len(witnesses) > max_paths + # The three conditions, in one place: unbounded search, no witness, clean ledger for + # this pair. The pair association comes from ``blocked``'s key and never from + # reading a diagnostic's message back out. + if depth is None and not witnesses and not stopped: + exhausted.append((source, sink)) # Every key the walk filed under is read, whether or not a requested pair claimed it. The # loop above reads one key per pair, so a diagnostic keyed any other way -- a reversed pair, # one arm of a callable frontier, a combination this caller did not request -- would be read diff --git a/tests/analysis/java/test_java_taint.py b/tests/analysis/java/test_java_taint.py index ca020cea..180ae9f1 100644 --- a/tests/analysis/java/test_java_taint.py +++ b/tests/analysis/java/test_java_taint.py @@ -207,6 +207,20 @@ def test_paths_are_trimmed_per_pair_and_completeness_says_the_cap_fired(): assert len(whole.paths) == 4 and whole.complete +def test_two_selectors_that_resolve_to_the_same_position_are_one_pair(): + """``max_paths`` is documented as most witnesses **per pair**, and a pair is a pair of resolved + *positions*: a caller assembling sources programmatically duplicates an entry by the same + accident that produces a degenerate pair, and counting it twice returns 2m witnesses for a cap of + m. ``roots`` already dedups by ``ref``; the verdict does too, keeping the first spelling so + ``exhausted`` still names what the caller wrote.""" + a, b = _value("in", HANDLE), _value("sql", STORE) + rows = [(a.ref, b.ref, _witness(a, b))] * 3 + once = _Recording(rows=rows[:1]).taint([("in", HANDLE), ("in", HANDLE)], [("sql", STORE)]) + assert len(once.paths) == 1 and once.complete, "one distinct pair, one witness" + capped = _Recording(rows=rows).taint([("in", HANDLE), ("in", HANDLE)], [("sql", STORE)], max_paths=2) + assert len(capped.paths) == 2 and not capped.complete, "the cap holds per distinct pair" + assert once.exhausted == [] and capped.exhausted == [] + def test_the_sanitizer_selectors_are_resolved_through_the_edge_vars_hook(): """Step 4 of the order: the two shapes reach the walk as ``$cuts`` and ``$cut_callables``, and a variable selector is checked against the vars on real SDG edges rather than ``resolve_value``, diff --git a/tests/analysis/python/test_python_taint.py b/tests/analysis/python/test_python_taint.py index a70f94c5..68b95f4e 100644 --- a/tests/analysis/python/test_python_taint.py +++ b/tests/analysis/python/test_python_taint.py @@ -175,6 +175,20 @@ def test_paths_are_trimmed_per_pair_and_completeness_says_the_cap_fired(): assert len(whole.paths) == 4 and whole.complete +def test_two_selectors_that_resolve_to_the_same_position_are_one_pair(): + """``max_paths`` is documented as most witnesses **per pair**, and a pair is a pair of resolved + *positions*: a caller assembling sources programmatically duplicates an entry by the same + accident that produces a degenerate pair, and counting it twice returns 2m witnesses for a cap of + m. ``roots`` already dedups by ``ref``; the verdict does too, keeping the first spelling so + ``exhausted`` still names what the caller wrote.""" + a, b = _value("x", "f"), _value("y", "g") + rows = [(a.ref, b.ref, _witness(a, b))] * 3 + once = _Recording(rows=rows[:1]).taint([("x", "f"), ("x", "f")], [("y", "g")]) + assert len(once.paths) == 1 and once.complete, "one distinct pair, one witness" + capped = _Recording(rows=rows).taint([("x", "f"), ("x", "f")], [("y", "g")], max_paths=2) + assert len(capped.paths) == 2 and not capped.complete, "the cap holds per distinct pair" + assert once.exhausted == [] and capped.exhausted == [] + def test_the_sanitizer_selectors_are_resolved_through_the_edge_vars_hook(): """Step 4 of the order: the two shapes reach the walk as ``$cuts`` and ``$cut_callables``, and a variable selector is checked against the vars on real SDG edges rather than ``resolve_value``, diff --git a/tests/analysis/typescript/test_typescript_taint.py b/tests/analysis/typescript/test_typescript_taint.py index 0a9dbcbc..260ea95b 100644 --- a/tests/analysis/typescript/test_typescript_taint.py +++ b/tests/analysis/typescript/test_typescript_taint.py @@ -175,6 +175,20 @@ def test_paths_are_trimmed_per_pair_and_completeness_says_the_cap_fired(): assert len(whole.paths) == 4 and whole.complete +def test_two_selectors_that_resolve_to_the_same_position_are_one_pair(): + """``max_paths`` is documented as most witnesses **per pair**, and a pair is a pair of resolved + *positions*: a caller assembling sources programmatically duplicates an entry by the same + accident that produces a degenerate pair, and counting it twice returns 2m witnesses for a cap of + m. ``roots`` already dedups by ``ref``; the verdict does too, keeping the first spelling so + ``exhausted`` still names what the caller wrote.""" + a, b = _value("x", "f"), _value("y", "g") + rows = [(a.ref, b.ref, _witness(a, b))] * 3 + once = _Recording(rows=rows[:1]).taint([("x", "f"), ("x", "f")], [("y", "g")]) + assert len(once.paths) == 1 and once.complete, "one distinct pair, one witness" + capped = _Recording(rows=rows).taint([("x", "f"), ("x", "f")], [("y", "g")], max_paths=2) + assert len(capped.paths) == 2 and not capped.complete, "the cap holds per distinct pair" + assert once.exhausted == [] and capped.exhausted == [] + def test_the_sanitizer_selectors_are_resolved_through_the_edge_vars_hook(): """Step 4 of the order: the two shapes reach the walk as ``$cuts`` and ``$cut_callables``, and a variable selector is checked against the vars on real SDG edges rather than ``resolve_value``, From 70cf27c68ca97cdd5aaf2862e57839a3276c8a6e Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Thu, 10 Sep 2026 06:22:45 -0400 Subject: [PATCH 26/65] fix(java): taint() asks the level gate, so a shallow analysis is not diagnosed as a port lattice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ruling F kept the level gate out of taint()'s body on the premise that _require_dataflow lives only on the local backends. That premise holds for Python and TypeScript and not for Java: this ABC declares one at :2196, a concrete no-op the in-memory backend overrides, because --emit neo4j is always full depth. Measured on the committed a1 fixture at symbol_table: both names resolve — Java's resolve_value reads the declared parameter list, not the formal_in vertices — so taint() reached _require_connected_ports and answered PORTS_DISCONNECTED, "this analysis carries no data or control dependence edge out of a callable's formal_in vertices". That helper's own docstring makes it a claim about what the analyzer emitted, so the caller is sent to codeanalyzer-java#227 when the remedy is analysis_level='system_dependency_graph'. A wrong diagnosis is worse than a coarse one. The gate goes immediately before the port-lattice gate, so the ordered contract is untouched: a caller still hears about their own typo before either gap in the analysis, and the two Java gates sit together. Python and TypeScript are left alone, where the premise does hold. --- cldk/analysis/java/backend.py | 18 +++++++++++++---- tests/analysis/java/test_java_taint.py | 28 +++++++++++++++++++++++++- 2 files changed, 41 insertions(+), 5 deletions(-) diff --git a/cldk/analysis/java/backend.py b/cldk/analysis/java/backend.py index c427c3dc..30d10282 100644 --- a/cldk/analysis/java/backend.py +++ b/cldk/analysis/java/backend.py @@ -2049,10 +2049,15 @@ def taint( at all -- and never of the analyzer's version, so output that connects the two layers makes this answer with no change here. - **The level gate belongs to the walk, not here.** Each :meth:`_taint_walk` opens with - ``self._require_dataflow()``, which is a no-op on the graph backend and the real check on the - in-memory one, so the gate lives in the same place in all three languages rather than in a - body two of the five backends would have to be asked a question they do not measure. + **The level gate is asked here**, which is Java's one divergence from the other two languages: + :meth:`_require_dataflow` exists on *this* contract -- a no-op on the graph backend, since + ``--emit neo4j`` is always full depth, and the real check on the in-memory one -- so asking it + costs nothing and buys the message that names both levels. Without it a local analysis below + the dependence level resolved its names, reached the port-lattice gate and was told + :data:`PORTS_DISCONNECTED`: a true sentence about what the *analyzer emitted*, pointing the + caller at codeanalyzer-java#227, when the remedy is + ``analysis_level='system_dependency_graph'``. The Python and TypeScript ABCs carry no such + method, and there the gate opens each :meth:`_taint_walk` instead. Args: sources: The values taint enters at, each ``(name, within)`` -- the addressing @@ -2081,6 +2086,10 @@ def taint( CodeanalyzerExecutionException: :data:`PORTS_DISCONNECTED` -- this analysis's port lattice carries no dependence edge, so every pair would come back refuted for a reason that has nothing to do with the program. + CodeanalyzerUsageException: (local backend) built below + ``analysis_level="program_dependency_graph"``, where there is no dependence edge to + walk at all -- reported as the level rather than as the port lattice, which is a + different fact. SelectorNotInGraph: A name matched nothing, or a sanitizer's shape disagrees with what it resolves to. TypeError: ``sources`` or ``sinks`` is a bare string, which would unpack into a pair. @@ -2099,6 +2108,7 @@ def taint( srcs = [self.resolve_value(name, within=within) for name, within in sources] dsts = [self.resolve_value(name, within=within) for name, within in sinks] cuts, cut_callables = resolve_sanitizers(sanitizers, resolve_callable=self.resolve_callable, edge_vars_in=self._edge_vars_in) + self._require_dataflow() self._require_connected_ports("taint") rows, blocked = self._taint_walk(srcs, dsts, cuts=cuts, cut_callables=cut_callables, depth=depth, max_paths=max_paths) found: Dict[Tuple[str, str], List[FlowPath]] = {} diff --git a/tests/analysis/java/test_java_taint.py b/tests/analysis/java/test_java_taint.py index 180ae9f1..163a51e9 100644 --- a/tests/analysis/java/test_java_taint.py +++ b/tests/analysis/java/test_java_taint.py @@ -31,12 +31,21 @@ from cldk.analysis.commons.results import Diagnostic, FlowPath, PathHop, SliceNode from cldk.analysis.java.backend import JavaAnalysisBackend -from cldk.utils.exceptions.exceptions import CodeanalyzerExecutionException, SelectorNotInGraph +from cldk.utils.exceptions.exceptions import CodeanalyzerExecutionException, CodeanalyzerUsageException, SelectorNotInGraph + +from tests.analysis.java.test_java_addressing import _local HANDLE = "com.acme.Svc.handle(java.lang.String)" STORE = "com.acme.Dao.store(java.lang.String)" LOG = "com.acme.Log.write(java.lang.String)" +#: Two real positions in the committed **a1** fixture, for the one test below that runs the level gate +#: against the local backend rather than the fake: a1 is level 1, where the analyzer emits no +#: cfg/cdg/ddg and no dependence edge out of a ``formal_in`` at all. +DIRECT = "com.ibm.websphere.samples.daytrader.impl.direct.TradeDirect" +CANCEL = f"{DIRECT}.cancelOrder(java.lang.Integer, boolean)" +SELL = f"{DIRECT}.sell(java.lang.String, java.lang.Integer, int)" + def _value(name: str, within: str) -> SliceNode: """A resolved value, addressed as this surface addresses one: a parameter plus the callable it @@ -138,6 +147,23 @@ def test_a_disconnected_port_lattice_refuses_after_resolution_and_before_the_wal backend.taint([("in", HANDLE)], [("sql", STORE)], depth=0) +def test_below_the_dataflow_level_the_diagnosis_is_the_level_and_not_the_port_lattice(analysis_json): + """Ruling F -- "the level gate belongs to the walk" -- rests on ``_require_dataflow`` not existing + on the ABC, which is true of Python and TypeScript and **not** of Java: Java has one, a no-op on + the graph backend and the real check on the in-memory one. + + Without it a level-1 local analysis resolved both names, reached ``_require_connected_ports`` and + was told ``PORTS_DISCONNECTED`` -- *this analysis's port lattice carries no dependence edge* -- + which that helper's own docstring makes a claim about what the analyzer **emitted**. True sentence, + wrong diagnosis: it points at codeanalyzer-java#227 when the remedy is + ``analysis_level='system_dependency_graph'``. Measured on this fixture before the gate was added. + """ + backend = _local(analysis_json) + backend.analysis_level = "symbol_table" + with pytest.raises(CodeanalyzerUsageException, match="program_dependency_graph") as raised: + backend.taint([("orderID", CANCEL)], [("userID", SELL)]) + assert "formal_in" not in str(raised.value), "the level is the diagnosis, not the port lattice" + def test_a_found_flow_carries_its_witnesses_the_roots_and_the_audit_line(): a, b = _value("in", HANDLE), _value("sql", STORE) backend = _Recording(rows=[(a.ref, b.ref, _witness(a, b))]) From b9c0d3f97535da6d8eaa3318af58c66b66213ba1 Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Thu, 10 Sep 2026 06:28:34 -0400 Subject: [PATCH 27/65] refactor(taint): the post-walk assembly lives once in commons, not three times Ruling E keeps ``taint()`` concrete on the three language ABCs -- the docstring is the surface a caller reads and it belongs where the accessor is -- but the arithmetic after the walk was byte-identical in all three: the pair fan-out, the per-pair trim, the three ``exhausted`` conditions, the ledger sweep, ``roots``. Items 1, 2 and 6 of this fix round are each one edit times three files, and the first one applied to two files instead of three is precisely the drift Ruling E was written to prevent. ``taint_verdict`` joins ``sdg_taint_query`` and ``slice_resolved`` in ``commons/graphs.py``, which already owns the shared shape of this surface. The three bodies keep everything a reader needs in place -- docstring, argument checks, resolution, the sanitizer resolve, Java's two gates -- and now differ only in those: python and typescript are identical, java adds the two gate lines. --- cldk/analysis/commons/graphs.py | 110 +++++++++++++++++++++++++++- cldk/analysis/java/backend.py | 75 +------------------ cldk/analysis/python/backend.py | 74 +------------------ cldk/analysis/typescript/backend.py | 75 +------------------ 4 files changed, 114 insertions(+), 220 deletions(-) diff --git a/cldk/analysis/commons/graphs.py b/cldk/analysis/commons/graphs.py index fff8c821..61ebd785 100644 --- a/cldk/analysis/commons/graphs.py +++ b/cldk/analysis/commons/graphs.py @@ -24,12 +24,12 @@ from __future__ import annotations -from typing import Callable, Iterable, List, Literal, Mapping, Sequence, Tuple +from typing import Callable, Dict, Iterable, List, Literal, Mapping, Sequence, Tuple import networkx as nx from cldk.analysis.commons.bounds import check_selector, reject_bare_string -from cldk.analysis.commons.results import FlowPath, LocateResult, PathHop, SliceNode +from cldk.analysis.commons.results import Diagnostic, FlowPath, LocateResult, PathHop, SliceNode, TaintResult def bounded_subgraph(graph: nx.DiGraph, roots: List[str], depth: int | None, declared: Iterable[str]) -> nx.DiGraph: @@ -575,3 +575,109 @@ def slice_resolved(roots: List[SliceNode]) -> str: results is comparing answers and not two spellings of one. """ return ", ".join(f"{r.callable} {r.kind} {r.name!r}" if r.kind != "callable" else r.callable for r in roots) + + +def taint_verdict( + sources: Sequence[Tuple[str, str]], + sinks: Sequence[Tuple[str, str]], + srcs: Sequence[SliceNode], + dsts: Sequence[SliceNode], + *, + rows: Sequence[Tuple[str, str, FlowPath]], + blocked: Mapping[Tuple[str, str], List[Diagnostic]], + depth: int | None, + max_paths: int, +) -> TaintResult: + """What a walk found, turned into the answer ``taint()`` returns: witnesses, verdicts, ledger. + + One implementation for all three languages, because every edit this assembly has needed has been + one edit times three files, and the first such edit applied to two of them is drift of exactly + the kind that ships a wrong ``exhausted``. The *contract* stays on each ABC -- the docstring, the + ordered checks, the resolution, Java's two gates -- and only the arithmetic after the walk is + here, in the module that already owns ``sdg_taint_query`` and ``slice_resolved``. + + Four rules live in this body and nowhere else: + + * **The verdict is assembled by looking each requested pair up**, never by consuming ``rows``. + A walk sees flat source and sink lists, so its m*n cross product can contain a combination no + requested pair names (a value that is both a source of one pair and a sink of another), and a + row for one of those is simply never read. + * **A pair is a pair of resolved positions.** The requested pairs are deduplicated by + ``(src ref, dst ref)`` in first-seen order, which is the rule ``roots`` follows too: without it + a duplicated selector repeats its witnesses and lets a cap of m yield 2m, contradicting + ``max_paths``' own "per pair". The first spelling wins, so ``exhausted`` names what the caller + wrote. + * **No key in ``blocked`` can vanish.** The pair loop reads one key per pair and claims it; every + key left unclaimed is swept into the ledger afterwards. A diagnostic keyed any other way -- a + reversed pair, one arm of a callable frontier, a combination this caller did not request -- + would otherwise be read by nobody, and the pair it named would come back ``exhausted`` with a + clean ledger: a certified refutation of a flow that was in fact blocked. Nothing here can + attribute a stray key to a pair, so ``exhausted`` is emptied rather than trusted; refusing to + certify is the direction that cannot close a live alert, and ``unresolved`` says why. + * **``complete`` is the whole batch's flag**, ``True`` only when the cap cut nothing *and* the + ledger is empty. It is deliberately conservative and deliberately coarse: one degenerate pair + makes a forty-pair call ``False``, and raising ``max_paths`` will not change that. ``exhausted`` + is what carries a per-pair verdict. + + Args: + sources: The ``(name, within)`` selectors as the caller wrote them -- what ``exhausted`` and + the diagnostics are named by. + sinks: The same, for the sinks. + srcs: What ``sources`` resolved to, positionally aligned with it. + dsts: What ``sinks`` resolved to, positionally aligned with it. + rows: The walk's ``(src ref, dst ref, path)`` triples, shortest-first within a pair and + capped at ``max_paths + 1`` per pair, which is what reports truncation without a second + counting traversal. + blocked: The walk's frontier ledger, keyed by the pair each diagnostic implicates. + depth: The bound the call ran under; ``exhausted`` is empty whenever it is not ``None``, + because a pair with no path within five hops is unmeasured rather than refuted. + max_paths: Most witnesses per pair. + + Returns: + The :class:`~cldk.analysis.commons.results.TaintResult` the accessor hands back. + """ + found: Dict[Tuple[str, str], List[FlowPath]] = {} + for src_ref, dst_ref, path in rows: + found.setdefault((src_ref, dst_ref), []).append(path) + pairs: Dict[Tuple[str, str], Tuple[str, str, SliceNode]] = {} + for (source, _), a in zip(sources, srcs): + for (sink, _), b in zip(sinks, dsts): + pairs.setdefault((a.ref, b.ref), (source, sink, a)) + paths: List[FlowPath] = [] + exhausted: List[Tuple[str, str]] = [] + ledger: List[Diagnostic] = [] + truncated = False + claimed: set[Tuple[str, str]] = set() + for (src_ref, dst_ref), (source, sink, a) in pairs.items(): + if src_ref == dst_ref: + # ``Diagnostic.code`` is a closed vocabulary with no member for a degenerate pair. + # ``no_match`` is the nearest true thing it can say -- there is no answer for this pair -- + # where ``unresolved_dispatch`` would falsely implicate the call frontier, which is the + # one signal ``exhausted`` reduces to. + ledger.append( + Diagnostic( + code="no_match", + message=( + f"{source!r} and {sink!r} name the same position within {a.callable!r}, so that pair is skipped rather than " + f"searched; a value reaches itself only through recursion, which reaches({a.callable!r}, {a.callable!r}) answers" + ), + ) + ) + continue + claimed.add((src_ref, dst_ref)) + stopped = list(blocked.get((src_ref, dst_ref), [])) + witnesses = found.get((src_ref, dst_ref), []) + ledger.extend(stopped) + paths.extend(witnesses[:max_paths]) + truncated = truncated or len(witnesses) > max_paths + # The three conditions, in one place: unbounded search, no witness, clean ledger for this + # pair. The pair association comes from ``blocked``'s key and never from reading a + # diagnostic's message back out. + if depth is None and not witnesses and not stopped: + exhausted.append((source, sink)) + unclaimed = [d for key, stopped in blocked.items() if key not in claimed for d in stopped] + if unclaimed: + ledger.extend(unclaimed) + exhausted = [] + roots = list({node.ref: node for node in [*srcs, *dsts]}.values()) + return TaintResult(paths=paths, complete=not truncated and not ledger, exhausted=exhausted, roots=roots, resolved=slice_resolved(roots), unresolved=ledger) diff --git a/cldk/analysis/java/backend.py b/cldk/analysis/java/backend.py index 30d10282..f4f3ec88 100644 --- a/cldk/analysis/java/backend.py +++ b/cldk/analysis/java/backend.py @@ -67,7 +67,7 @@ check_max_paths, reject_bare_string, ) -from cldk.analysis.commons.graphs import as_slice_node, call_reaches, cone_sinks, edge_sort_key, flow_path, sdg_rel_pattern, sdg_rels, shortest_walks, slice_resolved, via_table +from cldk.analysis.commons.graphs import as_slice_node, call_reaches, cone_sinks, edge_sort_key, flow_path, sdg_rel_pattern, sdg_rels, shortest_walks, slice_resolved, taint_verdict, via_table from cldk.analysis.commons.keys import body_key_column, resolve_module_key from cldk.analysis.commons.resolve import CallableCandidate, resolve_callable_signature, resolve_sanitizers, resolve_value_name, resolve_within from cldk.analysis.commons.results import ( @@ -2111,78 +2111,7 @@ def taint( self._require_dataflow() self._require_connected_ports("taint") rows, blocked = self._taint_walk(srcs, dsts, cuts=cuts, cut_callables=cut_callables, depth=depth, max_paths=max_paths) - found: Dict[Tuple[str, str], List[FlowPath]] = {} - for src_ref, dst_ref, path in rows: - found.setdefault((src_ref, dst_ref), []).append(path) - # The verdict is assembled by looking each *requested* pair up, never by consuming the rows: - # the walk sees flat source and sink lists, so its m*n cross product can contain a - # combination this loop refuses to answer (a value that is both a source and a sink of two - # different pairs), and a row for one is simply never read. - # - # The pairs are deduplicated by the *positions* they resolved to, in first-seen order. A pair - # is a pair of positions, which is what ``max_paths``' "per pair" and ``exhausted``'s verdict - # are both about: without this, a duplicated selector -- the accident that also produces a - # degenerate pair, a caller assembling sources programmatically -- would repeat its witnesses - # and let a cap of m yield 2m. ``roots`` has always deduplicated by ``ref``; this is the same - # rule one line later. The first spelling wins, so ``exhausted`` still names what the caller - # wrote. - pairs: Dict[Tuple[str, str], Tuple[str, str, SliceNode]] = {} - for (source, _), a in zip(sources, srcs): - for (sink, _), b in zip(sinks, dsts): - pairs.setdefault((a.ref, b.ref), (source, sink, a)) - paths: List[FlowPath] = [] - exhausted: List[Tuple[str, str]] = [] - ledger: List[Diagnostic] = [] - truncated = False - claimed: set[Tuple[str, str]] = set() - for (src_ref, dst_ref), (source, sink, a) in pairs.items(): - if src_ref == dst_ref: - # ``Diagnostic.code`` is a closed vocabulary with no member for a degenerate - # pair. ``no_match`` is the nearest true thing it can say -- there is no answer - # for this pair -- where ``unresolved_dispatch`` would falsely implicate the call - # frontier, which is the one signal ``exhausted`` reduces to. - ledger.append( - Diagnostic( - code="no_match", - message=( - f"{source!r} and {sink!r} name the same position within {a.callable!r}, so that pair is skipped rather than " - f"searched; a value reaches itself only through recursion, which reaches({a.callable!r}, {a.callable!r}) answers" - ), - ) - ) - continue - claimed.add((src_ref, dst_ref)) - stopped = list(blocked.get((src_ref, dst_ref), [])) - witnesses = found.get((src_ref, dst_ref), []) - ledger.extend(stopped) - paths.extend(witnesses[:max_paths]) - truncated = truncated or len(witnesses) > max_paths - # The three conditions, in one place: unbounded search, no witness, clean ledger for - # this pair. The pair association comes from ``blocked``'s key and never from - # reading a diagnostic's message back out. - if depth is None and not witnesses and not stopped: - exhausted.append((source, sink)) - # Every key the walk filed under is read, whether or not a requested pair claimed it. The - # loop above reads one key per pair, so a diagnostic keyed any other way -- a reversed pair, - # one arm of a callable frontier, a combination this caller did not request -- would be read - # by nobody, and the pair it named would come back in ``exhausted`` with a clean ledger: - # a certified refutation of a flow that was in fact blocked, which is the one output this - # accessor exists to refuse (E5, "a bound is never silent"). Nothing here can attribute a - # stray key to a requested pair, so no pair keeps its certification -- refusing to certify is - # the safe direction, and the ledger says why. - unclaimed = [d for key, stopped in blocked.items() if key not in claimed for d in stopped] - if unclaimed: - ledger.extend(unclaimed) - exhausted = [] - roots = list({node.ref: node for node in [*srcs, *dsts]}.values()) - return TaintResult( - paths=paths, - complete=not truncated and not ledger, - exhausted=exhausted, - roots=roots, - resolved=slice_resolved(roots), - unresolved=ledger, - ) + return taint_verdict(sources, sinks, srcs, dsts, rows=rows, blocked=blocked, depth=depth, max_paths=max_paths) def _taint_walk( self, diff --git a/cldk/analysis/python/backend.py b/cldk/analysis/python/backend.py index 123066b6..ab406999 100644 --- a/cldk/analysis/python/backend.py +++ b/cldk/analysis/python/backend.py @@ -68,6 +68,7 @@ sdg_rels, shortest_walks, slice_resolved, + taint_verdict, via_table, ) from cldk.analysis.commons.keys import body_key_column, call_graph_scope, resolve_module_key, scope_paths @@ -1135,78 +1136,7 @@ def taint( dsts = [self.resolve_value(name, within=within) for name, within in sinks] cuts, cut_callables = resolve_sanitizers(sanitizers, resolve_callable=self.resolve_callable, edge_vars_in=self._edge_vars_in) rows, blocked = self._taint_walk(srcs, dsts, cuts=cuts, cut_callables=cut_callables, depth=depth, max_paths=max_paths) - found: Dict[Tuple[str, str], List[FlowPath]] = {} - for src_ref, dst_ref, path in rows: - found.setdefault((src_ref, dst_ref), []).append(path) - # The verdict is assembled by looking each *requested* pair up, never by consuming the rows: - # the walk sees flat source and sink lists, so its m*n cross product can contain a - # combination this loop refuses to answer (a value that is both a source and a sink of two - # different pairs), and a row for one is simply never read. - # - # The pairs are deduplicated by the *positions* they resolved to, in first-seen order. A pair - # is a pair of positions, which is what ``max_paths``' "per pair" and ``exhausted``'s verdict - # are both about: without this, a duplicated selector -- the accident that also produces a - # degenerate pair, a caller assembling sources programmatically -- would repeat its witnesses - # and let a cap of m yield 2m. ``roots`` has always deduplicated by ``ref``; this is the same - # rule one line later. The first spelling wins, so ``exhausted`` still names what the caller - # wrote. - pairs: Dict[Tuple[str, str], Tuple[str, str, SliceNode]] = {} - for (source, _), a in zip(sources, srcs): - for (sink, _), b in zip(sinks, dsts): - pairs.setdefault((a.ref, b.ref), (source, sink, a)) - paths: List[FlowPath] = [] - exhausted: List[Tuple[str, str]] = [] - ledger: List[Diagnostic] = [] - truncated = False - claimed: set[Tuple[str, str]] = set() - for (src_ref, dst_ref), (source, sink, a) in pairs.items(): - if src_ref == dst_ref: - # ``Diagnostic.code`` is a closed vocabulary with no member for a degenerate - # pair. ``no_match`` is the nearest true thing it can say -- there is no answer - # for this pair -- where ``unresolved_dispatch`` would falsely implicate the call - # frontier, which is the one signal ``exhausted`` reduces to. - ledger.append( - Diagnostic( - code="no_match", - message=( - f"{source!r} and {sink!r} name the same position within {a.callable!r}, so that pair is skipped rather than " - f"searched; a value reaches itself only through recursion, which reaches({a.callable!r}, {a.callable!r}) answers" - ), - ) - ) - continue - claimed.add((src_ref, dst_ref)) - stopped = list(blocked.get((src_ref, dst_ref), [])) - witnesses = found.get((src_ref, dst_ref), []) - ledger.extend(stopped) - paths.extend(witnesses[:max_paths]) - truncated = truncated or len(witnesses) > max_paths - # The three conditions, in one place: unbounded search, no witness, clean ledger for - # this pair. The pair association comes from ``blocked``'s key and never from - # reading a diagnostic's message back out. - if depth is None and not witnesses and not stopped: - exhausted.append((source, sink)) - # Every key the walk filed under is read, whether or not a requested pair claimed it. The - # loop above reads one key per pair, so a diagnostic keyed any other way -- a reversed pair, - # one arm of a callable frontier, a combination this caller did not request -- would be read - # by nobody, and the pair it named would come back in ``exhausted`` with a clean ledger: - # a certified refutation of a flow that was in fact blocked, which is the one output this - # accessor exists to refuse (E5, "a bound is never silent"). Nothing here can attribute a - # stray key to a requested pair, so no pair keeps its certification -- refusing to certify is - # the safe direction, and the ledger says why. - unclaimed = [d for key, stopped in blocked.items() if key not in claimed for d in stopped] - if unclaimed: - ledger.extend(unclaimed) - exhausted = [] - roots = list({node.ref: node for node in [*srcs, *dsts]}.values()) - return TaintResult( - paths=paths, - complete=not truncated and not ledger, - exhausted=exhausted, - roots=roots, - resolved=slice_resolved(roots), - unresolved=ledger, - ) + return taint_verdict(sources, sinks, srcs, dsts, rows=rows, blocked=blocked, depth=depth, max_paths=max_paths) def _taint_walk( self, diff --git a/cldk/analysis/typescript/backend.py b/cldk/analysis/typescript/backend.py index 6096f042..7f735247 100644 --- a/cldk/analysis/typescript/backend.py +++ b/cldk/analysis/typescript/backend.py @@ -58,7 +58,7 @@ check_max_paths, reject_bare_string, ) -from cldk.analysis.commons.graphs import as_slice_node, edge_sort_key, sdg_rel_pattern, sdg_rels, slice_resolved, via_table +from cldk.analysis.commons.graphs import as_slice_node, edge_sort_key, sdg_rel_pattern, sdg_rels, slice_resolved, taint_verdict, via_table from cldk.analysis.commons.keys import module_dotted from cldk.analysis.commons.resolve import resolve_sanitizers from cldk.analysis.commons.results import Diagnostic, EdgePage, EntrypointCoverage, FlowPath, FlowPaths, LocateResult, Slice, SliceNode, TaintResult @@ -1012,78 +1012,7 @@ def taint( dsts = [self.resolve_value(name, within=within) for name, within in sinks] cuts, cut_callables = resolve_sanitizers(sanitizers, resolve_callable=self.resolve_callable, edge_vars_in=self._edge_vars_in) rows, blocked = self._taint_walk(srcs, dsts, cuts=cuts, cut_callables=cut_callables, depth=depth, max_paths=max_paths) - found: Dict[Tuple[str, str], List[FlowPath]] = {} - for src_ref, dst_ref, path in rows: - found.setdefault((src_ref, dst_ref), []).append(path) - # The verdict is assembled by looking each *requested* pair up, never by consuming the rows: - # the walk sees flat source and sink lists, so its m*n cross product can contain a - # combination this loop refuses to answer (a value that is both a source and a sink of two - # different pairs), and a row for one is simply never read. - # - # The pairs are deduplicated by the *positions* they resolved to, in first-seen order. A pair - # is a pair of positions, which is what ``max_paths``' "per pair" and ``exhausted``'s verdict - # are both about: without this, a duplicated selector -- the accident that also produces a - # degenerate pair, a caller assembling sources programmatically -- would repeat its witnesses - # and let a cap of m yield 2m. ``roots`` has always deduplicated by ``ref``; this is the same - # rule one line later. The first spelling wins, so ``exhausted`` still names what the caller - # wrote. - pairs: Dict[Tuple[str, str], Tuple[str, str, SliceNode]] = {} - for (source, _), a in zip(sources, srcs): - for (sink, _), b in zip(sinks, dsts): - pairs.setdefault((a.ref, b.ref), (source, sink, a)) - paths: List[FlowPath] = [] - exhausted: List[Tuple[str, str]] = [] - ledger: List[Diagnostic] = [] - truncated = False - claimed: set[Tuple[str, str]] = set() - for (src_ref, dst_ref), (source, sink, a) in pairs.items(): - if src_ref == dst_ref: - # ``Diagnostic.code`` is a closed vocabulary with no member for a degenerate - # pair. ``no_match`` is the nearest true thing it can say -- there is no answer - # for this pair -- where ``unresolved_dispatch`` would falsely implicate the call - # frontier, which is the one signal ``exhausted`` reduces to. - ledger.append( - Diagnostic( - code="no_match", - message=( - f"{source!r} and {sink!r} name the same position within {a.callable!r}, so that pair is skipped rather than " - f"searched; a value reaches itself only through recursion, which reaches({a.callable!r}, {a.callable!r}) answers" - ), - ) - ) - continue - claimed.add((src_ref, dst_ref)) - stopped = list(blocked.get((src_ref, dst_ref), [])) - witnesses = found.get((src_ref, dst_ref), []) - ledger.extend(stopped) - paths.extend(witnesses[:max_paths]) - truncated = truncated or len(witnesses) > max_paths - # The three conditions, in one place: unbounded search, no witness, clean ledger for - # this pair. The pair association comes from ``blocked``'s key and never from - # reading a diagnostic's message back out. - if depth is None and not witnesses and not stopped: - exhausted.append((source, sink)) - # Every key the walk filed under is read, whether or not a requested pair claimed it. The - # loop above reads one key per pair, so a diagnostic keyed any other way -- a reversed pair, - # one arm of a callable frontier, a combination this caller did not request -- would be read - # by nobody, and the pair it named would come back in ``exhausted`` with a clean ledger: - # a certified refutation of a flow that was in fact blocked, which is the one output this - # accessor exists to refuse (E5, "a bound is never silent"). Nothing here can attribute a - # stray key to a requested pair, so no pair keeps its certification -- refusing to certify is - # the safe direction, and the ledger says why. - unclaimed = [d for key, stopped in blocked.items() if key not in claimed for d in stopped] - if unclaimed: - ledger.extend(unclaimed) - exhausted = [] - roots = list({node.ref: node for node in [*srcs, *dsts]}.values()) - return TaintResult( - paths=paths, - complete=not truncated and not ledger, - exhausted=exhausted, - roots=roots, - resolved=slice_resolved(roots), - unresolved=ledger, - ) + return taint_verdict(sources, sinks, srcs, dsts, rows=rows, blocked=blocked, depth=depth, max_paths=max_paths) def _taint_walk( self, From c3f833f5e8b3ddc53ee56fc7c766a8f6456a3433 Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Thu, 10 Sep 2026 06:31:36 -0400 Subject: [PATCH 28/65] fix(results): a skipped pair says degenerate_pair, so two published texts are true again Task 5 shipped the same-position skip as ``code="no_match"`` because the closed vocabulary had nothing better, and that borrowed one code and falsified two published texts. ``docs/agent-api-reference.md`` says of ``no_match`` and its four siblings that **nothing emits them** -- resolution failures are raised, not attached -- and ``TaintResult.unresolved`` promises every entry carries ``code="unresolved_dispatch"``. A caller filtering the ledger on that code dropped the skip entirely, and one reading ``no_match`` as a failed search read a search that never ran. The member is additive: ``Literal`` gains ``degenerate_pair`` at the end, next to the other two late additions, so no existing value changes meaning and nothing a caller already destructures moves. ``no_match`` is unemitted again, which is what the doc row claims; ``unresolved``'s text now names both codes it can carry and says what each one means for the pair. --- cldk/analysis/commons/graphs.py | 9 ++++----- cldk/analysis/commons/results.py | 14 +++++++++----- docs/agent-api-reference.md | 1 + tests/analysis/java/test_java_taint.py | 4 ++++ tests/analysis/python/test_python_taint.py | 3 +++ tests/analysis/typescript/test_typescript_taint.py | 3 +++ 6 files changed, 24 insertions(+), 10 deletions(-) diff --git a/cldk/analysis/commons/graphs.py b/cldk/analysis/commons/graphs.py index 61ebd785..6b710b6d 100644 --- a/cldk/analysis/commons/graphs.py +++ b/cldk/analysis/commons/graphs.py @@ -650,13 +650,12 @@ def taint_verdict( claimed: set[Tuple[str, str]] = set() for (src_ref, dst_ref), (source, sink, a) in pairs.items(): if src_ref == dst_ref: - # ``Diagnostic.code`` is a closed vocabulary with no member for a degenerate pair. - # ``no_match`` is the nearest true thing it can say -- there is no answer for this pair -- - # where ``unresolved_dispatch`` would falsely implicate the call frontier, which is the - # one signal ``exhausted`` reduces to. + # Its own code, because the two neighbouring ones would both be false: ``no_match`` says a + # search found nothing and nothing was searched here, and ``unresolved_dispatch`` would + # implicate the call frontier, which is the one signal ``exhausted`` reduces to. ledger.append( Diagnostic( - code="no_match", + code="degenerate_pair", message=( f"{source!r} and {sink!r} name the same position within {a.callable!r}, so that pair is skipped rather than " f"searched; a value reaches itself only through recursion, which reaches({a.callable!r}, {a.callable!r}) answers" diff --git a/cldk/analysis/commons/results.py b/cldk/analysis/commons/results.py index ad8f45b2..c60166f7 100644 --- a/cldk/analysis/commons/results.py +++ b/cldk/analysis/commons/results.py @@ -63,8 +63,9 @@ class Diagnostic(BaseModel): populated**. E8 (leg 1.5) put typo-tolerant matching out of scope "not in the resolver, not in the error path", so nothing in the SDK constructs a ``Diagnostic`` with suggestions or with ``code="did_you_mean"``; the codes actually emitted are - ``file_not_in_graph``, ``module_scope``, ``module_source_unavailable`` and - ``entrypoint_report_unavailable``. The field and the code stay because both are part + ``file_not_in_graph``, ``module_scope``, ``module_source_unavailable``, + ``entrypoint_report_unavailable``, and — since ``taint()`` — ``unresolved_dispatch`` + and ``degenerate_pair``. The field and the code stay because both are part of a published model contract (``docs/agent-api-reference.md``) that a caller may already destructure; removing either is a separate, breaking change. """ @@ -82,6 +83,7 @@ class Diagnostic(BaseModel): "unresolved_dispatch", "graph_schema_mismatch", "entrypoint_report_unavailable", + "degenerate_pair", ] message: str suggestions: list[str] = [] @@ -671,9 +673,11 @@ class TaintResult(FlowPaths): not ``None``. roots: What each selector matched, so a conclusion is auditable rather than asserted. resolved: The human-readable form of ``roots``, via ``slice_resolved``. - unresolved: The frontier ledger. Each entry is a ``Diagnostic`` with ``code - ="unresolved_dispatch"`` (already in the closed vocabulary; no widening needed) and the - affected pair named in ``message`` prose — the same convention every other diagnostic in + unresolved: The ledger of everything that stopped a pair short of an answer, whatever stopped + it. Two codes reach it: ``unresolved_dispatch`` for a frontier the walk could not follow, + and ``degenerate_pair`` for a requested pair whose source and sink resolved to the same + position, which is skipped rather than searched. Both name the + affected pair in ``message`` prose — the same convention every other diagnostic in this SDK follows (e.g. the Neo4j backend's ``module_scope`` message). ``Diagnostic`` has no structured field for a pair today, so this is a human-readable explanation, not something to compute with: the pair→diagnostic association ``exhausted`` needs is tracked diff --git a/docs/agent-api-reference.md b/docs/agent-api-reference.md index 2ccfac31..578f69ff 100644 --- a/docs/agent-api-reference.md +++ b/docs/agent-api-reference.md @@ -930,6 +930,7 @@ Any accessor may attach these. They exist so an empty result is never ambiguous. | — | a scoping keyword naming nothing raises `SelectorNotInGraph`; it is an error, not a diagnostic | | `no_match`, `ambiguous`, `unknown_callable`, `unknown_param`, `did_you_mean` | declared in the `Diagnostic` code vocabulary, but **nothing emits them**: resolution failures are raised (`AmbiguousName` / `SelectorNotInGraph`), not attached, and `did_you_mean` in particular can never fire — E8 puts typo-tolerant matching out of scope in the error path as much as in the resolver | | `unresolved_dispatch` | an edge the traversal could not follow | +| `degenerate_pair` | a `taint()` pair whose source and sink resolved to the same position — skipped rather than searched, so it is in neither `paths` nor `exhausted` | **The rule behind all of them:** an empty result that could mean two things is a defect. When you get nothing back, check the diagnostics before concluding the answer is "no". diff --git a/tests/analysis/java/test_java_taint.py b/tests/analysis/java/test_java_taint.py index 163a51e9..598cb38c 100644 --- a/tests/analysis/java/test_java_taint.py +++ b/tests/analysis/java/test_java_taint.py @@ -164,6 +164,7 @@ def test_below_the_dataflow_level_the_diagnosis_is_the_level_and_not_the_port_la backend.taint([("orderID", CANCEL)], [("userID", SELL)]) assert "formal_in" not in str(raised.value), "the level is the diagnosis, not the port lattice" + def test_a_found_flow_carries_its_witnesses_the_roots_and_the_audit_line(): a, b = _value("in", HANDLE), _value("sql", STORE) backend = _Recording(rows=[(a.ref, b.ref, _witness(a, b))]) @@ -192,6 +193,7 @@ def test_the_same_position_pair_is_skipped_with_a_diagnostic_not_raised(): result = backend.taint([("in", HANDLE), ("msg", LOG)], [("in", HANDLE)]) assert result.paths == [] assert any("same position" in d.message for d in result.unresolved) + assert [d.code for d in result.unresolved] == ["degenerate_pair"], "its own code: nothing was searched, so no match failed" assert result.exhausted == [("msg", "in")], "the degenerate pair is skipped; the rest of the batch still answers" assert not result.complete @@ -220,6 +222,7 @@ def test_a_ledger_entry_no_requested_pair_claims_is_still_reported(): assert result.exhausted == [], "nothing can attribute a stray key to a pair, so no pair is certified" assert not result.complete + def test_paths_are_trimmed_per_pair_and_completeness_says_the_cap_fired(): """The walk caps each pair at ``max_paths + 1``, so the extra row reports truncation without a second counting traversal -- and the trim is per pair, so a prolific pair cannot starve a @@ -247,6 +250,7 @@ def test_two_selectors_that_resolve_to_the_same_position_are_one_pair(): assert len(capped.paths) == 2 and not capped.complete, "the cap holds per distinct pair" assert once.exhausted == [] and capped.exhausted == [] + def test_the_sanitizer_selectors_are_resolved_through_the_edge_vars_hook(): """Step 4 of the order: the two shapes reach the walk as ``$cuts`` and ``$cut_callables``, and a variable selector is checked against the vars on real SDG edges rather than ``resolve_value``, diff --git a/tests/analysis/python/test_python_taint.py b/tests/analysis/python/test_python_taint.py index 68b95f4e..0ce7fea7 100644 --- a/tests/analysis/python/test_python_taint.py +++ b/tests/analysis/python/test_python_taint.py @@ -134,6 +134,7 @@ def test_the_same_position_pair_is_skipped_with_a_diagnostic_not_raised(): result = backend.taint([("x", "f"), ("y", "h")], [("x", "f")]) assert result.paths == [] assert any("same position" in d.message for d in result.unresolved) + assert [d.code for d in result.unresolved] == ["degenerate_pair"], "its own code: nothing was searched, so no match failed" assert result.exhausted == [("y", "x")], "the degenerate pair is skipped; the rest of the batch still answers" assert not result.complete @@ -162,6 +163,7 @@ def test_a_ledger_entry_no_requested_pair_claims_is_still_reported(): assert result.exhausted == [], "nothing can attribute a stray key to a pair, so no pair is certified" assert not result.complete + def test_paths_are_trimmed_per_pair_and_completeness_says_the_cap_fired(): """The walk caps each pair at ``max_paths + 1``, so the extra row reports truncation without a second counting traversal -- and the trim is per pair, so a prolific pair cannot starve a @@ -189,6 +191,7 @@ def test_two_selectors_that_resolve_to_the_same_position_are_one_pair(): assert len(capped.paths) == 2 and not capped.complete, "the cap holds per distinct pair" assert once.exhausted == [] and capped.exhausted == [] + def test_the_sanitizer_selectors_are_resolved_through_the_edge_vars_hook(): """Step 4 of the order: the two shapes reach the walk as ``$cuts`` and ``$cut_callables``, and a variable selector is checked against the vars on real SDG edges rather than ``resolve_value``, diff --git a/tests/analysis/typescript/test_typescript_taint.py b/tests/analysis/typescript/test_typescript_taint.py index 260ea95b..cbff5fca 100644 --- a/tests/analysis/typescript/test_typescript_taint.py +++ b/tests/analysis/typescript/test_typescript_taint.py @@ -134,6 +134,7 @@ def test_the_same_position_pair_is_skipped_with_a_diagnostic_not_raised(): result = backend.taint([("x", "f"), ("y", "h")], [("x", "f")]) assert result.paths == [] assert any("same position" in d.message for d in result.unresolved) + assert [d.code for d in result.unresolved] == ["degenerate_pair"], "its own code: nothing was searched, so no match failed" assert result.exhausted == [("y", "x")], "the degenerate pair is skipped; the rest of the batch still answers" assert not result.complete @@ -162,6 +163,7 @@ def test_a_ledger_entry_no_requested_pair_claims_is_still_reported(): assert result.exhausted == [], "nothing can attribute a stray key to a pair, so no pair is certified" assert not result.complete + def test_paths_are_trimmed_per_pair_and_completeness_says_the_cap_fired(): """The walk caps each pair at ``max_paths + 1``, so the extra row reports truncation without a second counting traversal -- and the trim is per pair, so a prolific pair cannot starve a @@ -189,6 +191,7 @@ def test_two_selectors_that_resolve_to_the_same_position_are_one_pair(): assert len(capped.paths) == 2 and not capped.complete, "the cap holds per distinct pair" assert once.exhausted == [] and capped.exhausted == [] + def test_the_sanitizer_selectors_are_resolved_through_the_edge_vars_hook(): """Step 4 of the order: the two shapes reach the walk as ``$cuts`` and ``$cut_callables``, and a variable selector is checked against the vars on real SDG edges rather than ``resolve_value``, From c414c6ac8a535d9831131cfc444d19d39073bab3 Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Thu, 10 Sep 2026 06:34:03 -0400 Subject: [PATCH 29/65] docs(taint): complete is the batch's flag, said once instead of three ways The code -- ``not truncated and not ledger`` -- is the conservative choice and stays: reporting ``True`` beside a non-empty ledger would tell a caller who checks only the flag that a batch was fully searched when part of it was not. What was wrong is every text describing it. ``FlowPaths.complete`` says truncation is the only way to be ``False``, ``TaintResult`` claimed "nothing is redefined", and ``taint()``'s own Returns said "nothing truncated and no pair skipped", which omits the blocked pair. Their disagreement has a caller-visible cost: on a clean batch whose only irregularity is one degenerate pair, ``complete`` is ``False``, and the inherited text sends that caller to re-run with a bigger ``max_paths`` for the same answer. So each text now says the same thing, and the two properties a caller has to predict -- one ledger entry anywhere flips the batch, a bigger cap does not unflip it -- are pinned by a test rather than left to the prose. --- cldk/analysis/commons/results.py | 16 +++++++++++++--- cldk/analysis/java/backend.py | 7 +++++-- cldk/analysis/python/backend.py | 7 +++++-- cldk/analysis/typescript/backend.py | 7 +++++-- tests/analysis/java/test_java_taint.py | 15 +++++++++++++++ tests/analysis/python/test_python_taint.py | 15 +++++++++++++++ .../analysis/typescript/test_typescript_taint.py | 15 +++++++++++++++ 7 files changed, 73 insertions(+), 9 deletions(-) diff --git a/cldk/analysis/commons/results.py b/cldk/analysis/commons/results.py index c60166f7..ac6bbcd7 100644 --- a/cldk/analysis/commons/results.py +++ b/cldk/analysis/commons/results.py @@ -634,7 +634,9 @@ class FlowPaths(BoundedResult): Attributes: paths: The paths, in :func:`~cldk.analysis.python.backend.hop_sort_key` order. complete: ``False`` when ``max_paths`` cut the list; ``True`` when these are all the - shortest paths there are (including when there are none). + shortest paths there are (including when there are none). :class:`TaintResult` narrows + it -- there, truncation is only one of the ways it can be ``False`` -- so on a taint + verdict, ``False`` is not on its own a reason to raise the cap and ask again. """ paths: list[FlowPath] @@ -649,8 +651,16 @@ class TaintResult(FlowPaths): stopped the rest. A subclass of :class:`FlowPaths` rather than a new shape: the witnesses *are* flow paths, each - already carrying ``weakest``, and ``complete`` already means "were all the witnesses returned". - Three fields are added and nothing is redefined. + already carrying ``weakest``, and ``complete`` still answers "did this call return everything it + found". Three fields are added, and ``complete`` is narrowed. + + **What ``complete`` says here.** ``True`` only when nothing was truncated **and** + :attr:`unresolved` is empty. It is one flag for the whole batch, so a single skipped or blocked + pair makes it ``False`` however cleanly the other pairs answered -- deliberately, because the + alternative is ``True`` beside a non-empty ledger, which tells a caller who reads only the flag + that the batch was fully searched when part of it was not. The consequence to know: ``False`` + does not mean "raise ``max_paths`` and ask again". Where ``paths`` was not truncated a bigger cap + returns the same flag, and :attr:`unresolved` is what says why. **What ``exhausted`` claims.** A pair is listed when all three hold: the call passed ``depth=None``, the search found no path for it, and no :attr:`unresolved` diagnostic implicates diff --git a/cldk/analysis/java/backend.py b/cldk/analysis/java/backend.py index f4f3ec88..199ca04d 100644 --- a/cldk/analysis/java/backend.py +++ b/cldk/analysis/java/backend.py @@ -2076,8 +2076,11 @@ def taint( Returns: A :class:`~cldk.analysis.commons.results.TaintResult`: ``paths`` are the witnesses, ``exhausted`` the pairs searched to exhaustion with a clean ledger, ``roots`` and - ``resolved`` what every name matched, ``unresolved`` the ledger, and ``complete`` is - ``True`` only when nothing was truncated and no pair was skipped. A pair is named in + ``resolved`` what every name matched, ``unresolved`` the ledger, and ``complete`` is the + whole batch's flag: ``True`` only when nothing was truncated **and** the ledger is empty, + so one skipped or blocked pair makes it ``False`` however cleanly the rest answered -- + and where nothing was truncated, a bigger ``max_paths`` returns that same ``False``. A + pair is named in ``exhausted`` by the two strings the caller passed, so two sources sharing a name in different callables read as one pair there -- ``roots`` is what tells them apart. diff --git a/cldk/analysis/python/backend.py b/cldk/analysis/python/backend.py index ab406999..5f82a9e7 100644 --- a/cldk/analysis/python/backend.py +++ b/cldk/analysis/python/backend.py @@ -1110,8 +1110,11 @@ def taint( Returns: A :class:`~cldk.analysis.commons.results.TaintResult`: ``paths`` are the witnesses, ``exhausted`` the pairs searched to exhaustion with a clean ledger, ``roots`` and - ``resolved`` what every name matched, ``unresolved`` the ledger, and ``complete`` is - ``True`` only when nothing was truncated and no pair was skipped. A pair is named in + ``resolved`` what every name matched, ``unresolved`` the ledger, and ``complete`` is the + whole batch's flag: ``True`` only when nothing was truncated **and** the ledger is empty, + so one skipped or blocked pair makes it ``False`` however cleanly the rest answered -- + and where nothing was truncated, a bigger ``max_paths`` returns that same ``False``. A + pair is named in ``exhausted`` by the two strings the caller passed, so two sources sharing a name in different callables read as one pair there -- ``roots`` is what tells them apart. diff --git a/cldk/analysis/typescript/backend.py b/cldk/analysis/typescript/backend.py index 7f735247..b82a5c3b 100644 --- a/cldk/analysis/typescript/backend.py +++ b/cldk/analysis/typescript/backend.py @@ -986,8 +986,11 @@ def taint( Returns: A :class:`~cldk.analysis.commons.results.TaintResult`: ``paths`` are the witnesses, ``exhausted`` the pairs searched to exhaustion with a clean ledger, ``roots`` and - ``resolved`` what every name matched, ``unresolved`` the ledger, and ``complete`` is - ``True`` only when nothing was truncated and no pair was skipped. A pair is named in + ``resolved`` what every name matched, ``unresolved`` the ledger, and ``complete`` is the + whole batch's flag: ``True`` only when nothing was truncated **and** the ledger is empty, + so one skipped or blocked pair makes it ``False`` however cleanly the rest answered -- + and where nothing was truncated, a bigger ``max_paths`` returns that same ``False``. A + pair is named in ``exhausted`` by the two strings the caller passed, so two sources sharing a name in different callables read as one pair there -- ``roots`` is what tells them apart. diff --git a/tests/analysis/java/test_java_taint.py b/tests/analysis/java/test_java_taint.py index 598cb38c..0fba4ec0 100644 --- a/tests/analysis/java/test_java_taint.py +++ b/tests/analysis/java/test_java_taint.py @@ -268,6 +268,21 @@ def test_a_sanitizer_that_does_not_resolve_raises_before_the_walk(): assert backend.walks == [], "sanitizers are resolved before the traversal, not applied after it" +def test_complete_is_the_batch_flag_so_one_skipped_pair_flips_it_and_a_bigger_cap_will_not_help(): + """``complete`` is ``not truncated and not ledger``: the whole batch's flag, not the trim's, so an + otherwise clean batch whose only irregularity is one degenerate pair answers ``False``. A caller + reading ``FlowPaths.complete``'s inherited text would re-run with a bigger ``max_paths`` and get + the same flag, so the two are pinned together here -- nothing was truncated, so nothing about the + cap can change it, and ``unresolved`` is where the reason is.""" + a, b = _value("in", HANDLE), _value("sql", STORE) + rows = [(a.ref, b.ref, _witness(a, b))] + result = _Recording(rows=rows).taint([("in", HANDLE), ("sql", STORE)], [("sql", STORE)]) + assert len(result.paths) == 1 and not result.complete + assert [d.code for d in result.unresolved] == ["degenerate_pair"] + bigger = _Recording(rows=rows).taint([("in", HANDLE), ("sql", STORE)], [("sql", STORE)], max_paths=99) + assert len(bigger.paths) == 1 and not bigger.complete, "the cap never fired, so raising it answers the same" + + def test_the_two_walk_hooks_are_stubs_rather_than_abstract_methods(): """Ruling G: an abstract method here would make every concrete backend un-instantiable until the last implementation lands, so they raise instead. Task 7 flips them, and this test is what says diff --git a/tests/analysis/python/test_python_taint.py b/tests/analysis/python/test_python_taint.py index 0ce7fea7..8e099865 100644 --- a/tests/analysis/python/test_python_taint.py +++ b/tests/analysis/python/test_python_taint.py @@ -209,6 +209,21 @@ def test_a_sanitizer_that_does_not_resolve_raises_before_the_walk(): assert backend.walks == [], "sanitizers are resolved before the traversal, not applied after it" +def test_complete_is_the_batch_flag_so_one_skipped_pair_flips_it_and_a_bigger_cap_will_not_help(): + """``complete`` is ``not truncated and not ledger``: the whole batch's flag, not the trim's, so an + otherwise clean batch whose only irregularity is one degenerate pair answers ``False``. A caller + reading ``FlowPaths.complete``'s inherited text would re-run with a bigger ``max_paths`` and get + the same flag, so the two are pinned together here -- nothing was truncated, so nothing about the + cap can change it, and ``unresolved`` is where the reason is.""" + a, b = _value("x", "f"), _value("y", "g") + rows = [(a.ref, b.ref, _witness(a, b))] + result = _Recording(rows=rows).taint([("x", "f"), ("y", "g")], [("y", "g")]) + assert len(result.paths) == 1 and not result.complete + assert [d.code for d in result.unresolved] == ["degenerate_pair"] + bigger = _Recording(rows=rows).taint([("x", "f"), ("y", "g")], [("y", "g")], max_paths=99) + assert len(bigger.paths) == 1 and not bigger.complete, "the cap never fired, so raising it answers the same" + + def test_the_two_walk_hooks_are_stubs_rather_than_abstract_methods(): """Ruling G: an abstract method here would make every concrete backend un-instantiable until the last implementation lands, so they raise instead. Task 7 flips them, and this test is what says diff --git a/tests/analysis/typescript/test_typescript_taint.py b/tests/analysis/typescript/test_typescript_taint.py index cbff5fca..fc1b898c 100644 --- a/tests/analysis/typescript/test_typescript_taint.py +++ b/tests/analysis/typescript/test_typescript_taint.py @@ -209,6 +209,21 @@ def test_a_sanitizer_that_does_not_resolve_raises_before_the_walk(): assert backend.walks == [], "sanitizers are resolved before the traversal, not applied after it" +def test_complete_is_the_batch_flag_so_one_skipped_pair_flips_it_and_a_bigger_cap_will_not_help(): + """``complete`` is ``not truncated and not ledger``: the whole batch's flag, not the trim's, so an + otherwise clean batch whose only irregularity is one degenerate pair answers ``False``. A caller + reading ``FlowPaths.complete``'s inherited text would re-run with a bigger ``max_paths`` and get + the same flag, so the two are pinned together here -- nothing was truncated, so nothing about the + cap can change it, and ``unresolved`` is where the reason is.""" + a, b = _value("x", "f"), _value("y", "g") + rows = [(a.ref, b.ref, _witness(a, b))] + result = _Recording(rows=rows).taint([("x", "f"), ("y", "g")], [("y", "g")]) + assert len(result.paths) == 1 and not result.complete + assert [d.code for d in result.unresolved] == ["degenerate_pair"] + bigger = _Recording(rows=rows).taint([("x", "f"), ("y", "g")], [("y", "g")], max_paths=99) + assert len(bigger.paths) == 1 and not bigger.complete, "the cap never fired, so raising it answers the same" + + def test_the_two_walk_hooks_are_stubs_rather_than_abstract_methods(): """Ruling G: an abstract method here would make every concrete backend un-instantiable until the last implementation lands, so they raise instead. Task 7 flips them, and this test is what says From f8a1b24662f1ce172eca75a8f73f073085f65223 Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Thu, 10 Sep 2026 06:40:32 -0400 Subject: [PATCH 30/65] docs(java): the walk docstring stops claiming a gate taint already asked, and taint is the fifth Three counts and one paragraph were left behind by this leg. Java's ``_taint_walk`` docstring is Python's verbatim and ends by telling its implementer to open with ``self._require_dataflow()`` "because the graph backends do not measure the analysis level" -- false for Java since the gate moved onto ``taint()`` earlier in this round, and it is the exact prose Task 7 will read when placing Java's walk. It now says which two gates ``taint()`` already opened, so neither is asked twice. ``taint`` also joined the accessors that refuse on a disconnected port lattice, which makes it the fifth: the ``PORTS_DISCONNECTED`` commentary, the ``_PORTS_CARRY_DEPENDENCE`` probe's cost note and the invariants block all said four, and the probe's note is what tells a reader when the 6.5 ms is paid. Two tests close the holes a mutation sweep found: swapping the ``resolve_value`` loops with the ``resolve_sanitizers`` call passed all eleven tests, and so did dropping ``roots``'s dedup to ``[*srcs, *dsts]``. Both mutations now fail. The first is the order the docstring asserts -- a typo in a source must not be reported as a sanitizer problem, and a sanitizer must not be checked against the edges of a callable whose name has not been judged. --- cldk/analysis/java/backend.py | 23 +++++++------- cldk/analysis/java/neo4j/neo4j_backend.py | 4 +-- tests/analysis/java/test_java_taint.py | 30 +++++++++++++++++++ tests/analysis/python/test_python_taint.py | 30 +++++++++++++++++++ .../typescript/test_typescript_taint.py | 30 +++++++++++++++++++ 5 files changed, 105 insertions(+), 12 deletions(-) diff --git a/cldk/analysis/java/backend.py b/cldk/analysis/java/backend.py index 199ca04d..7191ffef 100644 --- a/cldk/analysis/java/backend.py +++ b/cldk/analysis/java/backend.py @@ -238,19 +238,20 @@ def java_body_node_kind(node_id: str, kind: str, parameters: Sequence[JCallableP return kind, None -#: Why the four forward value accessors refuse (D7), **when they do**. Up to codeanalyzer-java +#: Why the five forward value accessors refuse (D7), **when they do**. Up to codeanalyzer-java #: 3.0.2 the L4 port lattice was emitted *disconnected* from the statement dependence graph: not #: one of the reference graph's 134,742 ``J_DDG`` and 46,936 ``J_CDG`` edges had a #: :data:`PORT_KINDS` vertex at either end, so a ``formal_in`` — the only thing #: :meth:`JavaAnalysisBackend.resolve_value` ever returns — had **out-degree zero** and every #: forward traversal seeded on one ended where it started. That made ``flows_to_call`` and -#: ``flows_to_argument`` ``False`` for every input, ``paths_between`` empty for every input and -#: ``slice_forward`` the seed alone — each indistinguishable from a proved absence of flow, which -#: is exactly the ambiguous empty D7 forbids. They raise instead. +#: ``flows_to_argument`` ``False`` for every input, ``paths_between`` empty for every input, +#: ``slice_forward`` the seed alone and ``taint`` every requested pair refuted — each +#: indistinguishable from a proved absence of flow, which is exactly the ambiguous empty D7 +#: forbids. They raise instead. #: #: **codeanalyzer-java 3.0.3 joins the two layers** (codeanalyzer-java#227): ``@formal_in:k → use``, #: ``return → @formal_out``, ``statement → /actual_in:i`` and ``/actual_out → -#: statement``. On output from 3.0.3 the probe below answers ``True`` and all four accessors +#: statement``. On output from 3.0.3 the probe below answers ``True`` and all five accessors #: answer, with no change here — which is the point of asking the *data* rather than the analyzer #: version. The refusal is kept because it can still fire honestly: the Neo4j floor is 3.0.1, so a #: graph emitted by 3.0.1 or 3.0.2 is still attachable, and ``--l3-engine wala`` leaves @@ -1517,9 +1518,9 @@ def get_calling_lines(self, target_method_name: str) -> List[int]: # ONE COMPLETENESS PROTOCOL. Truncation is reported by ``complete`` on ``EdgePage`` / ``Slice`` # / ``FlowPaths``, never by silently returning less. # - # FOUR ACCESSORS REFUSE ON A DISCONNECTED PORT LATTICE RATHER THAN ANSWERING A CONSTANT. + # FIVE ACCESSORS REFUSE ON A DISCONNECTED PORT LATTICE RATHER THAN ANSWERING A CONSTANT. # Asked of the analysis, never of the analyzer version: codeanalyzer-java joins the port lattice - # to the statement graph from 3.0.3, and on such output all four answer with no change here. + # to the statement graph from 3.0.3, and on such output all five answer with no change here. # See :data:`PORTS_DISCONNECTED`. # ===================================================================================== @property @@ -2149,9 +2150,11 @@ def _taint_walk( is what keeps the verdict as precise as the walk's own knowledge; the signature cannot say so, which is why it is said here. - A local backend opens with ``self._require_dataflow()``: the graph backends do not measure - the analysis level (their attach probe never looks at the dependence relationships), so the - gate lives in the implementations that can answer rather than in :meth:`taint`. + **The level gate is not this method's**, unlike Python's and TypeScript's walks: Java has + :meth:`_require_dataflow` on the ABC, so :meth:`taint` asks it before the walk is ever + entered and an implementation that asked again would only be answering a question already + answered. Nor is the port-lattice gate: :meth:`taint` opens + :meth:`_require_connected_ports` too, in the same place the five sibling flow accessors do. A stub rather than an ``@abstractmethod`` while the implementations land, so a backend without one is refused when it is *called* rather than when it is constructed. diff --git a/cldk/analysis/java/neo4j/neo4j_backend.py b/cldk/analysis/java/neo4j/neo4j_backend.py index 03556d6d..0f32cd7b 100644 --- a/cldk/analysis/java/neo4j/neo4j_backend.py +++ b/cldk/analysis/java/neo4j/neo4j_backend.py @@ -953,9 +953,9 @@ def _value_reaches(self, src: str, dsts: Sequence[str], depth: int | None) -> bo return bool(self._run(query, src=src, dsts=[d for d in dsts if d != src], prefix=self._scope_prefix)[0]["ok"]) #: Whether this application's parameter vertices have any outgoing SDG edge — the measurement - #: the four forward value accessors refuse on + #: the five forward value accessors refuse on #: (:data:`~cldk.analysis.java.backend.PORTS_DISCONNECTED`). Costs 6.5 ms on daytrader8 and - #: 185.4 ms on ThingsBoard, once per backend, and only when one of those four is called: the + #: 185.4 ms on ThingsBoard, once per backend, and only when one of those five is called: the #: "no" answer is the expensive one, because it has to look at every ``formal_in``. _PORTS_CARRY_DEPENDENCE = ( "MATCH (b:JBodyNode)-[r:J_DDG|J_CDG|J_PARAM_IN|J_PARAM_OUT|J_SUMMARY]->(m:JBodyNode) " diff --git a/tests/analysis/java/test_java_taint.py b/tests/analysis/java/test_java_taint.py index 0fba4ec0..185ed66e 100644 --- a/tests/analysis/java/test_java_taint.py +++ b/tests/analysis/java/test_java_taint.py @@ -283,6 +283,36 @@ def test_complete_is_the_batch_flag_so_one_skipped_pair_flips_it_and_a_bigger_ca assert len(bigger.paths) == 1 and not bigger.complete, "the cap never fired, so raising it answers the same" +def test_the_names_are_resolved_before_the_sanitizers(): + """Step 3 before step 4 of the ordered contract, which the docstring asserts and nothing pinned: + swapping the two ``resolve_value`` loops with the ``resolve_sanitizers`` call passed the whole + suite. It matters because a caller with a typo in a *source* would be told about their + **sanitizer** instead -- and worse, a sanitizer selector is checked against the SDG edges of a + callable whose own name has not been judged yet.""" + backend = _Recording() + reached = [] + + def _refuse(name, *, within): + raise SelectorNotInGraph("value", [name], 1, detail=f"relative to within={within!r}") + + backend.resolve_value = _refuse + backend._edge_vars_in = lambda callable_id: reached.append(callable_id) or set() + with pytest.raises(SelectorNotInGraph): + backend.taint([("in", HANDLE)], [("sql", STORE)], sanitizers=[("answer", HANDLE)]) + assert reached == [], "the sanitizer hook is not touched until every name has resolved" + + +def test_roots_are_deduplicated_by_ref_so_one_position_is_audited_once(): + """``roots`` is the audit line a caller reads a verdict against, and a duplicated selector must + not make a position appear twice in it -- ``resolved`` is built from it, so the repetition would + be visible in the sentence a report quotes. Unpinned until now: dropping the dedup to + ``[*srcs, *dsts]`` passed the whole suite.""" + a, b = _value("in", HANDLE), _value("sql", STORE) + result = _Recording(rows=[(a.ref, b.ref, _witness(a, b))]).taint([("in", HANDLE), ("in", HANDLE)], [("sql", STORE)]) + assert [n.ref for n in result.roots] == [a.ref, b.ref], "two selectors, one position, one root" + assert result.resolved == f"{HANDLE} parameter 'in', {STORE} parameter 'sql'" + + def test_the_two_walk_hooks_are_stubs_rather_than_abstract_methods(): """Ruling G: an abstract method here would make every concrete backend un-instantiable until the last implementation lands, so they raise instead. Task 7 flips them, and this test is what says diff --git a/tests/analysis/python/test_python_taint.py b/tests/analysis/python/test_python_taint.py index 8e099865..27914d86 100644 --- a/tests/analysis/python/test_python_taint.py +++ b/tests/analysis/python/test_python_taint.py @@ -224,6 +224,36 @@ def test_complete_is_the_batch_flag_so_one_skipped_pair_flips_it_and_a_bigger_ca assert len(bigger.paths) == 1 and not bigger.complete, "the cap never fired, so raising it answers the same" +def test_the_names_are_resolved_before_the_sanitizers(): + """Step 3 before step 4 of the ordered contract, which the docstring asserts and nothing pinned: + swapping the two ``resolve_value`` loops with the ``resolve_sanitizers`` call passed the whole + suite. It matters because a caller with a typo in a *source* would be told about their + **sanitizer** instead -- and worse, a sanitizer selector is checked against the SDG edges of a + callable whose own name has not been judged yet.""" + backend = _Recording() + reached = [] + + def _refuse(name, *, within): + raise SelectorNotInGraph("value", [name], 1, detail=f"relative to within={within!r}") + + backend.resolve_value = _refuse + backend._edge_vars_in = lambda callable_id: reached.append(callable_id) or set() + with pytest.raises(SelectorNotInGraph): + backend.taint([("x", "f")], [("y", "g")], sanitizers=[("answer", "f")]) + assert reached == [], "the sanitizer hook is not touched until every name has resolved" + + +def test_roots_are_deduplicated_by_ref_so_one_position_is_audited_once(): + """``roots`` is the audit line a caller reads a verdict against, and a duplicated selector must + not make a position appear twice in it -- ``resolved`` is built from it, so the repetition would + be visible in the sentence a report quotes. Unpinned until now: dropping the dedup to + ``[*srcs, *dsts]`` passed the whole suite.""" + a, b = _value("x", "f"), _value("y", "g") + result = _Recording(rows=[(a.ref, b.ref, _witness(a, b))]).taint([("x", "f"), ("x", "f")], [("y", "g")]) + assert [n.ref for n in result.roots] == [a.ref, b.ref], "two selectors, one position, one root" + assert result.resolved == "f parameter 'x', g parameter 'y'" + + def test_the_two_walk_hooks_are_stubs_rather_than_abstract_methods(): """Ruling G: an abstract method here would make every concrete backend un-instantiable until the last implementation lands, so they raise instead. Task 7 flips them, and this test is what says diff --git a/tests/analysis/typescript/test_typescript_taint.py b/tests/analysis/typescript/test_typescript_taint.py index fc1b898c..cb55f113 100644 --- a/tests/analysis/typescript/test_typescript_taint.py +++ b/tests/analysis/typescript/test_typescript_taint.py @@ -224,6 +224,36 @@ def test_complete_is_the_batch_flag_so_one_skipped_pair_flips_it_and_a_bigger_ca assert len(bigger.paths) == 1 and not bigger.complete, "the cap never fired, so raising it answers the same" +def test_the_names_are_resolved_before_the_sanitizers(): + """Step 3 before step 4 of the ordered contract, which the docstring asserts and nothing pinned: + swapping the two ``resolve_value`` loops with the ``resolve_sanitizers`` call passed the whole + suite. It matters because a caller with a typo in a *source* would be told about their + **sanitizer** instead -- and worse, a sanitizer selector is checked against the SDG edges of a + callable whose own name has not been judged yet.""" + backend = _Recording() + reached = [] + + def _refuse(name, *, within): + raise SelectorNotInGraph("value", [name], 1, detail=f"relative to within={within!r}") + + backend.resolve_value = _refuse + backend._edge_vars_in = lambda callable_id: reached.append(callable_id) or set() + with pytest.raises(SelectorNotInGraph): + backend.taint([("x", "f")], [("y", "g")], sanitizers=[("answer", "f")]) + assert reached == [], "the sanitizer hook is not touched until every name has resolved" + + +def test_roots_are_deduplicated_by_ref_so_one_position_is_audited_once(): + """``roots`` is the audit line a caller reads a verdict against, and a duplicated selector must + not make a position appear twice in it -- ``resolved`` is built from it, so the repetition would + be visible in the sentence a report quotes. Unpinned until now: dropping the dedup to + ``[*srcs, *dsts]`` passed the whole suite.""" + a, b = _value("x", "f"), _value("y", "g") + result = _Recording(rows=[(a.ref, b.ref, _witness(a, b))]).taint([("x", "f"), ("x", "f")], [("y", "g")]) + assert [n.ref for n in result.roots] == [a.ref, b.ref], "two selectors, one position, one root" + assert result.resolved == "f parameter 'x', g parameter 'y'" + + def test_the_two_walk_hooks_are_stubs_rather_than_abstract_methods(): """Ruling G: an abstract method here would make every concrete backend un-instantiable until the last implementation lands, so they raise instead. Task 7 flips them, and this test is what says From 21622e6bb163f035bd567ddccfddef1f860d1761 Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Thu, 10 Sep 2026 07:21:11 -0400 Subject: [PATCH 31/65] feat(py-neo4j): _taint_walk, one traversal for m sources x n sinks _TAINT is sdg_taint_query("PY", ...) with the same node_label and projection _PATHS uses, so a taint row describes its nodes exactly as a slice does. The walk binds srcs/dsts as id lists, the resolved {var, prefix} cuts and the can:// callable cuts, cap = max_paths + 1 and the application prefix; the cap is per pair because the collect is grouped by (a, b), and the one row past max_paths is what lets taint() report truncation without a second traversal. _EDGE_VARS answers _edge_vars_in, which resolve_sanitizers validates a variable selector against (Ruling A: resolve_value addresses formal_in parameters only, so a real PY_DDG var that is not a parameter would be told it does not exist). Its $prefix is a callable's ref, narrower than the application prefix and stamped by the same construction. The ledger comes back empty, and the docstring says why rather than guessing: an unresolved dispatch emits no dependence edge at all, so the frontier is an absence with no key to file under, and filing the :PyExternal ghosts instead would empty exhausted for every application (Ruling I). The consequence is stated where a caller reads it -- a pair whose flow leaves through an unresolved call is certified exhausted. The two new statements and their two inline sites join the multi-application scope audit. --- cldk/analysis/python/neo4j/neo4j_backend.py | 76 ++++++++++++++++++- .../test_neo4j_multi_application_scope.py | 2 +- 2 files changed, 76 insertions(+), 2 deletions(-) diff --git a/cldk/analysis/python/neo4j/neo4j_backend.py b/cldk/analysis/python/neo4j/neo4j_backend.py index 0c217527..3b1aad40 100644 --- a/cldk/analysis/python/neo4j/neo4j_backend.py +++ b/cldk/analysis/python/neo4j/neo4j_backend.py @@ -97,7 +97,7 @@ from codeanalyzer.schema.py_schema import PyEntrypointReport from cldk.analysis.commons.backend import semver as _semver -from cldk.analysis.commons.graphs import path_order, sdg_path_query +from cldk.analysis.commons.graphs import path_order, sdg_path_query, sdg_taint_query from cldk.analysis.commons.keys import module_key_of from cldk.analysis.commons.resolve import CallableCandidate, body_node_kind, resolve_callable_signature, resolve_value_name, resolve_within, value_candidate from cldk.analysis.commons.results import BodyRef, CallableRef, Diagnostic, EdgePage, EntrypointCoverage, FlowPath, FlowPaths, LocateResult, ModuleRef, PathHop, Slice, SliceNode, TypeRef @@ -1642,6 +1642,80 @@ def paths_between(self, src: str, dst: str, *, src_within: str, dst_within: str, b = self.resolve_value(dst, within=dst_within) return self._paths(self._PATHS, _slice_node, a, b, src=a.ref, dst=b.ref, depth=depth, max_paths=max_paths) + #: ``taint()``'s statement: the same shortest-path search as :attr:`_PATHS`, but m sources + #: against n sinks in one traversal, with the sanitizer cut **inside** the pattern and the cap + #: applied per pair. Everything that differs from :attr:`_PATHS` is argued in + #: :func:`~cldk.analysis.commons.graphs.sdg_taint_query`'s own docstring; the two share this + #: backend's ``node_label`` and projection verbatim, which is what makes a taint witness and a + #: ``paths_between`` witness describe a node identically. + _TAINT = sdg_taint_query( + "PY", + node_label="PyBodyNode", + projection="ref: n.id, kind: n.kind, var: n.var, line: n.start_line, " + "callable: head([(c:PyCallable)-[:PY_HAS_BODY_NODE]->(n) | c.signature]), " + "c_line: head([(c:PyCallable)-[:PY_HAS_BODY_NODE]->(n) | c.start_line])", + ) + + #: The variable names on SDG edges *leaving* a node inside ``$prefix`` -- ``startNode``, matching + #: :attr:`_TAINT`'s cut predicate exactly, so a sanitizer this validates is one that predicate + #: can actually match (Ruling A / :func:`~cldk.analysis.commons.resolve.resolve_sanitizers`). + #: + #: ``$prefix`` here is a **callable's** ``can://`` ref, not the application's: narrower than the + #: usual scope and application-stamped by the same construction, since a callable id embeds the + #: application. One round trip per variable sanitizer, which is as often as a caller writes one. + _EDGE_VARS = "MATCH (n:PyBodyNode)-[r:{rels}]->() WHERE n.id STARTS WITH $prefix RETURN collect(DISTINCT r.var) AS vars" + + def _taint_walk(self, srcs, dsts, *, cuts, cut_callables, depth, max_paths): + """The sanitized shortest walks, server-side (see :meth:`PythonAnalysisBackend._taint_walk`). + + One statement for the whole batch, and one row per witness -- ``a.id AS src`` / ``b.id AS + dst`` carry the pairing back, because the m*n batching is only useful if the grouping + survives it. Ordering, the per-pair ``$cap`` and both cuts are the statement's + (:func:`~cldk.analysis.commons.graphs.sdg_taint_query`), so nothing is re-sorted or + re-filtered here: a Python-side filter is the false-refutation bug that function exists to + avoid, and a Python-side sort would silently disagree with + :func:`~cldk.analysis.commons.graphs.path_order`. + + **The ledger comes back empty, and that is a measured limitation rather than a shortcut.** + An unresolved dispatch is not observable from inside this walk: when the analyzer cannot + resolve a call it emits no ``PY_PARAM_IN``/``PY_PARAM_OUT`` for it at all, so the frontier + is an *absence* of edges, indistinguishable here from a call that genuinely passes nothing + tainted. The graph's ``:PyExternal`` ghosts are the opposite case -- a call resolved *to* + something outside the project -- and reporting those as frontier findings would file a + diagnostic against every pair in any application that calls a library function, emptying + ``exhausted`` for all of them (Ruling I) and destroying the refutation this accessor is for. + Consequence, stated because nothing here can catch it: a pair whose flow leaves through an + unresolved call is certified ``exhausted``. That is what leg 4b's corpus check is for. + """ + rows = self._run( + self._TAINT.format(rels=SDG_REL_PATTERN, depth="" if depth is None else depth), + srcs=[n.ref for n in srcs], + dsts=[n.ref for n in dsts], + cuts=cuts, + cut_callables=cut_callables, + cap=max_paths + 1, + prefix=self._scope_prefix, + ) + return [ + ( + r["src"], + r["dst"], + flow_path([_slice_node(n, self._module_key) for n in r["ns"]], [(e["via"], e["var"], e["prov"]) for e in r["rs"]], via=VIA), + ) + for r in rows + ], {} + + def _edge_vars_in(self, callable_id: str) -> FrozenSet[str]: + """The edge variables scoped to this callable (see :meth:`PythonAnalysisBackend._edge_vars_in`). + + ``r.var`` is absent on four of the five relationship types, so the collected list carries + ``None``; it is dropped rather than kept, because a membership test against a set holding + ``None`` would be answering a question no caller can ask -- ``resolve_sanitizers`` refuses a + blank variable before it gets here. + """ + rows = self._run(self._EDGE_VARS.format(rels=SDG_REL_PATTERN), prefix=callable_id) + return frozenset(v for v in rows[0]["vars"] if v) + def call_paths_between(self, src: str, dst: str, *, depth: int | None = None, max_paths: int = DEFAULT_MAX_PATHS) -> FlowPaths: """How one callable reaches another (see :meth:`PythonAnalysisBackend.call_paths_between`).""" check_depth(depth) diff --git a/tests/analysis/python/test_neo4j_multi_application_scope.py b/tests/analysis/python/test_neo4j_multi_application_scope.py index 9f71d1eb..3ae675c4 100644 --- a/tests/analysis/python/test_neo4j_multi_application_scope.py +++ b/tests/analysis/python/test_neo4j_multi_application_scope.py @@ -452,7 +452,7 @@ def _every_statement() -> Dict[str, str]: def test_the_audit_sees_the_dataflow_statements_too(): names = set(_class_level_statements()) - for expected in ("_REACHES", "_CONE", "_PATHS", "_CALL_PATHS", "_VALUE_REACHES", "_CALLEE_VALUES", "_SOURCES", "_SLICE", "_CALLERS", "_CALLEES", "_OWN_EDGES", "_LOCATE_QUERY", "_OVERVIEW_PROJECTION"): + for expected in ("_REACHES", "_CONE", "_PATHS", "_CALL_PATHS", "_TAINT", "_EDGE_VARS", "_VALUE_REACHES", "_CALLEE_VALUES", "_SOURCES", "_SLICE", "_CALLERS", "_CALLEES", "_OWN_EDGES", "_LOCATE_QUERY", "_OVERVIEW_PROJECTION"): assert expected in names, f"{expected} is not a class-level statement any more; move it back or extend the audit" From 3630528862b60caa7dbfed99a5b62ee0a8c3ec55 Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Thu, 10 Sep 2026 07:21:29 -0400 Subject: [PATCH 32/65] feat(py-local): _taint_walk over the local SDG, with the scoped variable cut The local mirror of the Cypher walk: _require_dataflow() first (Ruling F -- taint() carries no level gate because the graph backends have no level to measure, so a below-level-4 caller is refused here or nowhere), then one _shortest_walks per resolved (src, dst) pair, which is what makes max_paths + 1 a per-pair cap rather than a flat limit that lets a prolific pair starve a sparse one. Pairs are deduplicated by resolved position because the graph side gets that free from a.id IN $srcs, and without it a duplicated selector would flip taint()'s truncated flag on one backend only. Both sanitizer mechanisms are predicates inside the search, not filters over its output: cut_callables becomes allow_node on the can:// prefix, cuts becomes allow_edge on (var, start node). Expressing the second needed shortest_walks' allow_edge to take the hop's start node -- the callback had (rel, var), and the scoped predicate the Cypher settled on (startNode(r).id STARTS WITH c.prefix) cannot be written without it. Widened to allow_edge(frm, rel, var), the one call site is inside steps(), which is the only place the start node exists. An unscoped local predicate would sever names like answer or token in every callable that happens to reuse them, and over-cutting is a false refutation. _edge_vars_in reads the vars off the forward adjacency under a callable's prefix. Tests: the scoped cut gets the graph an unscoped predicate cannot tell apart, in the semantics spec; the level gate is asserted offline next to the other level-guard tests, since the live file skips without the fixture. The live parity file measures both backends on the fixture -- the witnesses, the hop chains, the max_paths + 1 boundary, per-pair starvation, and a callable sanitizer cutting its own pair while the sibling survives. Its sources are callee parameters: a caller's parameter has disjoint @formal_in and @entry def-sites upstream (codeanalyzer-python#204), so sourcing from one would encode a wrong expectation as a passing assertion. --- cldk/analysis/commons/graphs.py | 27 ++- .../python/codeanalyzer/codeanalyzer.py | 58 +++++- .../analysis/commons/test_taint_semantics.py | 28 ++- tests/analysis/python/test_dataflow.py | 9 + .../analysis/python/test_python_taint_live.py | 180 ++++++++++++++++++ 5 files changed, 292 insertions(+), 10 deletions(-) create mode 100644 tests/analysis/python/test_python_taint_live.py diff --git a/cldk/analysis/commons/graphs.py b/cldk/analysis/commons/graphs.py index 6b710b6d..ff542bf7 100644 --- a/cldk/analysis/commons/graphs.py +++ b/cldk/analysis/commons/graphs.py @@ -366,7 +366,7 @@ def shortest_walks( limit: int, *, via: Mapping[str, str], - allow_edge: Callable[[str, "str | None"], bool] | None = None, + allow_edge: Callable[[str, str, "str | None"], bool] | None = None, allow_node: Callable[[str], bool] | None = None, ) -> List[list]: """Up to ``limit`` shortest ``src``->``dst`` walks over ``edges``, in the documented order. @@ -379,10 +379,21 @@ def shortest_walks( ordinary -- one statement feeding one argument on several variables is several distinct paths -- and collapsing them would merge several pieces of evidence into one. - ``allow_edge`` and ``allow_node`` are the taint sanitizer cut: ``allow_edge(relationship type, - var)`` keeps a label, ``allow_node(node id)`` keeps a node (checked against ``src`` itself too, - so a source inside a cut callable yields no walk at all). Both default to ``None``, meaning no - filtering, so every caller that predates taint is unaffected. **They must be applied before the + ``allow_edge`` and ``allow_node`` are the taint sanitizer cut: ``allow_edge(start node id, + relationship type, var)`` keeps a label, ``allow_node(node id)`` keeps a node (checked against + ``src`` itself too, so a source inside a cut callable yields no walk at all). Both default to + ``None``, meaning no filtering, so every caller that predates taint is unaffected. + + **The start node is a parameter because a variable cut is scoped**, and it is scoped to the + *start* node exactly as the Cypher predicate is (corrected Ruling B -- + :func:`sdg_taint_query`'s ``startNode({rel_var}).id STARTS WITH c.prefix``). A cut is + ``{var, prefix}``: the variable, and the ``can://`` id of the callable the caller wrote + ``within=`` as. Without the start node a local predicate could only compare the name, cutting + every ``result``/``answer``/``token`` hop in the application when the caller named one + callable's -- and over-cutting produces false refutations, the one output this design refuses. + ``startNode`` and not either endpoint is deliberate there and here: a parameter-passing edge + starts in the caller, so a cut named for the callee's formal under-cuts rather than over-cuts, + and under-cutting only over-reports. **They must be applied before the breadth-first pass computes ``dist``, not only in the depth-first replay** -- see :func:`steps` below, which both passes call. Filtering the replay alone would leave ``dist`` describing the unfiltered graph: a sanitized 2-hop route would still pin ``dist[dst]`` to 2, and a clean 3-hop @@ -416,11 +427,15 @@ def steps(node: str): would leave the breadth-first ``dist`` describing the unfiltered graph (see the module docstring above for why that is a false refutation, not a performance shortcut). Filtering here instead makes ``dist`` the shortest *satisfying* distance. + + ``node`` is what a scoped variable cut needs and is only available here, which is why + ``allow_edge`` takes it: this is the one place in either pass that knows which node a label + is leaving. """ for d, labels in edges.get(node, {}).items(): if allow_node is not None and not allow_node(d): continue - kept = [lab for lab in labels if allow_edge is None or allow_edge(lab[0], lab[1])] + kept = [lab for lab in labels if allow_edge is None or allow_edge(node, lab[0], lab[1])] if kept: yield d, kept diff --git a/cldk/analysis/python/codeanalyzer/codeanalyzer.py b/cldk/analysis/python/codeanalyzer/codeanalyzer.py index ebe88985..69d18c84 100644 --- a/cldk/analysis/python/codeanalyzer/codeanalyzer.py +++ b/cldk/analysis/python/codeanalyzer/codeanalyzer.py @@ -52,7 +52,7 @@ import logging from functools import partial from pathlib import Path -from typing import Dict, Iterator, List, Sequence, Tuple, Union +from typing import Dict, FrozenSet, Iterator, List, Sequence, Tuple, Union import networkx as nx @@ -1433,6 +1433,62 @@ def _value_paths(self, a: SliceNode, b: SliceNode, depth: int | None, max_paths: paths = [flow_path([described[a.ref]] + [described[ref] for ref, _ in walk], [label for _, label in walk], via=VIA) for walk in walks[:max_paths]] return FlowPaths(paths=paths, complete=len(walks) <= max_paths) + def _taint_walk(self, srcs, dsts, *, cuts, cut_callables, depth, max_paths): + """The sanitized shortest walks, in process (see :meth:`PythonAnalysisBackend._taint_walk`). + + ``self._require_dataflow()`` first, per :meth:`PythonAnalysisBackend.taint`'s own note: the + level gate is a local backend's to ask, and asking it after resolution would mean a level-2 + analysis hearing "no such value" from ``resolve_value`` rather than "rebuild at level 4". + + One :func:`~cldk.analysis.commons.graphs.shortest_walks` call **per pair**, which is what + makes ``max_paths + 1`` a per-pair cap here the way ``collect(p)[0..$cap]`` is one over + Cypher -- a single walk over the flattened source and sink lists would let one prolific pair + starve the rest. Pairs are deduplicated by resolved position first, for the same reason + :func:`~cldk.analysis.commons.graphs.taint_verdict` deduplicates the requested ones: two + selectors naming one position are one pair, and walking it twice would report each witness + twice and make a cap of *m* yield *2m*. The graph side gets that free from ``a.id IN $srcs``. + + Both cuts are :func:`~cldk.analysis.commons.graphs.shortest_walks`' predicates rather than a + filter over the walks it returns, which is the property the whole design rests on: the + breadth-first pass must measure the shortest *satisfying* distance, or a sanitized short + route hides a clean longer one and the pair comes back refuted. ``allow_edge`` reads the + hop's **start** node, mirroring the Cypher predicate's ``startNode(r)`` term, so a variable + cut severs only the callable the caller named it in. Both are ``None`` when nothing is + sanitized -- the documented "no filtering" default, and no per-node cost on the common call. + + The ledger comes back empty for the graph backend's measured reason + (:meth:`~cldk.analysis.python.neo4j.neo4j_backend.PyNeo4jBackend._taint_walk`): an + unresolved dispatch emits no dependence edge at all, so the frontier is an absence here too, + and the analyzer's own ``call_sites`` do not say which callee it failed to resolve either. + """ + self._require_dataflow() + adjacency, nodes = self._sdg() + allow_node = (lambda nid: not any(nid.startswith(q) for q in cut_callables)) if cut_callables else None + allow_edge = (lambda frm, _rel, var: not any(var == c["var"] and frm.startswith(c["prefix"]) for c in cuts)) if cuts else None + pairs: Dict[Tuple[str, str], SliceNode] = {} + for a in srcs: + for b in dsts: + pairs.setdefault((a.ref, b.ref), a) + rows = [] + for (src_ref, dst_ref), a in pairs.items(): + walks = self._shortest_walks(adjacency["forward"], src_ref, dst_ref, depth, max_paths + 1, allow_edge=allow_edge, allow_node=allow_node) + described = {ref: _local_slice_node(nodes[ref], ref) for walk in walks for ref, _ in walk if ref in nodes} + described[src_ref] = a + rows.extend((src_ref, dst_ref, flow_path([described[src_ref]] + [described[ref] for ref, _ in walk], [label for _, label in walk], via=VIA)) for walk in walks) + return rows, {} + + def _edge_vars_in(self, callable_id: str) -> FrozenSet[str]: + """The edge variables scoped to this callable (see :meth:`PythonAnalysisBackend._edge_vars_in`). + + Off the adjacency this backend already builds and caches, so a variable sanitizer costs a + scan of it and no second traversal. Edges *leaving* a node under ``callable_id`` -- the same + ``startNode`` scoping the cut itself uses, so this validates exactly the domain the cut can + match. Four of the five relationship types carry no ``var``; those ``None``s are dropped, + because ``resolve_sanitizers`` refuses a blank variable before it asks. + """ + forward = self._sdg()[0]["forward"] + return frozenset(var for src, outs in forward.items() if src.startswith(callable_id) for labels in outs.values() for _rel, var, _prov in labels if var) + def paths_between(self, src: str, dst: str, *, src_within: str, dst_within: str, depth: int | None = None, max_paths: int = DEFAULT_MAX_PATHS) -> FlowPaths: """How a value reaches another value (see :meth:`PythonAnalysisBackend.paths_between`).""" check_depth(depth) diff --git a/tests/analysis/commons/test_taint_semantics.py b/tests/analysis/commons/test_taint_semantics.py index 7cabd214..89ee44ba 100644 --- a/tests/analysis/commons/test_taint_semantics.py +++ b/tests/analysis/commons/test_taint_semantics.py @@ -27,7 +27,7 @@ def test_a_sanitized_shortest_route_does_not_hide_a_clean_longer_one(): """The local twin of the inlining result: the search must find the shortest *satisfying* walk, not filter the shortest walk. If this returns [], the predicate was applied to the replay only and every local taint refutation is unsound.""" - walks = shortest_walks(ADJ, "a", "b", None, 10, via=VIA, allow_edge=lambda rel, var: var != "tainted") + walks = shortest_walks(ADJ, "a", "b", None, 10, via=VIA, allow_edge=lambda frm, rel, var: var != "tainted") assert [len(w) for w in walks] == [3], "the clean 3-hop route was not found" @@ -35,7 +35,7 @@ def test_a_null_var_hop_is_not_cut_by_a_variable_sanitizer(): """PARAM_IN carries no var. A predicate that treats None as "not equal to anything" is fine; one that treats it as unknown-and-therefore-excluded refutes every interprocedural flow.""" adj = {"a": {"p": [("PY_DDG", "clean", ["ssa"])]}, "p": {"q": [("PY_PARAM_IN", None, None)]}, "q": {"b": [("PY_DDG", "clean", ["ssa"])]}} - walks = shortest_walks(adj, "a", "b", None, 10, via=VIA, allow_edge=lambda rel, var: var != "tainted") + walks = shortest_walks(adj, "a", "b", None, 10, via=VIA, allow_edge=lambda frm, rel, var: var != "tainted") assert [len(w) for w in walks] == [3] @@ -58,10 +58,32 @@ def test_a_sanitized_parallel_edge_is_not_reported_as_evidence(): ``dist[b]`` at 1 no matter which pass filters, so a BFS-only filter cannot fail this case -- only the replay's own filter keeps the sanitized label out of the walk it emits as taint evidence. """ - walks = shortest_walks(PARALLEL, "a", "b", None, 10, via=VIA, allow_edge=lambda rel, var: var != "tainted") + walks = shortest_walks(PARALLEL, "a", "b", None, 10, via=VIA, allow_edge=lambda frm, rel, var: var != "tainted") assert [lab[1] for w in walks for _, lab in w] == ["clean"] +#: The same variable name on two different start nodes -- ``a`` is inside the cut's scope, ``s`` is +#: not. Corrected Ruling B is only observable on a graph like this one; on ``ADJ`` a scoped and an +#: unscoped predicate agree. +SCOPED = { + "s": {"a": [("PY_DDG", "answer", ["ssa"])]}, + "a": {"b": [("PY_DDG", "answer", ["ssa"])]}, +} + + +def test_a_variable_cut_is_scoped_to_the_start_nodes_it_names(): + """The local mirror of the Cypher predicate's ``startNode(r).id STARTS WITH c.prefix``: a cut on + ``answer`` written for one callable must not sever the same name elsewhere. Names like + ``answer``, ``result`` and ``token`` recur across callables in any real program, so an unscoped + local predicate would over-cut -- and over-cutting is a false refutation, which in triage closes + a live alert. Only the ``a -> b`` hop is cut here, so the ``s -> b`` walk is 1 hop shorter than + it can be reached in, i.e. there is no walk at all.""" + kept = shortest_walks(SCOPED, "s", "b", None, 10, via=VIA, allow_edge=lambda frm, rel, var: not (var == "answer" and frm.startswith("a"))) + assert kept == [], "the in-scope hop is the only route, so cutting it leaves nothing" + survives = shortest_walks(SCOPED, "s", "b", None, 10, via=VIA, allow_edge=lambda frm, rel, var: not (var == "answer" and frm.startswith("z"))) + assert [len(w) for w in survives] == [2], "a cut scoped to a callable this walk never enters must sever nothing" + + def test_a_cut_source_yields_no_walk(): """A source inside a cut callable yields no walk at all -- checked against ``src`` up front, since ``src`` is never itself a ``steps()`` destination for the BFS/DFS filtering to catch. Also diff --git a/tests/analysis/python/test_dataflow.py b/tests/analysis/python/test_dataflow.py index d35f0063..ba21b29d 100644 --- a/tests/analysis/python/test_dataflow.py +++ b/tests/analysis/python/test_dataflow.py @@ -1226,6 +1226,15 @@ def test_local_paths_need_dataflow_and_reuse_the_one_level_guard(local_l2): assert "program_dependency_graph" in str(e.value) +def test_the_local_taint_walk_opens_with_the_same_level_gate(local_l2): + """Ruling F: ``taint()`` carries no level gate of its own -- the graph backends have no level to + measure -- so the local walk is where a below-level-4 caller is refused, or nowhere. The hook is + exercised directly because the gate is its first statement, before any selector is resolved.""" + with pytest.raises(CodeanalyzerUsageException) as e: + local_l2._taint_walk([], [], cuts=[], cut_callables=[], depth=None, max_paths=1) + assert "program_dependency_graph" in str(e.value) + + def test_local_call_paths_do_not_need_dataflow(local_l2): """``call_paths_between`` is a call-graph question and the call graph exists from level 2 -- the same split ``reaches``/``callers_of`` already make.""" diff --git a/tests/analysis/python/test_python_taint_live.py b/tests/analysis/python/test_python_taint_live.py new file mode 100644 index 00000000..74c960c1 --- /dev/null +++ b/tests/analysis/python/test_python_taint_live.py @@ -0,0 +1,180 @@ +################################################################################ +# Copyright IBM Corporation 2026 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +################################################################################ + +"""``taint()``'s two Python walks, measured against a real graph and a real analysis. + +The offline suite (``test_python_taint.py``) pins everything ``taint()`` does *around* a walk. This +one pins the walks themselves, and it needs both backends in one file because the only claim worth +making about two implementations of one hook is that they answer identically -- and "identically" +means the same hop chains and the same refuted pairs, not two truthy results. + +**The fixture is small on purpose and its source is not free.** ``fixture/proj/app.py`` carries a +caller (``Handler.handle``) whose parameter reaches a sink through two routes, but a *caller's* +parameter is unusable as a source here: its ``@formal_in`` port and its ``@entry`` def-site are +disjoint upstream, so the port a selector resolves to reaches no argument at all +(codeanalyzer-python#204). Sourcing from ``("user_input", "handle")`` would therefore measure that +defect and record a wrong expectation as a passing assertion. The usable witnesses start at a +**callee's** parameter, and the 6-hop shape they take crosses two call boundaries in both +directions:: + + formal_in -> body -> formal_out -[return]-> actual_out -> stmt -> actual_in -[argument]-> formal_in + +Path counts on this graph are also **not route counts**: #204's secondary finding is that a +reaching-definition ``var`` names the *use* rather than the def, which inflates them. So the numbers +below are measured, and what they are asserted against is a hop chain wherever a chain will do. + +Every ref is measured from the graph through ``resolve_value``. Nothing here hardcodes a ``can://`` +id: the leg-4a ledger did, its fixture was regenerated, and those ids now name nothing. +""" + +import os +from pathlib import Path + +import pytest + +from cldk import CLDK +from cldk.analysis.commons.backend_config import Neo4jConnectionConfig + +#: The fixture's own container, not the shared live graph the rest of this directory attaches to: +#: leg 4b needs an application whose *shape* is known callable-by-callable, and odoo-slim-19 is +#: neither small enough to enumerate nor stable enough to name a pair in. Defaults point at the +#: container ``fixture/README.md`` builds; 7687 is deliberately not among them. +TAINT_URI = os.environ.get("CLDK_TEST_TAINT_NEO4J_URI", "bolt://localhost:7697") +TAINT_USER = os.environ.get("CLDK_TEST_TAINT_NEO4J_USER", "neo4j") +TAINT_PASSWORD = os.environ.get("CLDK_TEST_TAINT_NEO4J_PASSWORD", "cldkleg4btest") +TAINT_APP = os.environ.get("CLDK_TEST_TAINT_NEO4J_APP", "leg4b") +PROJ = Path(__file__).resolve().parents[3] / ".superpowers" / "sdd" / "2026-09-09-leg-4b-taint" / "fixture" / "proj" + + +def _fixture_graph_present() -> bool: + """True iff a server answers at ``TAINT_URI`` *and* holds ``TAINT_APP``. + + Connectivity alone is not enough, for ``test_e2e_neo4j_live.py``'s reason: a developer with + another container on this port would otherwise see these assertions run against a graph that + has none of the callables they name, and read the failures as defects. + """ + try: + from neo4j import GraphDatabase + except ModuleNotFoundError: + return False + try: + driver = GraphDatabase.driver(TAINT_URI, auth=(TAINT_USER, TAINT_PASSWORD)) + try: + driver.verify_connectivity() + with driver.session() as session: + found = session.run("MATCH (a:PyApplication {name: $n}) RETURN count(a) AS c", n=TAINT_APP).single() + return bool(found and found["c"]) + finally: + driver.close() + except Exception: # noqa: BLE001 - any connection/auth failure => skip, never fail + return False + + +pytestmark = pytest.mark.skipif( + not PROJ.is_dir() or not _fixture_graph_present(), + reason=(f"no leg-4b taint fixture: needs {PROJ} on disk and Neo4j at {TAINT_URI} holding {TAINT_APP!r} " "(see fixture/README.md; set CLDK_TEST_TAINT_NEO4J_URI / _USER / _PASSWORD / _APP)"), +) + +#: The two sources and the two sinks the fixture can actually witness, as a caller writes them. +SOURCES = [("raw", "scrub"), ("raw", "relay")] +SINKS = [("cleaned", "run_query"), ("note", "run_query")] + + +# ``.backend`` and not the facade: ``taint()`` is a backend method until the facade method lands +# (leg 4b Task 8 -- ``PythonAnalysis`` delegates one accessor at a time, and this is the last one), +# and a suite that waited for the delegation would leave the two walks untested in between. +@pytest.fixture(scope="module") +def graph(): + """The fixture graph, attached. Module-scoped: attaching runs three probes and a module load.""" + facade = CLDK.python(backend=Neo4jConnectionConfig(uri=TAINT_URI, username=TAINT_USER, password=TAINT_PASSWORD, application_name=TAINT_APP)) + yield facade.backend + facade.backend.close() + + +@pytest.fixture(scope="module") +def local(): + """The same project analysed in process, at the level the SDG needs. Module-scoped for the same + reason: the analyzer runs once per construction.""" + return CLDK.python(project_path=str(PROJ), analysis_level="system_dependency_graph").backend + + +def _chains(result): + """A result's witnesses as sorted ``via``-chains -- the vocabulary a caller reads, and the one + thing two backends can be compared in without comparing their ids.""" + return sorted(tuple(h.via for h in p.hops) for p in result.paths) + + +def test_both_backends_agree_on_the_witnesses_and_on_the_refutations(local, graph): + """Agreeing on a predicate is not agreeing on a set: assert the hop chains and the exhausted + pairs, not truthiness.""" + got = {} + for name, backend in (("local", local), ("graph", graph)): + r = backend.taint(SOURCES, SINKS, max_paths=10) + got[name] = (_chains(r), sorted(r.exhausted), r.complete) + assert got["local"] == got["graph"] + assert len(got["local"][0]) == 6, "2 + 2 + 1 + 1, from the measured table" + assert got["local"][1] == [], "every pair has a witness" + assert got["local"][2] is True + + +def test_the_six_witnesses_all_cross_two_call_boundaries_in_both_directions(local, graph): + """The shape, not just the count. A walk that stopped at a call boundary would still return + *some* rows on this graph; only the chain says it went in and came back out.""" + for backend in (local, graph): + assert _chains(backend.taint(SOURCES, SINKS, max_paths=10)) == [("data", "data", "return", "data", "data", "argument")] * 6 + + +@pytest.mark.parametrize("backend_name", ["local", "graph"]) +def test_the_walk_returns_one_row_past_the_cap_so_truncation_is_never_silent(request, backend_name): + """A walk that caps at ``max_paths`` instead of ``max_paths + 1`` returns a full-looking result + with ``complete=True`` -- a silent bound, which E5 exists to forbid. ``taint()`` cannot detect + it, so each of the five implementations is tested here or nowhere.""" + backend = request.getfixturevalue(backend_name) + pair = ([("raw", "scrub")], [("cleaned", "run_query")]) + at_one = backend.taint(*pair, max_paths=1) + assert len(at_one.paths) == 1 and at_one.complete is False + at_two = backend.taint(*pair, max_paths=2) + assert len(at_two.paths) == 2 and at_two.complete is True + + +@pytest.mark.parametrize("backend_name", ["local", "graph"]) +def test_a_prolific_pair_does_not_starve_a_sparse_one(request, backend_name): + """Four pairs and six witnesses against ``max_paths=1``: a flat ``LIMIT $cap`` returns two rows + for the whole batch, so two of the four pairs come back with nothing -- reported as no flow, + which in triage closes a live alert.""" + backend = request.getfixturevalue(backend_name) + r = backend.taint(SOURCES, SINKS, max_paths=1) + pairs = {(p.hops[0].frm.ref, p.hops[-1].to.ref) for p in r.paths} + assert len(pairs) == 4, "a flat cap cannot produce four pairs from a cap of two" + assert r.complete is False, "the two-witness pairs truncated, and a bound is never silent (E5)" + + +@pytest.mark.parametrize("backend_name", ["local", "graph"]) +def test_a_callable_sanitizer_cuts_its_own_pair_and_leaves_the_sibling_alone(request, backend_name): + """Over-cutting is the one output this leg exists to refuse, so a cut that closed both pairs + would pass a naive "the sanitizer worked" assertion while being the failure.""" + backend = request.getfixturevalue(backend_name) + r = backend.taint(SOURCES, [("note", "run_query")], sanitizers=["scrub"]) + scrub_src = backend.resolve_value("raw", within="scrub").ref + relay_src = backend.resolve_value("raw", within="relay").ref + assert len(r.paths) == 1, "the relay route is not sanitized and must survive" + assert r.paths[0].hops[0].frm.ref == relay_src + assert all(scrub_src != h.frm.ref for p in r.paths for h in p.hops) + # ``exhausted`` names a pair by the two SELECTORS the caller wrote (``taint_verdict``), so the + # cut pair reads as the names, not the refs -- and both sources are spelled ``raw``, which is + # exactly why ``roots`` is what tells two same-named pairs apart. + assert r.exhausted == [("raw", "note")] + assert r.complete is True, "one pair refuted and one witnessed is a clean batch" From bde244426ef51cedfd76f857a257d37064185259 Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Thu, 10 Sep 2026 08:01:09 -0400 Subject: [PATCH 33/65] fix(taint): delimit the callable cut, so `create` cannot sever `createGuest` Four sites tested "is this node under callable q" with a bare prefix test. That is self-delimiting only because a Python or Java callable id ends in `)`. A TypeScript callable id carries no closing delimiter, so a cut named `create` also removed every id belonging to `createGuest` -- 13 of them in the committed level-4 TypeScript fixture, plus a second collision (`User` / `UserId`). Removing paths a caller never sanitized adds the pair to `exhausted`, and `exhausted` certifies that no flow exists, so over-cutting emits a false refutation while under-cutting only over-reports. The directions are not symmetric. One predicate, `under_callable`, beside `sdg_taint_query` which already owns the Cypher half -- rather than four lambdas, because Task 7 writes four more copies and the defect is invisible in three of the four languages. Three disjuncts: the callable's own node, `q + '@'` for its body nodes (69/69 on the leg-4b Python fixture, so Python's behaviour is unchanged), and `q + '/'` for what is minted under a body node, which is 920 real port sub-nodes in the TypeScript fixture and is kept to preserve the bare test's reach. --- cldk/analysis/commons/graphs.py | 57 +++++++++++++++++-- .../python/codeanalyzer/codeanalyzer.py | 6 +- tests/analysis/commons/test_lifted_helpers.py | 17 ++++++ .../analysis/commons/test_taint_semantics.py | 52 ++++++++++++++++- 4 files changed, 123 insertions(+), 9 deletions(-) diff --git a/cldk/analysis/commons/graphs.py b/cldk/analysis/commons/graphs.py index ff542bf7..5c437ebc 100644 --- a/cldk/analysis/commons/graphs.py +++ b/cldk/analysis/commons/graphs.py @@ -24,7 +24,7 @@ from __future__ import annotations -from typing import Callable, Dict, Iterable, List, Literal, Mapping, Sequence, Tuple +from typing import Callable, Collection, Dict, Iterable, List, Literal, Mapping, Sequence, Tuple import networkx as nx @@ -240,6 +240,48 @@ def sdg_path_query(P: str, *, node_label: str, endpoint_scope: Callable[[str], s ) +def under_callable(node_id: str, callable_ids: Collection[str]) -> bool: + """Whether ``node_id`` names one of ``callable_ids``, one of its body nodes, or one of a + callable nested inside it -- the Python mirror of :func:`sdg_taint_query`'s ``$cut_callables`` + predicate, and the one place either half of that predicate is written. + + **A bare ``node_id.startswith(q)`` is wrong, and only visibly wrong in one language.** A Python + callable id ends in ``)`` and so does a Java one, so a prefix test on those is self-delimiting by + accident -- measured on the committed daytrader8 level-4 fixture: of 444 delimiter-free ids every + callable-shaped one ends ``)``, and ``()`` is not a prefix of + ``(java.math.BigDecimal, ...)`` because the ``)`` falls where the other has ``j``. A + TypeScript callable id carries no closing delimiter at all + (``can://slim/typescript/src/services.ts/UserService/create``), so a bare prefix test written for + ``create`` also matches every id belonging to ``createGuest`` -- 13 of them in the committed + level-4 TypeScript fixture, alongside a second collision (``User`` / ``UserId``). That over-cuts: + paths a caller never sanitized disappear, the pair joins ``exhausted``, and ``exhausted`` is a + *certificate that no flow exists*. Over-cutting is therefore a false refutation -- the one output + ``taint()`` exists to refuse -- while under-cutting merely over-reports. The two directions are + not symmetric, so do not simplify this back to one ``startswith``. + + Three disjuncts, each earning its place. The joiner histogram over every longer id starting with + a delimiter-free id in that TypeScript fixture is ``{'/': 920, '@': 254, 'G': 13, 'I': 1}``: the + two joiners are what the disjuncts accept, and the ``G`` and ``I`` are exactly the collisions + they now reject. + + * ``node_id == q`` -- the callable's own node. Unreachable under a body-node ``MATCH`` and free, + but it is what "under this callable" means, so it is written rather than assumed away. + * ``q + "@"`` -- its body nodes. On the leg-4b Python fixture all 69 ``PY_HAS_BODY_NODE`` children + join with ``@`` and none joins any other way, so on Python this predicate accepts exactly what + the bare prefix test accepted. codeanalyzer-typescript's graph was measured the same at 125,532 + body nodes with 0 exceptions (see ``TSNeo4jBackend._OWN_EDGES``, whose ``$bp`` is + ``node.ref + "@"`` for exactly this reason). + * ``q + "/"`` -- everything minted *under* a body node or a nested callable, since a child id is + ``parent + "/" + key`` (``reconstruct.child_key`` raises if it is not). This is not a + speculative disjunct: a TypeScript call site's port sub-nodes are spelled + ``...create@26:5/actual_in:1``, and there are 920 such joins in the one fixture. Dropping it + would sever a call site from its own arguments and under-cut every interprocedural cut, so it + is here to **preserve** the bare prefix test's reach, exactly as ``resolve_sanitizers`` + documents a callable cut ("every body node under it"). + """ + return any(node_id == q or node_id.startswith(q + "@") or node_id.startswith(q + "/") for q in callable_ids) + + def sdg_taint_query(P: str, *, node_label: str, endpoint_scope: Callable[[str], str] | None = None, interior_scope: Callable[[str], str] | None = None, projection: str, rel_var: str = "r") -> str: """The multi-source, multi-sink shortest-path statement behind ``taint()``. @@ -248,8 +290,10 @@ def sdg_taint_query(P: str, *, node_label: str, endpoint_scope: Callable[[str], * ``$srcs`` / ``$dsts`` are lists, not a single ``$src`` / ``$dst``, so m sources against n sinks is one round trip and one traversal per pair rather than m*n statements. * A sanitizer cut lives **inside** the pattern -- ``$cut_callables`` (a list of ``can://`` - prefixes; a body-node id *is* ``@``, so cutting a callable is a - prefix test and needs no join) and ``$cuts`` (a list of ``{var, prefix}`` maps) -- rather + callable ids, tested with the three disjuncts :func:`under_callable` documents rather than a + bare ``STARTS WITH``, so a cut named ``create`` cannot also sever ``createGuest``; no join is + needed either way, because a body node's id is minted under its callable's) and ``$cuts`` + (a list of ``{var, prefix}`` maps) -- rather than being applied to the rows a first, unfiltered match returns. Measured on Neo4j 5.26.30: an ``all()`` over ``relationships(p)`` inlines into the ``ShortestPath`` operator, so the search itself returns the shortest *unsanitized* path. Filtering afterwards would report no @@ -313,10 +357,13 @@ def sdg_taint_query(P: str, *, node_label: str, endpoint_scope: Callable[[str], f"MATCH (a:{node_label}) WHERE a.id IN $srcs{a_scope} " f"MATCH (b:{node_label}) WHERE b.id IN $dsts{b_scope} " "MATCH p = allShortestPaths((a)-[:{rels}*1..{depth}]->(b)) " - "WHERE all(n IN nodes(p) WHERE NOT any(q IN $cut_callables WHERE n.id STARTS WITH q))" + "WHERE all(n IN nodes(p) WHERE NOT any(q IN $cut_callables WHERE " + "n.id = q OR n.id STARTS WITH q + '@' OR n.id STARTS WITH q + '/'))" + interior + " " f"AND all({rel_var} IN relationships(p) WHERE NOT any(c IN $cuts WHERE " - f"coalesce({rel_var}.var, '') = c.var AND startNode({rel_var}).id STARTS WITH c.prefix)) " + f"coalesce({rel_var}.var, '') = c.var AND (startNode({rel_var}).id = c.prefix " + f"OR startNode({rel_var}).id STARTS WITH c.prefix + '@' " + f"OR startNode({rel_var}).id STARTS WITH c.prefix + '/'))) " "WITH a, b, p, " + path_order(P) + " AS key ORDER BY length(p), key " "WITH a, b, collect(p)[0..$cap] AS ps " "UNWIND ps AS p " diff --git a/cldk/analysis/python/codeanalyzer/codeanalyzer.py b/cldk/analysis/python/codeanalyzer/codeanalyzer.py index 69d18c84..6e3a669d 100644 --- a/cldk/analysis/python/codeanalyzer/codeanalyzer.py +++ b/cldk/analysis/python/codeanalyzer/codeanalyzer.py @@ -61,7 +61,7 @@ from codeanalyzer.schema import Analysis, model_dump_json from cldk.analysis import AnalysisLevel -from cldk.analysis.commons.graphs import call_reaches +from cldk.analysis.commons.graphs import call_reaches, under_callable from cldk.analysis.commons.levels import ANALYZER_LEVELS, LEVEL_NAMES, analyzer_level from cldk.analysis.commons.resolve import CallableCandidate, body_node_kind, resolve_callable_signature, resolve_value_name, resolve_within, value_candidate from cldk.analysis.commons.results import BodyRef, CallableRef, Diagnostic, EdgePage, EntrypointCoverage, FlowPaths, LocateResult, ModuleRef, Slice, SliceNode, TypeRef @@ -1463,8 +1463,8 @@ def _taint_walk(self, srcs, dsts, *, cuts, cut_callables, depth, max_paths): """ self._require_dataflow() adjacency, nodes = self._sdg() - allow_node = (lambda nid: not any(nid.startswith(q) for q in cut_callables)) if cut_callables else None - allow_edge = (lambda frm, _rel, var: not any(var == c["var"] and frm.startswith(c["prefix"]) for c in cuts)) if cuts else None + allow_node = (lambda nid: not under_callable(nid, cut_callables)) if cut_callables else None + allow_edge = (lambda frm, _rel, var: not any(var == c["var"] and under_callable(frm, (c["prefix"],)) for c in cuts)) if cuts else None pairs: Dict[Tuple[str, str], SliceNode] = {} for a in srcs: for b in dsts: diff --git a/tests/analysis/commons/test_lifted_helpers.py b/tests/analysis/commons/test_lifted_helpers.py index 235479c7..71945247 100644 --- a/tests/analysis/commons/test_lifted_helpers.py +++ b/tests/analysis/commons/test_lifted_helpers.py @@ -351,6 +351,23 @@ def test_the_taint_query_uses_callables_not_string_replace(): assert "AND b.id STARTS WITH $prefix" in q +def test_the_taint_query_delimits_the_callable_cut_rather_than_bare_prefixing_it(): + """The Cypher half of :func:`~cldk.analysis.commons.graphs.under_callable`, asserted here so the + two halves cannot drift: a TypeScript callable id has no closing delimiter, so a bare + ``n.id STARTS WITH q`` cuts ``createGuest`` when the caller named ``create`` -- 13 real ids in + the committed level-4 TypeScript fixture. Over-cutting adds pairs to ``exhausted``, which + certifies that no flow exists, so it is a false refutation and not a conservative default.""" + from cldk.analysis.commons.graphs import sdg_taint_query + + q = sdg_taint_query("PY", node_label="PyBodyNode", projection="ref: n.id") + assert "n.id STARTS WITH q)" not in q, "a bare prefix test over-cuts every sibling callable" + for disjunct in ("n.id = q", "n.id STARTS WITH q + '@'", "n.id STARTS WITH q + '/'"): + assert disjunct in q, f"the callable cut lost its {disjunct!r} disjunct" + for disjunct in ("startNode(r).id = c.prefix", "startNode(r).id STARTS WITH c.prefix + '@'", "startNode(r).id STARTS WITH c.prefix + '/'"): + assert disjunct in q, f"the variable cut's scope lost its {disjunct!r} disjunct" + assert "STARTS WITH c.prefix)" not in q, "the variable cut's scope is still a bare prefix test" + + def test_the_taint_query_still_formats(): from cldk.analysis.commons.graphs import sdg_rel_pattern, sdg_taint_query diff --git a/tests/analysis/commons/test_taint_semantics.py b/tests/analysis/commons/test_taint_semantics.py index 89ee44ba..bd03744d 100644 --- a/tests/analysis/commons/test_taint_semantics.py +++ b/tests/analysis/commons/test_taint_semantics.py @@ -1,7 +1,7 @@ # tests/analysis/commons/test_taint_semantics.py import pytest -from cldk.analysis.commons.graphs import shortest_walks, via_table +from cldk.analysis.commons.graphs import shortest_walks, under_callable, via_table from cldk.analysis.commons.resolve import resolve_sanitizers from cldk.analysis.commons.results import Diagnostic, TaintResult from cldk.utils.exceptions.exceptions import SelectorNotInGraph @@ -45,6 +45,56 @@ def test_a_node_cut_removes_a_whole_callable(): assert [len(w) for w in walks] == [3] +# ---------------------------------------------------------------------------------------------- +# The callable-cut predicate itself. The three tests above pass their OWN ``startswith`` lambdas, +# so they exercise ``shortest_walks``' plumbing and never the predicate the backends actually use; +# ``under_callable`` is that predicate, and these two are its only coverage. +# ---------------------------------------------------------------------------------------------- +#: Real ids, out of the committed level-4 TypeScript fixture +#: (``tests/resources/typescript/analysis_json/v2/a4/analysis.json``, app ``slim``). A TypeScript +#: callable id ends in the bare member name with no delimiter, so ``create`` is a strict prefix of +#: ``createGuest`` -- and 13 ids in that one fixture begin with ``create`` while belonging to +#: ``createGuest``. Measured joiners over every longer id starting with an ``@``-free id: +#: ``{'/': 920, '@': 254, 'G': 13, 'I': 1}`` -- the ``G`` and the ``I`` are the two collisions +#: (``create``/``createGuest`` and ``User``/``UserId``), the ``@`` and ``/`` are the real joins. +_CREATE = "can://slim/typescript/src/services.ts/UserService/create" +_CREATE_GUEST = "can://slim/typescript/src/services.ts/UserService/createGuest" + + +def test_a_callable_cut_does_not_reach_a_callable_whose_name_merely_starts_with_it(): + """The over-cut, in real analyzer output rather than a constructed pair. A TypeScript callable id + carries no closing delimiter, so a bare prefix test written for ``create`` also cuts every body + node of ``createGuest``. Cutting more than the caller named removes paths; removing paths adds + the pair to ``exhausted``; ``exhausted`` certifies that *no flow exists*. So over-cutting is a + false refutation -- the one output this accessor exists to refuse -- while under-cutting merely + over-reports. Python and Java ids end in ``)`` and hide this entirely (measured on daytrader8: + 444 ``@``-free ids, all callable-shaped ones ending ``)``, and ``()`` is not a prefix of + ``(java.math.BigDecimal, ...)``), which is why the predicate is shared: it has to hold for + the language that does not hide it.""" + assert under_callable(_CREATE + "@26:5", [_CREATE]), "its own body node" + assert under_callable(_CREATE + "@26:5/actual_in:1", [_CREATE]), "a call site's port sub-node" + assert not under_callable(_CREATE_GUEST, [_CREATE]), "a sibling callable is not under it" + assert not under_callable(_CREATE_GUEST + "@32:5", [_CREATE]), "nor is that sibling's body" + assert not under_callable(_CREATE_GUEST + "@32:5/actual_out", [_CREATE]) + assert under_callable(_CREATE_GUEST + "@32:5", [_CREATE_GUEST]), "the cut it was named for holds" + + +def test_a_python_callable_cut_accepts_exactly_what_the_bare_prefix_test_accepted(): + """Measured on the leg-4b fixture: all 69 body nodes join with ``@``, so the narrowing is + behaviour-preserving on the one language that has a live graph to measure.""" + q = "can://leg4b/python/app.py/scrub(raw)" + for key in ("@entry", "@exit", "@formal_in:0", "@32:8"): + assert under_callable(q + key, [q]) + + +def test_a_callable_cut_names_the_callable_itself_and_takes_a_list(): + """The ``= q`` disjunct, and that several cuts are tested disjunctively -- ``$cut_callables`` is + a list, and a node under any member is cut.""" + assert under_callable(_CREATE, [_CREATE]) + assert under_callable(_CREATE_GUEST + "@32:5", [_CREATE, _CREATE_GUEST]) + assert not under_callable(_CREATE_GUEST + "@32:5", []) + + PARALLEL = {"a": {"b": [("PY_DDG", "tainted", ["ssa"]), ("PY_DDG", "clean", ["ssa"])]}} From e2dd243c1f4a14d7093b3ffb705bcd03d843a300 Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Thu, 10 Sep 2026 08:08:05 -0400 Subject: [PATCH 34/65] fix(sdg): keep the var the analyzer puts on a param edge The local adjacency hardcoded `var=None` on PY_PARAM_IN/PY_PARAM_OUT. That used to be the truth: until the rc.5 analyzer pin (codeanalyzer-python 1.5.1) those two lists declared the property and the projection wrote nothing. The pin bump made it a lie and the line was never revisited. Measured on the leg-4b fixture: 6 of 6 param_in and 5 of 5 param_out carry a var. Withholding them cost two things. `_edge_vars_in` could not see any call-crossing variable, so `resolve_sanitizers` refused a real dataflow variable as nonexistent -- `sanitizers=[("raw", "handle")]` raised SelectorNotInGraph locally while the graph answered 6 paths. And `allow_edge`'s `var == c["var"]` could never match a param edge, so a scoped variable cut was structurally incapable of cutting at a call boundary, which is the capability this branch was rebased onto the new pins for. Both backends now agree on every `_edge_vars_in` and on every hop's var. Witness order did not move: the two witnesses of raw@scrub -> cleaned@run_query already separate at hop 1 on `` vs `app::ch.*`, so the cap keeps the same survivor. Offline coverage for what was container-gated: a two-route project reproducing the Neo4j fixture's shape (6 witnesses, same via chain) covers the cap boundary, the per-pair dedup, both cuts and the cut-source case with no container, and the walk hook's calling convention is pinned where Task 7 will read it. --- .../python/codeanalyzer/codeanalyzer.py | 10 +- tests/analysis/python/test_dataflow.py | 175 ++++++++++++++++++ tests/analysis/python/test_python_taint.py | 26 +++ 3 files changed, 209 insertions(+), 2 deletions(-) diff --git a/cldk/analysis/python/codeanalyzer/codeanalyzer.py b/cldk/analysis/python/codeanalyzer/codeanalyzer.py index 6e3a669d..ec3da994 100644 --- a/cldk/analysis/python/codeanalyzer/codeanalyzer.py +++ b/cldk/analysis/python/codeanalyzer/codeanalyzer.py @@ -1289,10 +1289,16 @@ def link(src: str, dst: str, label: tuple) -> None: ) # Endpoints here are already global (``emit_l4`` resolved them through the endpoint # functions' identity maps), so they are used as-is -- joining them again would mint - # ids that name nothing. + # ids that name nothing. ``var`` is read off the edge exactly as the intra-procedural + # branch above reads it: until the rc.5 analyzer pin (codeanalyzer-python 1.5.1) these + # two lists declared the property and the projection wrote nothing, so a hardcoded + # ``None`` was the truth; the pin bump made it a lie, and a lie with consequences -- + # a call-crossing variable was invisible to ``_edge_vars_in`` (so a real sanitizer was + # refused as nonexistent) and ``allow_edge``'s ``var == c["var"]`` could never cut at a + # call boundary, which is the capability this branch was rebased onto the new pins for. for rel, edges in (("PY_PARAM_IN", self.application.param_in), ("PY_PARAM_OUT", self.application.param_out)): for e in edges or []: - link(e.src, e.dst, (rel, None, ())) + link(e.src, e.dst, (rel, getattr(e, "var", None), ())) self._sdg_cache = ({"forward": forward, "backward": backward}, nodes) return self._sdg_cache diff --git a/tests/analysis/python/test_dataflow.py b/tests/analysis/python/test_dataflow.py index ba21b29d..2dbe7e8c 100644 --- a/tests/analysis/python/test_dataflow.py +++ b/tests/analysis/python/test_dataflow.py @@ -1262,6 +1262,181 @@ def test_a_bad_depth_is_refused_by_the_path_accessors(slice_l4, call): call(slice_l4) +# ---------------------------------------------------------------------------------------------- +# Leg 4b fix round: the local ``taint`` walk, offline. +# +# Task 6 shipped this walk covered only by a container-gated file, so its cap, its per-pair dedup +# and both of its cuts were invisible to a developer without podman -- in a leg whose whole output +# is a refutation. Nothing below needs a graph or a container; only the analyzer. +# ---------------------------------------------------------------------------------------------- +def test_a_param_edge_carries_the_variable_the_analyzer_put_on_it(slice_l4): + """``PY_PARAM_IN``/``PY_PARAM_OUT`` reach the adjacency with their ``var``, and the calling + callable's edge variables therefore include the callee formal the call crosses into. + + **The hardcoded ``None`` this replaces used to be correct.** Until the rc.5 analyzer pin + (codeanalyzer-python 1.5.1 and its Java/TypeScript siblings) ``param_in``/``param_out`` declared + a ``var`` property that the projection never wrote, so ``None`` was the truth. The pin bump made + it a lie, and the lie cost two things: ``_edge_vars_in`` could not see any call-crossing variable, + so ``resolve_sanitizers`` refused a real dataflow variable as nonexistent (Ruling A exists to + prevent exactly that); and ``allow_edge``'s ``var == c["var"]`` test could never match a param + edge, so a scoped variable cut was structurally incapable of cutting at a call boundary -- which + is the capability this leg was rebased onto the new pins to obtain. + """ + adjacency = slice_l4._sdg()[0]["forward"] + params = [(rel, var) for outs in adjacency.values() for labels in outs.values() for rel, var, _prov in labels if rel.startswith("PY_PARAM")] + assert params, "the fixture has a call, so it has param edges; without them this asserts nothing" + assert all(var for _rel, var in params), f"a param edge reached the adjacency with no var: {params}" + assert ("PY_PARAM_IN", "x") in params, "the argument hop carries the callee formal's name" + assert ("PY_PARAM_OUT", "") in params, "the return hop carries the analyzer's return var" + assert "x" in slice_l4._edge_vars_in(slice_l4.resolve_callable("Portal.charge").ref), "a call-crossing variable is invisible to a sanitizer that names it" + + +#: Two routes from one parameter to one sink -- a short one through a sanitizing call and a longer +#: one that does not -- which is the shape that separates a correct sanitizer cut from a plausible +#: one, and the shape ``taint`` is for. Deliberately the leg-4b Neo4j fixture's own source minus its +#: class: measured to answer identically (6 witnesses, the same +#: ``data data return data data argument`` chain on each), so what the container-gated file proves +#: about the graph, this proves about the local walk for free. +@pytest.fixture(scope="module") +def two_route_project(tmp_path_factory): + root = tmp_path_factory.mktemp("taint") + (root / "src").mkdir() + (root / "src" / "app.py").write_text( + textwrap.dedent( + """ + def scrub(raw): + return "".join(ch for ch in raw if ch.isalnum()) + + + def relay(raw): + return raw + + + def wrap(mid): + return "[" + mid + "]" + + + def run_query(cleaned, note): + return "SELECT " + cleaned + " -- " + note + + + def handle(user_input): + answer = scrub(user_input) # short route: one call between the ports + hop = relay(user_input) # long route: two calls between the ports + note = wrap(hop) + return run_query(answer, note) + """ + ).lstrip() + ) + return root + + +@pytest.fixture(scope="module") +def taint_l4(two_route_project, tmp_path_factory) -> PyCodeanalyzer: + return _backend(two_route_project, tmp_path_factory.mktemp("cache-taint"), AnalysisLevel.system_dependency_graph) + + +#: As a caller writes them. A *caller's* parameter is unusable as a source (codeanalyzer-python#204: +#: its ``@formal_in`` port and its ``@entry`` def-site are disjoint upstream), so both sources are a +#: callee's -- the same choice the container-gated file documents at length. +TAINT_SOURCES = [("raw", "scrub"), ("raw", "relay")] +TAINT_SINKS = [("cleaned", "run_query"), ("note", "run_query")] +#: The one pair with two witnesses, so it is the only one a cap of 1 can be measured on. +TAINT_PAIR = ([("raw", "scrub")], [("cleaned", "run_query")]) + + +def test_the_local_walk_finds_the_measured_witnesses_and_refutes_nothing(taint_l4): + """The anchor the rest of this group narrows: 2 + 2 + 1 + 1 witnesses over four pairs, each + crossing two call boundaries in both directions. A walk that stopped at a call boundary would + still return rows; only the chain says it went in and came back out.""" + r = taint_l4.taint(TAINT_SOURCES, TAINT_SINKS, max_paths=10) + assert sorted(tuple(h.via for h in p.hops) for p in r.paths) == [("data", "data", "return", "data", "data", "argument")] * 6 + assert r.exhausted == [] and r.complete is True + + +def test_the_local_walk_returns_one_row_past_the_cap_so_truncation_is_never_silent(taint_l4): + """A walk that caps at ``max_paths`` rather than ``max_paths + 1`` returns a full-looking result + with ``complete=True`` -- a silent bound, which E5 forbids. ``taint()`` cannot detect it, so the + walk is tested here or nowhere.""" + at_one = taint_l4.taint(*TAINT_PAIR, max_paths=1) + assert len(at_one.paths) == 1 and at_one.complete is False + at_two = taint_l4.taint(*TAINT_PAIR, max_paths=2) + assert len(at_two.paths) == 2 and at_two.complete is True + + +def test_the_local_cap_keeps_a_prefix_of_one_total_order(taint_l4): + """*Which* witness survives is stated, not incidental. ``shortest_walks``' replay sorts equal + length branches by ``(via, var, to)`` -- the same components ``hop_sort_key`` documents and the + same the Cypher ``ORDER BY length(p), key`` produces -- so the cap is a prefix of a total order + rather than whichever walks the recursion happened to reach first. ```` and not + ``app::ch.*`` is a measured value: the two witnesses of this pair differ at hop 2, and ``'<'`` + sorts before ``'a'``.""" + one, many = taint_l4.taint(*TAINT_PAIR, max_paths=1), taint_l4.taint(*TAINT_PAIR, max_paths=5) + assert one.paths == many.paths[:1], "the local taint cap is not a prefix of one total order" + assert one.paths[0].hops[1].var == "" + + +def test_the_local_walk_runs_once_per_distinct_pair_not_once_per_selector(taint_l4): + """Two selectors naming one position are one pair. Walking it twice would report every witness + twice and make a cap of *m* yield *2m* -- the graph side gets this free from ``a.id IN $srcs``, + so the local side has to deduplicate to match.""" + once = taint_l4.taint(*TAINT_PAIR, max_paths=10) + twice = taint_l4.taint([("raw", "scrub"), ("raw", "scrub")], [("cleaned", "run_query")], max_paths=10) + assert len(twice.paths) == len(once.paths) == 2 + + +def test_a_local_callable_cut_severs_its_own_pair_and_leaves_the_sibling_alone(taint_l4): + """Over-cutting is the one output this leg refuses, so a cut that closed *both* pairs would pass + a naive "the sanitizer worked" assertion while being the failure.""" + r = taint_l4.taint(TAINT_SOURCES, [("note", "run_query")], sanitizers=["scrub"]) + scrub_src = taint_l4.resolve_value("raw", within="scrub").ref + relay_src = taint_l4.resolve_value("raw", within="relay").ref + assert len(r.paths) == 1 and r.paths[0].hops[0].frm.ref == relay_src + assert all(scrub_src != h.frm.ref for p in r.paths for h in p.hops) + assert r.exhausted == [("raw", "note")] and r.complete is True + + +def test_a_local_callable_cut_that_contains_the_source_yields_no_walk(taint_l4): + """``allow_node`` is checked against ``src`` up front, because ``src`` is never itself a + ``steps()`` destination for either pass to filter. Without that check a source inside a cut + callable would still emit its first hop.""" + r = taint_l4.taint([("raw", "relay")], [("note", "run_query")], sanitizers=["relay"]) + assert r.paths == [] and r.exhausted == [("raw", "note")] and r.complete is True + + +def test_a_local_variable_cut_severs_a_call_boundary_and_only_the_pairs_that_cross_it(taint_l4): + """The scoped variable cut, end to end, and the assertion that fails without the param-edge + ``var`` above: ``cleaned`` is ``run_query``'s formal, reached from ``handle`` across a + ``PY_PARAM_IN``, so cutting it inside ``handle`` is a cut *at* a call boundary. With ``var`` + hardcoded ``None`` this raised ``SelectorNotInGraph`` -- ``_edge_vars_in('handle')`` could not + see the variable at all -- so the whole scoped-variable-cut mechanism was unreachable from the + local backend and no test noticed. + + Only the two ``cleaned`` pairs are refuted; the two ``note`` pairs keep both their witnesses, + which is what distinguishes a scoped cut from a cut on every hop in the callable.""" + r = taint_l4.taint(TAINT_SOURCES, TAINT_SINKS, sanitizers=[("cleaned", "handle")], max_paths=10) + assert len(r.paths) == 3, "the two note-sink pairs survive: 2 + 1 witnesses" + assert {p.hops[-1].to.ref for p in r.paths} == {taint_l4.resolve_value("note", within="run_query").ref} + assert r.exhausted == [("raw", "cleaned"), ("raw", "cleaned")], "both sources are spelled 'raw'; roots tell them apart" + assert r.complete is True + + +def test_a_local_variable_cut_is_scoped_to_the_callable_it_names(taint_l4): + """The same name, cut inside a callable this walk's hops do not leave: ``mid`` is real inside + ``wrap`` (so Ruling A admits it) and severs nothing on these six witnesses. An unscoped cut on a + recurring name like ``result`` or ``token`` would sever flows the caller never named -- over-cut, + false refutation.""" + r = taint_l4.taint(TAINT_SOURCES, TAINT_SINKS, sanitizers=[("mid", "wrap")], max_paths=10) + assert len(r.paths) == 6 and r.exhausted == [] + + +def test_a_local_variable_sanitizer_that_names_nothing_still_raises(taint_l4): + """Ruling A widened the domain to edge variables; it did not remove the check. A typo must still + be refused loudly rather than silently cutting nothing.""" + with pytest.raises(SelectorNotInGraph): + taint_l4.taint(TAINT_SOURCES, TAINT_SINKS, sanitizers=[("nosuchvar", "handle")]) + + # ---------------------------------------------------------------------------------------------- # Fix round: which accessors bound themselves by default, and why that is one rule, not five. # ---------------------------------------------------------------------------------------------- diff --git a/tests/analysis/python/test_python_taint.py b/tests/analysis/python/test_python_taint.py index 27914d86..f3333e42 100644 --- a/tests/analysis/python/test_python_taint.py +++ b/tests/analysis/python/test_python_taint.py @@ -254,6 +254,32 @@ def test_roots_are_deduplicated_by_ref_so_one_position_is_audited_once(): assert result.resolved == "f parameter 'x', g parameter 'y'" +def test_the_walk_hook_is_called_once_per_call_with_the_cap_the_caller_wrote(): + """The calling convention every ``_taint_walk`` implementation has to honour, stated at the one + layer that can see it -- and the one Task 7's four new walks are most likely to get wrong. + + Three facts, none of them obvious from the signature. **One call, not one per pair:** the hook + receives the flat resolved source and sink lists and owns the m*n cross product itself, which is + what makes a single round trip possible on the graph backends. **The cap arrives + unincremented:** ``taint()`` passes ``max_paths`` through as written, so the ``+ 1`` that makes + truncation visible is the *walk's* to add, per pair -- a walk that assumes the increment already + happened caps at ``max_paths - 1``, and a walk that forgets it caps at exactly ``max_paths`` and + returns a full-looking result with ``complete=True``, which is the silent bound E5 forbids. + **The lists arrive undeduplicated and positionally aligned** with the selectors the caller wrote, + because ``taint_verdict`` is what deduplicates by resolved position; a walk that assumed + distinct inputs would walk a repeated pair twice. + """ + a, b = _value("x", "f"), _value("y", "g") + backend = _Recording(rows=[(a.ref, b.ref, _witness(a, b))]) + backend.taint([("x", "f"), ("x", "f")], [("y", "g")], max_paths=3, depth=7) + assert len(backend.walks) == 1, "the hook is called once for the batch, not once per pair" + (walk,) = backend.walks + assert walk["max_paths"] == 3, "the walk owns the + 1; taint() must not pre-apply it" + assert walk["depth"] == 7 + assert walk["srcs"] == [a.ref, a.ref], "duplicates reach the walk; taint_verdict dedups the pairs" + assert walk["dsts"] == [b.ref] + + def test_the_two_walk_hooks_are_stubs_rather_than_abstract_methods(): """Ruling G: an abstract method here would make every concrete backend un-instantiable until the last implementation lands, so they raise instead. Task 7 flips them, and this test is what says From f4e9bb69601150ce52c243b40b9ed6c88131f8ca Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Thu, 10 Sep 2026 08:16:30 -0400 Subject: [PATCH 35/65] docs(taint): the empty ledger is a refusal to file, not a missing signal Both `_taint_walk` docstrings justified `return rows, {}` with "there is no frontier signal here". Two signals exist, and both are cheap to read: locally `PyCallsite.callee_signature is None` on an unresolved site, and on the graph a `kind:'call'` body node with no outgoing `PY_RESOLVES_TO`. Measured on the leg-4b fixture: 6 call nodes, 5 resolved, 1 not. The behaviour stays. Filing a diagnostic voids `exhausted` for the whole batch under Ruling I, so a signal that cannot yet be told apart from an ordinary call into a library would refute nothing, ever. Say that instead: the gap is the confidence to act on the signal, not the observation, and telling "unresolved dispatch" from "resolved external" is future work. "No signal exists" invites nobody to revisit it. --- .../python/codeanalyzer/codeanalyzer.py | 13 ++++++--- cldk/analysis/python/neo4j/neo4j_backend.py | 29 +++++++++++++------ 2 files changed, 29 insertions(+), 13 deletions(-) diff --git a/cldk/analysis/python/codeanalyzer/codeanalyzer.py b/cldk/analysis/python/codeanalyzer/codeanalyzer.py index ec3da994..818c2181 100644 --- a/cldk/analysis/python/codeanalyzer/codeanalyzer.py +++ b/cldk/analysis/python/codeanalyzer/codeanalyzer.py @@ -1462,10 +1462,15 @@ def _taint_walk(self, srcs, dsts, *, cuts, cut_callables, depth, max_paths): cut severs only the callable the caller named it in. Both are ``None`` when nothing is sanitized -- the documented "no filtering" default, and no per-node cost on the common call. - The ledger comes back empty for the graph backend's measured reason - (:meth:`~cldk.analysis.python.neo4j.neo4j_backend.PyNeo4jBackend._taint_walk`): an - unresolved dispatch emits no dependence edge at all, so the frontier is an absence here too, - and the analyzer's own ``call_sites`` do not say which callee it failed to resolve either. + The ledger comes back empty, and the graph backend's twin states the reason in full + (:meth:`~cldk.analysis.python.neo4j.neo4j_backend.PyNeo4jBackend._taint_walk`). In short: a + signal does exist here, as ``PyCallsite.callee_signature is None`` -- the leg-4b fixture's + ``scrub`` has two call sites and exactly one reads that way, matching the 5-of-6 the graph + measures -- so this is a refusal to file rather than an absence to report. Filing a + diagnostic voids ``exhausted`` for the whole batch (Ruling I), and a signal that cannot yet + be told apart from an ordinary call into a library would void every refutation in every + application that makes one. Same consequence, equally uncatchable from in here: a pair whose + flow leaves through an unresolved call is certified ``exhausted``. """ self._require_dataflow() adjacency, nodes = self._sdg() diff --git a/cldk/analysis/python/neo4j/neo4j_backend.py b/cldk/analysis/python/neo4j/neo4j_backend.py index 3b1aad40..7f63b9d1 100644 --- a/cldk/analysis/python/neo4j/neo4j_backend.py +++ b/cldk/analysis/python/neo4j/neo4j_backend.py @@ -1676,16 +1676,27 @@ def _taint_walk(self, srcs, dsts, *, cuts, cut_callables, depth, max_paths): avoid, and a Python-side sort would silently disagree with :func:`~cldk.analysis.commons.graphs.path_order`. - **The ledger comes back empty, and that is a measured limitation rather than a shortcut.** - An unresolved dispatch is not observable from inside this walk: when the analyzer cannot - resolve a call it emits no ``PY_PARAM_IN``/``PY_PARAM_OUT`` for it at all, so the frontier - is an *absence* of edges, indistinguishable here from a call that genuinely passes nothing - tainted. The graph's ``:PyExternal`` ghosts are the opposite case -- a call resolved *to* - something outside the project -- and reporting those as frontier findings would file a - diagnostic against every pair in any application that calls a library function, emptying - ``exhausted`` for all of them (Ruling I) and destroying the refutation this accessor is for. + **The ledger comes back empty, and that is a deliberate refusal rather than a missing + signal.** A frontier signal does exist, on this backend and on the local one: here an + unresolved dispatch leaves a ``kind:'call'`` body node with no outgoing ``PY_RESOLVES_TO``, + and in process it is ``PyCallsite.callee_signature is None``. Measured on the leg-4b + fixture, 5 of its 6 call nodes resolve and one does not, so the signal is real and cheap to + read -- what is missing is the confidence to act on it, not the observation. + + It is not filed because the granularity is wrong in the one direction that matters. A + diagnostic empties ``exhausted`` for the *whole batch* (Ruling I, see + :func:`~cldk.analysis.commons.graphs.taint_verdict`), so a frontier signal that also fires + on the ordinary case -- a call resolved to something outside the project, which on this + graph is a ``PY_RESOLVES_TO`` into a ``:PyExternal`` ghost -- would void every refutation in + every application that calls a library function. This fixture suggests the two cases are + separable (the external call carries its target, the unresolved one carries nothing), but + six call sites are not a corpus, and a refutation instrument may not be wired to a signal on + that evidence. Telling "unresolved dispatch" apart from "resolved external" well enough to + file only the first is future work, and leg 4b's corpus check is where the separation gets + measured -- deferring to it is the plan, not the justification. + Consequence, stated because nothing here can catch it: a pair whose flow leaves through an - unresolved call is certified ``exhausted``. That is what leg 4b's corpus check is for. + unresolved call is certified ``exhausted``. """ rows = self._run( self._TAINT.format(rels=SDG_REL_PATTERN, depth="" if depth is None else depth), From 57213e421f95e172e7e2366e0c21f9b37be22f52 Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Thu, 10 Sep 2026 08:16:37 -0400 Subject: [PATCH 36/65] test(taint): assert which witness survives the cap, on both walks `_chains` collapses each witness to its `via` chain, and all six witnesses on this fixture share one chain, so every ordering assertion in the file was blind to order by construction. The cap test counted rows and never asked which row. Adds the survivor (`hops[1].var == ""`, measured), the prefix property `at_one.paths == many.paths[:1]` per backend, and a cross-backend test that the local replay's `sorted(..., key=(via, var, to))` and Cypher's `ORDER BY length(p), key` rank the same two witnesses the same way -- the only thing that would catch the two disagreeing about a tie, since `taint_verdict` truncates with `witnesses[:max_paths]`. The cross-backend comparison drops the `can://` application segment: a local run names the application after the project directory and the graph after the `PyApplication` it was imported as, and that is the only field the two disagree on. --- .../analysis/python/test_python_taint_live.py | 60 +++++++++++++++++-- 1 file changed, 56 insertions(+), 4 deletions(-) diff --git a/tests/analysis/python/test_python_taint_live.py b/tests/analysis/python/test_python_taint_live.py index 74c960c1..871889af 100644 --- a/tests/analysis/python/test_python_taint_live.py +++ b/tests/analysis/python/test_python_taint_live.py @@ -92,6 +92,11 @@ def _fixture_graph_present() -> bool: SOURCES = [("raw", "scrub"), ("raw", "relay")] SINKS = [("cleaned", "run_query"), ("note", "run_query")] +#: The one pair on this fixture with more than one witness, so the only pair whose *ordering* is +#: observable: ``raw@scrub -> cleaned@run_query`` has two, and they separate in +#: :func:`~cldk.analysis.python.backend.hop_sort_key` at hop 2 (```` against ``app::ch.*``). +CAP_PAIR = ([("raw", "scrub")], [("cleaned", "run_query")]) + # ``.backend`` and not the facade: ``taint()`` is a backend method until the facade method lands # (leg 4b Task 8 -- ``PythonAnalysis`` delegates one accessor at a time, and this is the last one), @@ -113,10 +118,32 @@ def local(): def _chains(result): """A result's witnesses as sorted ``via``-chains -- the vocabulary a caller reads, and the one - thing two backends can be compared in without comparing their ids.""" + thing two backends can be compared in without comparing their ids. + + Sorted, and ``via`` only, so this is **blind to order by construction**: all six witnesses on + this fixture share the chain ``("data","data","return","data","data","argument")``. Anything + about *which* witness came first has to use :func:`_keys`. + """ return sorted(tuple(h.via for h in p.hops) for p in result.paths) +def _keys(result): + """A result's witnesses in order, each collapsed to what ``hop_sort_key`` actually ranks on. + + ``(via, var, position)`` per hop -- the triple + :func:`~cldk.analysis.python.backend.hop_sort_key` builds, which is what makes a tie-break + disagreement between the local replay's ``sorted(..., key=(via, var, to))`` and Cypher's + ``ORDER BY length(p), key`` visible. Unsorted, unlike :func:`_chains`: the order *is* the claim. + + The ``can://`` application segment is dropped because the two backends legitimately disagree + about it and only about it -- a local run names the application after the project directory + (``proj``), and the graph after the ``PyApplication`` it was imported as (``leg4b``) -- so + ``ref.split("/", 3)[3]`` keeps the whole addressable position and discards the one field that is + a naming fact rather than an ordering one. Everything below it is identical, measured. + """ + return [[(h.via, h.var, h.to.ref.split("/", 3)[3]) for h in p.hops] for p in result.paths] + + def test_both_backends_agree_on_the_witnesses_and_on_the_refutations(local, graph): """Agreeing on a predicate is not agreeing on a set: assert the hop chains and the exhausted pairs, not truthiness.""" @@ -143,11 +170,19 @@ def test_the_walk_returns_one_row_past_the_cap_so_truncation_is_never_silent(req with ``complete=True`` -- a silent bound, which E5 exists to forbid. ``taint()`` cannot detect it, so each of the five implementations is tested here or nowhere.""" backend = request.getfixturevalue(backend_name) - pair = ([("raw", "scrub")], [("cleaned", "run_query")]) - at_one = backend.taint(*pair, max_paths=1) + at_one = backend.taint(*CAP_PAIR, max_paths=1) assert len(at_one.paths) == 1 and at_one.complete is False - at_two = backend.taint(*pair, max_paths=2) + at_two = backend.taint(*CAP_PAIR, max_paths=2) assert len(at_two.paths) == 2 and at_two.complete is True + # *Which* witness survived, not just how many. A cap that kept the other one passes every count + # assertion above, and a caller who reads ``paths[0]`` as "the shortest route" reads a walk the + # ordering never promised. ```` is measured, not guessed: hop 2 of the surviving witness + # goes out through ``scrub``'s formal_out, and its sibling goes through the module-global + # ``app::ch.*`` -- and ``'<'`` sorts before ``'a'``, which is the whole tie-break. + assert at_one.paths[0].hops[1].var == "" + many = backend.taint(*CAP_PAIR, max_paths=5) + assert at_one.paths == many.paths[:1], "the taint cap is not a prefix of one total order" + assert len(many.paths) == 2 and many.complete is True @pytest.mark.parametrize("backend_name", ["local", "graph"]) @@ -178,3 +213,20 @@ def test_a_callable_sanitizer_cuts_its_own_pair_and_leaves_the_sibling_alone(req # exactly why ``roots`` is what tells two same-named pairs apart. assert r.exhausted == [("raw", "note")] assert r.complete is True, "one pair refuted and one witnessed is a clean batch" + + +def test_both_backends_rank_the_two_witnesses_the_same_way(local, graph): + """The cap test above holds each backend to its own order; nothing yet holds the two to *each + other's*. + + Two implementations of one ordering can each be internally consistent and still disagree about + a tie -- the local replay sorts branches by ``(via, var, to)`` in Python, the graph sorts whole + paths by ``path_order(P)`` in Cypher, and on a tie the two could hand a caller different + ``paths[0]`` from the same question. Since ``taint_verdict`` truncates with + ``witnesses[:max_paths]``, that disagreement is exactly a disagreement about which witness a + capped result reports, and ``_chains`` cannot see it. + """ + got = {name: (_keys(b.taint(*CAP_PAIR, max_paths=1)), _keys(b.taint(*CAP_PAIR, max_paths=5))) for name, b in (("local", local), ("graph", graph))} + assert got["local"] == got["graph"], "the two walks rank the same two witnesses differently" + capped, full = got["local"] + assert len(full) == 2 and capped == full[:1] From 531d8c67af982ece8b02dfae47968e0f69e97206 Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Thu, 10 Sep 2026 08:18:20 -0400 Subject: [PATCH 37/65] refactor(neo4j): name _EDGE_VARS' callable scope apart from $prefix Twenty statements bind `prefix=self._scope_prefix`; `_EDGE_VARS` bound `prefix=callable_id`. The multi-application audit classified it as prefix-scoped and the conclusion was right -- a callable `can://` id embeds the application name, so a node in a second application cannot start with it -- but for a property of the bound value rather than the convention the audit checks. The invariant was a coincidence that happened to read as a rule. `$callable_prefix` makes the two scopes distinguishable, and the audit's expectation table now lists it as its own spelling with the reason it counts. Verified the rename is load-bearing: the old regex does not match the renamed statement, so the classification had to be extended rather than inherited. Also corrects the comment's claim that this predicate matches `_TAINT`'s cut "exactly" -- it is a bare `STARTS WITH` against `under_callable`'s delimited test since the callable-cut fix, which under-cuts (a variable accepted that the cut cannot match severs nothing) rather than over-cuts. --- cldk/analysis/python/neo4j/neo4j_backend.py | 25 +++++++++++++------ .../test_neo4j_multi_application_scope.py | 16 +++++++++--- 2 files changed, 29 insertions(+), 12 deletions(-) diff --git a/cldk/analysis/python/neo4j/neo4j_backend.py b/cldk/analysis/python/neo4j/neo4j_backend.py index 7f63b9d1..d0125502 100644 --- a/cldk/analysis/python/neo4j/neo4j_backend.py +++ b/cldk/analysis/python/neo4j/neo4j_backend.py @@ -1656,14 +1656,23 @@ def paths_between(self, src: str, dst: str, *, src_within: str, dst_within: str, "c_line: head([(c:PyCallable)-[:PY_HAS_BODY_NODE]->(n) | c.start_line])", ) - #: The variable names on SDG edges *leaving* a node inside ``$prefix`` -- ``startNode``, matching - #: :attr:`_TAINT`'s cut predicate exactly, so a sanitizer this validates is one that predicate - #: can actually match (Ruling A / :func:`~cldk.analysis.commons.resolve.resolve_sanitizers`). + #: The variable names on SDG edges *leaving* a node inside ``$callable_prefix`` -- ``startNode``, + #: the same end of the hop :attr:`_TAINT`'s cut predicate reads, so a sanitizer this validates is + #: one that predicate can actually match (Ruling A / + #: :func:`~cldk.analysis.commons.resolve.resolve_sanitizers`). #: - #: ``$prefix`` here is a **callable's** ``can://`` ref, not the application's: narrower than the - #: usual scope and application-stamped by the same construction, since a callable id embeds the - #: application. One round trip per variable sanitizer, which is as often as a caller writes one. - _EDGE_VARS = "MATCH (n:PyBodyNode)-[r:{rels}]->() WHERE n.id STARTS WITH $prefix RETURN collect(DISTINCT r.var) AS vars" + #: The parameter is named apart from every other statement's ``$prefix`` because it holds a + #: different thing: a **callable's** ``can://`` ref, not the application's. It is still + #: application-scoped, by construction rather than by convention -- a callable id embeds the + #: application name -- and ``test_neo4j_multi_application_scope.py`` classifies it on that basis. + #: + #: A bare ``STARTS WITH``, deliberately wider than :attr:`_TAINT`'s delimited cut + #: (:func:`~cldk.analysis.commons.graphs.under_callable`): a sibling callable whose name merely + #: starts with this one contributes its edge vars here, so a variable may be *accepted* that the + #: cut cannot then match. That direction under-cuts -- a cut that severs nothing over-reports -- + #: and the one this leg must refuse is the other. One round trip per variable sanitizer, which is + #: as often as a caller writes one. + _EDGE_VARS = "MATCH (n:PyBodyNode)-[r:{rels}]->() WHERE n.id STARTS WITH $callable_prefix RETURN collect(DISTINCT r.var) AS vars" def _taint_walk(self, srcs, dsts, *, cuts, cut_callables, depth, max_paths): """The sanitized shortest walks, server-side (see :meth:`PythonAnalysisBackend._taint_walk`). @@ -1724,7 +1733,7 @@ def _edge_vars_in(self, callable_id: str) -> FrozenSet[str]: ``None`` would be answering a question no caller can ask -- ``resolve_sanitizers`` refuses a blank variable before it gets here. """ - rows = self._run(self._EDGE_VARS.format(rels=SDG_REL_PATTERN), prefix=callable_id) + rows = self._run(self._EDGE_VARS.format(rels=SDG_REL_PATTERN), callable_prefix=callable_id) return frozenset(v for v in rows[0]["vars"] if v) def call_paths_between(self, src: str, dst: str, *, depth: int | None = None, max_paths: int = DEFAULT_MAX_PATHS) -> FlowPaths: diff --git a/tests/analysis/python/test_neo4j_multi_application_scope.py b/tests/analysis/python/test_neo4j_multi_application_scope.py index 3ae675c4..e975e40e 100644 --- a/tests/analysis/python/test_neo4j_multi_application_scope.py +++ b/tests/analysis/python/test_neo4j_multi_application_scope.py @@ -155,11 +155,19 @@ def _node(module: str, key: str, **props: Any) -> Dict[str, Any]: "PyExternal": [{"id": f"can://{app}/@external/os/path", "name": "path", "module": "os"} for app in (APP_A, APP_B)], } -#: The three spellings of the application scope a statement may carry: the whole application -#: (``$prefix``); for a narrowed bulk fetch, a list of per-module prefixes (``$prefixes``); and for +#: The four spellings of the application scope a statement may carry: the whole application +#: (``$prefix``); for a narrowed bulk fetch, a list of per-module prefixes (``$prefixes``); for #: ``locate``, one module's own prefix per position (``pos.module_prefix``, minted from the same -#: application name). ``file_key IN $mods`` is *not* one: a module key is not application-stamped. -_MATCHES_BY_PREFIX = re.compile(r"\.id STARTS WITH (\$prefix\b|pos\.module_prefix\b)|any\(p IN \$prefixes WHERE \w+\.id STARTS WITH p\)") +#: application name); and for ``_EDGE_VARS``, one **callable's** own ref (``$callable_prefix``). +#: ``file_key IN $mods`` is *not* one: a module key is not application-stamped. +#: +#: The last two are narrower than the application and still satisfy this audit, for the same reason +#: and only that reason: both are minted from an id that already embeds the application name, so a +#: node in a second application cannot start with either. ``$callable_prefix`` is spelled apart from +#: ``$prefix`` on purpose -- it was ``$prefix`` too, which made this classification read as though +#: ``_EDGE_VARS`` were scoped the way its 20 siblings are, when what makes it scoped is a property of +#: the value bound to it. A distinct name keeps the invariant checkable rather than annotated. +_MATCHES_BY_PREFIX = re.compile(r"\.id STARTS WITH (\$prefix\b|\$callable_prefix\b|pos\.module_prefix\b)|any\(p IN \$prefixes WHERE \w+\.id STARTS WITH p\)") def _is_scoped(statement: str) -> bool: From 13709cf0ec57d2887741a641fa8cdc34e3d81272 Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Thu, 10 Sep 2026 08:32:53 -0400 Subject: [PATCH 38/65] test(taint): cover the cut's nested-callable disjunct, and cite it correctly `under_callable`'s `q + "/"` disjunct had no coverage: the one assertion that looked like its test used `create@26:5/actual_in:1`, which starts with `create@` and so is accepted by the `@` disjunct instead. Deleting `startswith(q + "/")` left every test in the file green. Its docstring also cited the wrong evidence for the same reason -- call-site port sub-nodes, which the `@` disjunct already reaches, and a 920 figure that counts containment at file/class/method level rather than anything the disjunct earns. What it earns is the closures written inside a callable. Measured on the committed level-4 TypeScript fixture: of the 33 ids owning body nodes, 5 have a `q + "/"` descendant, every one an anonymous nested callable. `resolve_sanitizers` documents a callable cut as putting "every body node under it" off-limits, and an arrow function's are under it, so dropping the disjunct under-cuts -- the safe direction, which is why nothing else would have caught the loss. --- cldk/analysis/commons/graphs.py | 16 ++++++++++------ tests/analysis/commons/test_taint_semantics.py | 18 ++++++++++++++++++ 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/cldk/analysis/commons/graphs.py b/cldk/analysis/commons/graphs.py index 5c437ebc..3e7c315e 100644 --- a/cldk/analysis/commons/graphs.py +++ b/cldk/analysis/commons/graphs.py @@ -271,13 +271,17 @@ def under_callable(node_id: str, callable_ids: Collection[str]) -> bool: the bare prefix test accepted. codeanalyzer-typescript's graph was measured the same at 125,532 body nodes with 0 exceptions (see ``TSNeo4jBackend._OWN_EDGES``, whose ``$bp`` is ``node.ref + "@"`` for exactly this reason). - * ``q + "/"`` -- everything minted *under* a body node or a nested callable, since a child id is + * ``q + "/"`` -- the body nodes of a callable *nested inside* ``q``, since a child id is ``parent + "/" + key`` (``reconstruct.child_key`` raises if it is not). This is not a - speculative disjunct: a TypeScript call site's port sub-nodes are spelled - ``...create@26:5/actual_in:1``, and there are 920 such joins in the one fixture. Dropping it - would sever a call site from its own arguments and under-cut every interprocedural cut, so it - is here to **preserve** the bare prefix test's reach, exactly as ``resolve_sanitizers`` - documents a callable cut ("every body node under it"). + speculative disjunct, and it is the ``@`` disjunct that shows why it is needed separately: a + call site's own port sub-nodes (``...create@26:5/actual_in:1``) start with ``create@`` and are + already accepted above, but a nested arrow function's are not. Measured on the committed + level-4 TypeScript fixture: of the 33 ids that own body nodes, **5 have a ``q + "/"`` + descendant**, every one an anonymous nested callable -- + ``...controllers.ts/Controller/@entry`` and its four siblings. Dropping this + disjunct would leave a callable cut covering the callable but not the closures written inside + it, which under-cuts, so it is here to **preserve** the bare prefix test's reach, exactly as + ``resolve_sanitizers`` documents a callable cut ("every body node under it"). """ return any(node_id == q or node_id.startswith(q + "@") or node_id.startswith(q + "/") for q in callable_ids) diff --git a/tests/analysis/commons/test_taint_semantics.py b/tests/analysis/commons/test_taint_semantics.py index bd03744d..a77d9d63 100644 --- a/tests/analysis/commons/test_taint_semantics.py +++ b/tests/analysis/commons/test_taint_semantics.py @@ -87,6 +87,24 @@ def test_a_python_callable_cut_accepts_exactly_what_the_bare_prefix_test_accepte assert under_callable(q + key, [q]) +def test_a_callable_cut_reaches_a_callable_nested_inside_it(): + """The ``q + "/"`` disjunct, which is the only one with no other test covering it -- a call + site's port sub-node (``create@26:5/actual_in:1``) starts with ``create@`` and so is already + accepted by the ``@`` disjunct, meaning deleting ``startswith(q + "/")`` leaves every other + assertion in this file green. + + What it actually earns is the closures written *inside* a callable. Measured on the same + committed fixture: of the 33 ids that own body nodes, 5 have a ``q + "/"`` descendant and every + one is an anonymous nested callable. ``resolve_sanitizers`` documents a callable cut as putting + "every body node under it" off-limits, and an arrow function's body nodes are under it -- so + without this disjunct a cut would cover ``Controller`` and not the closure it returns, which + *under*-cuts and merely over-reports. That is the safe direction, which is exactly why nothing + else would have caught its loss.""" + q = "can://slim/typescript/src/controllers.ts/Controller" + for nested in ("/", "/@entry", "/@exit", "/@formal_out", "/@5:16"): + assert under_callable(q + nested, [q]), f"a nested callable's {nested} is under {q}" + + def test_a_callable_cut_names_the_callable_itself_and_takes_a_list(): """The ``= q`` disjunct, and that several cuts are tested disjunctively -- ``$cut_callables`` is a list, and a node under any member is cut.""" From 45f1c214482d0b2c61fbe8a3eb54addfb8ffb72e Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Thu, 10 Sep 2026 09:30:53 -0400 Subject: [PATCH 39/65] feat(typescript): the local taint walk, scoped by callable id and not by prefix ``TSCodeanalyzer._taint_walk`` and ``_edge_vars_in``, off the adjacency the backend already builds and caches. One ``shortest_walks`` call per deduplicated pair, capped at ``max_paths + 1`` so truncation is never silent, with both cuts as the walker's own predicates rather than a filter over what it returns: the breadth-first pass has to measure the shortest *satisfying* distance, or a sanitized short route hides a clean longer one and the pair comes back refuted. Both the node cut and the variable cut scope through ``under_callable``. On TypeScript that is load-bearing rather than stylistic (Ruling K): a callable id ends in a bare member name, so ``.../UserService/create`` is a strict non-delimited prefix of ``.../UserService/createGuest``, and a bare ``startswith`` would sever every hop in a sibling the caller never named -- over-cutting, which in this design certifies a refutation over a live flow. The ledger comes back empty. A frontier signal exists (``TSBodyNode.callee is None`` on a ``call`` node, 0 of the a4 fixture's 31), but filing it voids ``exhausted`` for the whole batch, and it is not yet distinguishable from an ordinary call into an ambient declaration. Ten offline tests on the a4 fixture, every number derived from ``analysis.json`` through ``shortest_walks``: the level gate, the six measured witnesses over two pairs, the cap boundary at n and n+1, the cap as a prefix of one total order, per-pair dedup, a callable cut that severs one pair and leaves its sibling, a callable cut containing the source, a variable cut over a ``TS_PARAM_OUT`` return crossing, the Ruling-K near-miss that must not be cut, and a typo that must still raise. --- .../typescript/codeanalyzer/codeanalyzer.py | 80 +++++++++- .../typescript/test_typescript_dataflow.py | 150 ++++++++++++++++++ 2 files changed, 228 insertions(+), 2 deletions(-) diff --git a/cldk/analysis/typescript/codeanalyzer/codeanalyzer.py b/cldk/analysis/typescript/codeanalyzer/codeanalyzer.py index 3ef90694..a0899f4f 100644 --- a/cldk/analysis/typescript/codeanalyzer/codeanalyzer.py +++ b/cldk/analysis/typescript/codeanalyzer/codeanalyzer.py @@ -35,7 +35,7 @@ from functools import cached_property, partial from pathlib import Path from subprocess import CompletedProcess -from typing import Dict, Iterator, List, Sequence, Set, Tuple, Union +from typing import Dict, FrozenSet, Iterator, List, Sequence, Set, Tuple, Union import networkx as nx @@ -51,7 +51,7 @@ check_page_size, edge_page, ) -from cldk.analysis.commons.graphs import call_reaches, cone_sinks, flow_path, shortest_walks, slice_resolved +from cldk.analysis.commons.graphs import call_reaches, cone_sinks, flow_path, shortest_walks, slice_resolved, under_callable from cldk.analysis.commons.keys import body_key_column, resolve_module_key from cldk.analysis.commons.levels import ANALYZER_LEVELS, LEVEL_NAMES, analyzer_level from cldk.analysis.commons.resolve import CallableCandidate, resolve_callable_signature, resolve_value_name, resolve_within @@ -1338,6 +1338,82 @@ def _call_neighbours(self, name: str, in_class: str | None, in_module: str | Non #: to TypeScript's ``via`` table. _shortest_walks = staticmethod(partial(shortest_walks, via=VIA)) + def _taint_walk(self, srcs, dsts, *, cuts, cut_callables, depth, max_paths): + """The sanitized shortest walks, in process (see :meth:`TSAnalysisBackend._taint_walk`). + + ``self._require_dataflow()`` first, per Ruling F and :meth:`TSAnalysisBackend.taint`'s own + note: the level gate is a local backend's to ask, and asking it after resolution would mean + a level-2 analysis hearing "no such value" from ``resolve_value`` rather than "rebuild at + level 4". + + One :func:`~cldk.analysis.commons.graphs.shortest_walks` call **per pair**, which is what + makes ``max_paths + 1`` a per-pair cap here the way ``collect(p)[0..$cap]`` is one over + Cypher -- a single walk over the flattened source and sink lists would let one prolific pair + starve the rest. Pairs are deduplicated by resolved position first, for the same reason + :func:`~cldk.analysis.commons.graphs.taint_verdict` deduplicates the requested ones: two + selectors naming one position are one pair, and walking it twice would report each witness + twice and make a cap of *m* yield *2m*. The graph side gets that free from ``a.id IN $srcs``. + + Both cuts are :func:`~cldk.analysis.commons.graphs.shortest_walks`' predicates rather than a + filter over the walks it returns, which is the property the whole design rests on: the + breadth-first pass must measure the shortest *satisfying* distance, or a sanitized short + route hides a clean longer one and the pair comes back refuted. ``allow_edge`` reads the + hop's **start** node, mirroring the Cypher predicate's ``startNode(r)`` term, so a variable + cut severs only the callable the caller named it in. Both are ``None`` when nothing is + sanitized -- the documented "no filtering" default, and no per-node cost on the common call. + + Scoping goes through :func:`~cldk.analysis.commons.graphs.under_callable` and never through + ``startswith``, and on TypeScript that is not a stylistic preference (Ruling K): a + TypeScript callable id ends in a bare member name, so ``.../UserService/create`` is a strict + non-delimited prefix of ``.../UserService/createGuest`` and a bare prefix test would sever + every hop in the sibling the caller never named. Python and Java ids end in ``)``, which is + why the Python twin can get away with the shorter spelling. + + The ledger comes back empty, and the graph backend's twin states the reason in full + (:meth:`~cldk.analysis.typescript.neo4j.neo4j_backend.TSNeo4jBackend._taint_walk`). In + short: a signal does exist here, as ``TSBodyNode.callee is None`` on a ``call`` node -- the + same one :attr:`has_resolution_edges` reads at the application level, and measured 0 of the + a4 fixture's 31 call nodes, so the fixture has nothing to file -- so this is a refusal to + file rather than an absence to report. Filing a diagnostic voids ``exhausted`` for the whole + batch (Ruling I), and a signal that cannot yet be told apart from an ordinary call into an + ambient declaration would void every refutation in every application that makes one. Same + consequence, equally uncatchable from in here: a pair whose flow leaves through an + unresolved call is certified ``exhausted``. + """ + self._require_dataflow() + adjacency, nodes = self._sdg + allow_node = (lambda nid: not under_callable(nid, cut_callables)) if cut_callables else None + allow_edge = (lambda frm, _rel, var: not any(var == c["var"] and under_callable(frm, (c["prefix"],)) for c in cuts)) if cuts else None + pairs: Dict[Tuple[str, str], SliceNode] = {} + for a in srcs: + for b in dsts: + pairs.setdefault((a.ref, b.ref), a) + rows = [] + for (src_ref, dst_ref), a in pairs.items(): + walks = self._shortest_walks(adjacency["forward"], src_ref, dst_ref, depth, max_paths + 1, allow_edge=allow_edge, allow_node=allow_node) + described = {ref: self._slice_node(ref) for walk in walks for ref, _ in walk if ref in nodes} + described[src_ref] = a + rows.extend((src_ref, dst_ref, flow_path([described[src_ref]] + [described[ref] for ref, _ in walk], [label for _, label in walk], via=VIA)) for walk in walks) + return rows, {} + + def _edge_vars_in(self, callable_id: str) -> FrozenSet[str]: + """The edge variables scoped to this callable (see :meth:`TSAnalysisBackend._edge_vars_in`). + + Off the adjacency this backend already builds and caches, so a variable sanitizer costs a + scan of it and no second traversal. Edges *leaving* a node under ``callable_id`` -- the same + ``startNode`` scoping the cut itself uses, so this validates exactly the domain the cut can + match. Two of the five relationship types carry no ``var`` (``TS_CDG`` and ``TS_SUMMARY``); + those ``None``\ s are dropped, because ``resolve_sanitizers`` refuses a blank variable + before it asks. + + :func:`~cldk.analysis.commons.graphs.under_callable` and not ``startswith`` for Ruling K's + reason, spelled out in :meth:`_taint_walk`: with a bare prefix test ``create``'s domain would + silently include every variable of ``createGuest``, and a sanitizer naming one of those would + be *accepted* here and then cut nothing there -- a sanitizer the caller believes is in force. + """ + forward = self._sdg[0]["forward"] + return frozenset(var for src, outs in forward.items() if under_callable(src, (callable_id,)) for labels in outs.values() for _rel, var, _prov in labels if var) + def paths_between(self, src: str, dst: str, *, src_within: str, dst_within: str, depth: int | None = None, max_paths: int = DEFAULT_MAX_PATHS) -> FlowPaths: """How a value reaches another value (see :meth:`TSAnalysisBackend.paths_between`).""" check_depth(depth) diff --git a/tests/analysis/typescript/test_typescript_dataflow.py b/tests/analysis/typescript/test_typescript_dataflow.py index 701e0970..703912a8 100644 --- a/tests/analysis/typescript/test_typescript_dataflow.py +++ b/tests/analysis/typescript/test_typescript_dataflow.py @@ -53,6 +53,8 @@ CREATE = "src/services.UserService.create" NEXT_ID = "src/services.nextId" ENTITY_CTOR = "src/models.Entity.constructor" +MAKE_GUEST = "src/services.makeGuestName" +CREATE_GUEST = "src/services.UserService.createGuest" def _fake_run_writing_output(payload: str): @@ -299,3 +301,151 @@ def test_every_predicate_and_path_accessor_type_checks_depth(ts): ): with pytest.raises(ValueError, match="depth"): call() + + +# ---------------------------------------------------------------------------------------------- +# Leg 4b, Task 7: the local ``taint`` walk, offline. +# +# ``taint()`` is a refutation instrument: its ``exhausted`` list certifies "no flow exists between +# this source and this sink", so over-cutting is far worse than under-cutting -- cutting too much +# removes paths, removing paths adds pairs to ``exhausted``, and a wrong ``exhausted`` closes an +# alert on a live flow. Every assertion below is therefore two-sided: the cut severed what it named +# *and* left alone what it did not. +# +# No graph and no container; only the a4 fixture the rest of this file already uses. ``taint`` is +# not on the facade yet, so the backend is called directly. Every number was derived by walking +# ``analysis.json``'s ``ddg``/``cdg``/``summary`` lists and the application's ``param_in``/ +# ``param_out`` overlays through ``shortest_walks`` -- never by running ``_taint_walk`` and copying +# what it printed. +# ---------------------------------------------------------------------------------------------- +#: As a caller writes them. Both sources are a *callee's* parameter, per Ruling J: a caller's own +#: parameter has its ``@formal_in`` port disjoint from its ``@entry`` def-site upstream, so it is +#: unusable as a source on any of the three analyzers. +TAINT_SOURCES = [("id", SHOW), ("seed", MAKE_GUEST)] +TAINT_SINKS = [("n", NEXT_ID)] +#: The pair with two witnesses of equal length -- the only one a cap of 1 can be measured on. +TAINT_PAIR = ([("id", SHOW)], [("n", NEXT_ID)]) + + +def test_the_local_taint_walk_opens_with_the_same_level_gate(ts_a2): + """Ruling F: ``taint()`` carries no level gate of its own on this backend -- the graph backend + has no level to measure -- so the local walk is where a below-level-4 caller is refused, or + nowhere. The hook is called directly because the gate is its first statement, before any + selector is resolved: asking it later would make a level-2 caller hear "no such value" from + ``resolve_value`` rather than "rebuild at level 4".""" + with pytest.raises(CodeanalyzerUsageException) as e: + ts_a2.backend._taint_walk([], [], cuts=[], cut_callables=[], depth=None, max_paths=1) + assert "program_dependency_graph" in str(e.value) + + +def test_the_local_walk_finds_the_measured_witnesses_and_refutes_nothing(ts): + """The anchor the rest of this group narrows. Two pairs: ``show``'s ``id`` reaches ``nextId``'s + ``n`` by two four-hop routes that differ only in which field of ``UserService`` the argument is + computed from, and ``makeGuestName``'s ``seed`` reaches it by four eight-hop routes that leave + ``makeGuestName`` through its return, pass through ``createGuest`` and enter ``create``. A walk + that stopped at a call boundary would still return the first two rows; only the eight-hop + chains say it crossed one in both directions.""" + r = ts.backend.taint(TAINT_SOURCES, TAINT_SINKS, max_paths=10) + assert sorted(tuple(h.via for h in p.hops) for p in r.paths) == [ + ("data", "argument", "data", "argument"), + ("data", "argument", "data", "argument"), + ("data", "data", "return", "data", "control", "argument", "data", "argument"), + ("data", "data", "return", "data", "control", "argument", "data", "argument"), + ("data", "data", "return", "data", "data", "argument", "data", "argument"), + ("data", "data", "return", "data", "data", "argument", "data", "argument"), + ] + assert r.exhausted == [] and r.complete is True + + +def test_the_local_walk_returns_one_row_past_the_cap_so_truncation_is_never_silent(ts): + """A walk that caps at ``max_paths`` rather than ``max_paths + 1`` returns a full-looking result + with ``complete=True`` -- a silent bound, which E5 forbids. ``taint()`` cannot detect it from the + rows it is handed, so the extra row is tested here or nowhere.""" + at_one = ts.backend.taint(*TAINT_PAIR, max_paths=1) + assert len(at_one.paths) == 1 and at_one.complete is False + at_two = ts.backend.taint(*TAINT_PAIR, max_paths=2) + assert len(at_two.paths) == 2 and at_two.complete is True + + +def test_the_local_cap_keeps_a_prefix_of_one_total_order(ts): + """*Which* witness survives is stated, not incidental. ``shortest_walks``' replay sorts equal + length branches by ``(via, var, to)`` -- the components ``hop_sort_key`` documents, and the same + order the Cypher ``ORDER BY length(p), key`` produces -- so the cap is a prefix of a total order + rather than whichever branch the recursion reached first. The two witnesses of this pair are + both four hops and first differ at hop 3, where ``this.startId`` sorts before + ``this.users.length``.""" + one, many = ts.backend.taint(*TAINT_PAIR, max_paths=1), ts.backend.taint(*TAINT_PAIR, max_paths=5) + assert one.paths == many.paths[:1], "the local taint cap is not a prefix of one total order" + assert [h.var for h in one.paths[0].hops] == ["id", NEXT_ID, "this.startId", "n"] + + +def test_the_local_walk_runs_once_per_distinct_pair_not_once_per_selector(ts): + """Two selectors naming one position are one pair. Walking it twice would report every witness + twice and make a cap of *m* yield *2m* -- the graph side gets this free from ``a.id IN $srcs``, + so the local side has to deduplicate to match.""" + once = ts.backend.taint(*TAINT_PAIR, max_paths=10) + twice = ts.backend.taint([("id", SHOW), ("id", SHOW)], [("n", NEXT_ID)], max_paths=10) + assert len(twice.paths) == len(once.paths) == 2 + + +def test_a_local_callable_cut_severs_its_own_pair_and_leaves_the_sibling_alone(ts): + """Over-cutting is the one output this leg refuses, so a cut that closed *both* pairs would pass + a naive "the sanitizer worked" assertion while being the failure. ``createGuest`` sits on every + route out of ``makeGuestName`` and on none of ``show``'s, so exactly one pair is refuted.""" + r = ts.backend.taint(TAINT_SOURCES, TAINT_SINKS, sanitizers=[CREATE_GUEST], max_paths=10) + guest = ts.backend.resolve_callable(CREATE_GUEST).ref + assert len(r.paths) == 2, "show's two witnesses survive a cut that names a callable they never enter" + assert all(not h.frm.ref.startswith(guest) and not h.to.ref.startswith(guest) for p in r.paths for h in p.hops) + assert r.exhausted == [("seed", "n")] and r.complete is True + + +def test_a_local_callable_cut_that_contains_the_source_yields_no_walk(ts): + """``allow_node`` is checked against ``src`` up front, because ``src`` is never itself a + ``steps()`` destination for either pass to filter. Without that check a source inside a cut + callable would still emit its first hop.""" + r = ts.backend.taint([("seed", MAKE_GUEST)], TAINT_SINKS, sanitizers=[MAKE_GUEST]) + assert r.paths == [] and r.exhausted == [("seed", "n")] and r.complete is True + + +def test_a_local_variable_cut_severs_a_call_boundary_and_only_the_pairs_that_cross_it(ts): + """The scoped variable cut, end to end, over a hop that is a call boundary: ``$ret`` is the var + the analyzer writes on ``makeGuestName``'s ``TS_PARAM_OUT`` return edge, so cutting it inside + ``makeGuestName`` severs the *return* crossing rather than a statement edge. That only works + because ``TS_PARAM_IN``/``TS_PARAM_OUT`` reach the adjacency carrying their ``var``: with it + hardcoded ``None`` the name would not be in ``_edge_vars_in``'s domain at all and + ``resolve_sanitizers`` would refuse a real dataflow variable (Ruling A exists to prevent that), + and ``allow_edge``'s ``var == c["var"]`` test could never match a param hop. + + ``show``'s two witnesses cross no such edge and are untouched, which is what separates a scoped + cut from a cut on every hop in the application.""" + assert "$ret" in ts.backend._edge_vars_in(ts.backend.resolve_callable(MAKE_GUEST).ref) + r = ts.backend.taint(TAINT_SOURCES, TAINT_SINKS, sanitizers=[("$ret", MAKE_GUEST)], max_paths=10) + assert len(r.paths) == 2 and {p.hops[0].frm.callable for p in r.paths} == {SHOW} + assert r.exhausted == [("seed", "n")] and r.complete is True + + +def test_a_local_variable_cut_is_scoped_by_callable_id_and_not_by_string_prefix(ts): + """Ruling K, on the one fixture that can witness it. ``UserService.create``'s callable id is a + strict, **non-delimited** prefix of ``UserService.createGuest``'s -- a TypeScript callable id ends + in a bare member name, with no ``(`` to terminate it the way Python's and Java's do -- so a cut + scoped with ``id.startswith(prefix)`` would sever every hop inside ``createGuest`` too. + + ``$ret`` is a real edge variable under both, and the seed pair's route leaves + ``createGuest@32:5/actual_out`` on a ``$ret`` hop. Cutting ``$ret`` inside ``create`` must + therefore change nothing: all six witnesses survive. Under a bare prefix test the seed pair + comes back refuted -- a certified "no flow" over a flow that exists, which is the exact failure + ``under_callable`` is in the shared module to prevent. The companion test above cuts the same + variable name inside a callable the routes *do* leave and severs a pair, so the two together say + the scoping is real rather than vacuous.""" + create, guest = ts.backend.resolve_callable(CREATE).ref, ts.backend.resolve_callable(CREATE_GUEST).ref + assert guest.startswith(create) and guest[len(create)] not in "@/", "the fixture no longer carries the collision this test is about" + r = ts.backend.taint(TAINT_SOURCES, TAINT_SINKS, sanitizers=[("$ret", CREATE)], max_paths=10) + assert len(r.paths) == 6 and r.exhausted == [] + + +def test_a_local_variable_sanitizer_that_names_nothing_still_raises(ts): + """Ruling A widened the domain from parameters to edge variables; it did not remove the check. A + typo must be refused loudly rather than silently cutting nothing -- a sanitizer that cuts + nothing reports flows the caller believes were sanitized.""" + with pytest.raises(SelectorNotInGraph): + ts.backend.taint(TAINT_SOURCES, TAINT_SINKS, sanitizers=[("nosuchvar", MAKE_GUEST)]) From 37d78fc4986aef77dd07e1fd5026612a458afa2e Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Thu, 10 Sep 2026 09:43:55 -0400 Subject: [PATCH 40/65] feat(typescript): the graph taint walk, and three scope-audit rules it made honest The Cypher half of the TypeScript walk: one generated statement per batch, the cut carried inside the pattern so it can inline into ShortestPath, and $cap bound one past max_paths so taint_verdict can tell a trimmed answer from a complete one. _EDGE_VARS answers the sanitizer domain over $callable_prefix and drops the nulls that TS_CDG and TS_SUMMARY hops collect. Three rules in the multi-application scope audit had to move, each for a reason worth stating: - test_no_statement_spells_the_scope_with_any banned any() next to a STARTS WITH, which every taint statement now has -- the cut is an any() over a list of cut descriptors and is not the application scope. Narrowed to any() over a scope parameter, which is what the ban was about. - _MATCHES_BY_PREFIX and _SCOPED_VAR did not know $callable_prefix, so _EDGE_VARS read as an unscoped prefix match. What makes it scoped is a property of the bound value -- a callable id embeds the application name. - test_seek_labels_follow_the_measured_rule exempted only {id: $x} from the "no :CanNode on a prefix-scoped statement" rule, so _TAINT's a.id IN $srcs did not read as a point lookup. An IN-list is a unique-index seek per value, the same as an equality; _MATCHES_BY_ID already treats the three spellings alike. This one is an argument, not a measurement: there is no live TypeScript graph to re-measure the plan on. _EDGE_VARS is genuinely prefix-scoped with a range seek, so it names the bare :TSBodyNode label, matching Python's :PyBodyNode. The offline graph tests pin the statement text, the parameter binding and the row translation. They say nothing about what the server does with the statement, which is the verification debt this leg carries for TypeScript. --- .../typescript/neo4j/neo4j_backend.py | 109 +++++++++++++- ...ypescript_neo4j_multi_application_scope.py | 31 +++- .../typescript/test_typescript_taint.py | 138 +++++++++++++++++- 3 files changed, 272 insertions(+), 6 deletions(-) diff --git a/cldk/analysis/typescript/neo4j/neo4j_backend.py b/cldk/analysis/typescript/neo4j/neo4j_backend.py index 957fc924..fc4cf13b 100644 --- a/cldk/analysis/typescript/neo4j/neo4j_backend.py +++ b/cldk/analysis/typescript/neo4j/neo4j_backend.py @@ -133,7 +133,7 @@ class never writes and needs neither the analyzer binary nor the sources. encode_cursor, keyset_where, ) -from cldk.analysis.commons.graphs import cone_sinks, flow_path, path_order, sdg_path_query, slice_resolved +from cldk.analysis.commons.graphs import cone_sinks, flow_path, path_order, sdg_path_query, sdg_taint_query, slice_resolved from cldk.analysis.commons.keys import body_key_column, module_key_of, resolve_module_key from cldk.analysis.commons.resolve import CallableCandidate, resolve_callable_signature, resolve_value_name, resolve_within from cldk.analysis.commons.results import ( @@ -1892,6 +1892,113 @@ def paths_between(self, src: str, dst: str, *, src_within: str, dst_within: str, query = self._PATHS.format(rels=SDG_REL_PATTERN, depth="" if depth is None else depth) return self._paths(query, self._slice_row, a, b, src=a.ref, dst=b.ref, max_paths=max_paths) + #: ``taint()``'s statement: the same shortest-path search as :attr:`_PATHS`, but m sources + #: against n sinks in one traversal, with the sanitizer cut **inside** the pattern and the cap + #: applied per pair. Everything that differs from :attr:`_PATHS` is argued in + #: :func:`~cldk.analysis.commons.graphs.sdg_taint_query`'s own docstring; the two share this + #: backend's ``node_label`` and projection verbatim, which is what makes a taint witness and a + #: ``paths_between`` witness describe a node identically. + #: + #: ``interior_scope`` and no ``endpoint_scope``, matching :attr:`_PATHS`: the endpoints are + #: pinned by ``a.id IN $srcs`` / ``b.id IN $dsts``, and a ``can://`` id embeds the application + #: that minted it, while an interior node reached over an SDG relationship is not provably this + #: application's (the SDG types are deliberately outside the audit's ``_KEEPS_SCOPE``). + _TAINT = sdg_taint_query( + "TS", + node_label="CanNode:TSBodyNode", + interior_scope=_scoped, + projection="ref: n.id, kind: n.kind, of: n.of, line: n.start_line, " + "callable: head([(c:TSCallable)-[:TS_HAS_BODY_NODE]->(n) | c.signature]), " + "c_line: head([(c:TSCallable)-[:TS_HAS_BODY_NODE]->(n) | c.start_line])", + ) + + #: The variable names on SDG edges *leaving* a node inside ``$callable_prefix`` -- ``startNode``, + #: the same end of the hop :attr:`_TAINT`'s cut predicate reads, so a sanitizer this validates is + #: one that predicate can actually match (Ruling A / + #: :func:`~cldk.analysis.commons.resolve.resolve_sanitizers`). + #: + #: The parameter is named apart from this backend's ``$p`` because it holds a different thing: a + #: **callable's** ``can://`` ref, not the application's. It is still application-scoped, by + #: construction rather than by convention -- a callable id embeds the application name -- and + #: ``test_typescript_neo4j_multi_application_scope.py`` classifies it on that basis. + #: + #: A bare ``STARTS WITH``, deliberately wider than :attr:`_TAINT`'s delimited cut + #: (:func:`~cldk.analysis.commons.graphs.under_callable`), and on TypeScript that width is not + #: hypothetical: a callable id ends in a bare member name, so ``create``'s prefix really does + #: reach every edge of ``createGuest``. A variable may therefore be *accepted* here that the cut + #: cannot then match. That direction under-cuts -- a cut that severs nothing over-reports -- and + #: the one this leg must refuse is the other. Widening the domain is also what Ruling A asks for; + #: narrowing it here would refuse a real sanitizer, which is the failure that ruling exists to + #: prevent. One round trip per variable sanitizer, which is as often as a caller writes one. + #: The **bare** ``:TSBodyNode`` and not :attr:`_TAINT`'s ``:CanNode:TSBodyNode``, per the measured + #: seek rule this backend's audit enforces: a ``STARTS WITH`` is a range seek and ``:CanNode`` + #: turns it into a range-seek union, while ``_TAINT`` pins its anchors by id and seeks the unique + #: index. Same reason the Python twin spells it ``:PyBodyNode``. + _EDGE_VARS = "MATCH (n:TSBodyNode)-[r:{rels}]->() WHERE n.id STARTS WITH $callable_prefix RETURN collect(DISTINCT r.var) AS vars" + + def _taint_walk(self, srcs, dsts, *, cuts, cut_callables, depth, max_paths): + """The sanitized shortest walks, server-side (see :meth:`TSAnalysisBackend._taint_walk`). + + One statement for the whole batch, and one row per witness -- ``a.id AS src`` / ``b.id AS + dst`` carry the pairing back, because the m*n batching is only useful if the grouping + survives it. Ordering, the per-pair ``$cap`` and both cuts are the statement's + (:func:`~cldk.analysis.commons.graphs.sdg_taint_query`), so nothing is re-sorted or + re-filtered here: a Python-side filter is the false-refutation bug that function exists to + avoid, and a Python-side sort would silently disagree with + :func:`~cldk.analysis.commons.graphs.path_order`. + + No level gate, per Ruling F: ``--emit neo4j`` takes no ``-a`` and is always full depth, so + this backend has no shallow mode to refuse and nothing to measure -- the attach probe reads + the relationship-type fingerprint, never the dependence edges' presence. + + **The ledger comes back empty, and that is a deliberate refusal rather than a missing + signal.** A frontier signal exists here too, though it is spelled differently than on the + Python graph: cants emits **no ``TS_RESOLVES_TO`` relationship at all**, so an unresolved + dispatch cannot be read as a missing edge the way it can there. What it leaves is a + ``kind:'call'`` body node whose ``callee`` property is null -- the property + :attr:`has_resolution_edges` probes at the application level, and the same one the local + backend reads off ``TSBodyNode.callee``. Measured 0 of the a4 fixture's 31 call nodes, so + that fixture has nothing to file. + + It is not filed because the granularity is wrong in the one direction that matters. A + diagnostic empties ``exhausted`` for the *whole batch* (Ruling I, see + :func:`~cldk.analysis.commons.graphs.taint_verdict`), so a signal that also fires on the + ordinary case would void every refutation in every application that makes one. And in + TypeScript the ordinary case is common and hard to separate: a call into an ambient + declaration, a ``.d.ts``-only type, or an untyped ``require`` leaves the same null + ``callee`` as a genuinely unresolved dispatch, and with no ``TS_RESOLVES_TO`` there is not + even an ``:TSExternal`` ghost on the far end to tell the two apart by. Telling them apart + well enough to file only the first is future work; leg 4b's corpus check is where it gets + measured. + + Consequence, stated because nothing here can catch it: a pair whose flow leaves through an + unresolved call is certified ``exhausted``. + """ + rows = self._run( + self._TAINT.format(rels=SDG_REL_PATTERN, depth="" if depth is None else depth), + srcs=[n.ref for n in srcs], + dsts=[n.ref for n in dsts], + cuts=cuts, + cut_callables=cut_callables, + cap=max_paths + 1, + **self._scope_params, + ) + return [ + (r["src"], r["dst"], flow_path([self._slice_row(n) for n in r["ns"]], [(e["via"], e["var"], e["prov"]) for e in r["rs"]], via=VIA)) + for r in rows + ], {} + + def _edge_vars_in(self, callable_id: str) -> FrozenSet[str]: + """The edge variables scoped to this callable (see :meth:`TSAnalysisBackend._edge_vars_in`). + + ``r.var`` is absent on ``TS_CDG`` and ``TS_SUMMARY``, so the collected list carries ``None``; + it is dropped rather than kept, because a membership test against a set holding ``None`` + would be answering a question no caller can ask -- ``resolve_sanitizers`` refuses a blank + variable before it gets here. + """ + rows = self._run(self._EDGE_VARS.format(rels=SDG_REL_PATTERN), callable_prefix=callable_id) + return frozenset(v for v in rows[0]["vars"] if v) + def call_paths_between(self, src: str, dst: str, *, depth: int | None = None, max_paths: int = DEFAULT_MAX_PATHS) -> FlowPaths: """How one callable reaches another (see :meth:`TSAnalysisBackend.call_paths_between`).""" check_depth(depth) diff --git a/tests/analysis/typescript/test_typescript_neo4j_multi_application_scope.py b/tests/analysis/typescript/test_typescript_neo4j_multi_application_scope.py index 7bdaad79..05fcf6a9 100644 --- a/tests/analysis/typescript/test_typescript_neo4j_multi_application_scope.py +++ b/tests/analysis/typescript/test_typescript_neo4j_multi_application_scope.py @@ -724,7 +724,13 @@ def test_has_resolution_edges_is_probed_against_this_applications_edges(): # ===================================================================================== # The audit: every statement, class-level and inline, carries the application scope # ===================================================================================== -_MATCHES_BY_PREFIX = re.compile(r"\w+\.id STARTS WITH \$p\b") +#: ``$p`` is the application scope (TS-3). ``$callable_prefix`` (leg 4b) is one **callable's** own +#: ``can://`` ref, minted by ``resolve_callable``, which is itself application-scoped -- so a body +#: node whose id starts with it is this application's by construction, exactly as one matched by a +#: whole id is. It is spelled apart from ``$p`` on purpose: what makes ``_EDGE_VARS`` scoped is a +#: property of the *value* bound to it, not of the statement, and a distinct name keeps that +#: checkable rather than annotated. +_MATCHES_BY_PREFIX = re.compile(r"\w+\.id STARTS WITH \$(?:p|callable_prefix)\b") _MATCHES_BY_SIGNATURE = re.compile(r"signature\s*[:=]\s*\$|\.signature IN \$") #: A ``can://`` id, or a **prefix of one**. ``$bp`` (leg 2.5b) is a resolved callable's own ``ref`` #: plus ``@`` -- minted by ``resolve_callable``, which is itself application-scoped -- so a body node @@ -753,7 +759,7 @@ def test_has_resolution_edges_is_probed_against_this_applications_edges(): #: deliberately not on this list: ``can:///typescript/`` is inside the application and drops #: every ``.js`` module and every language-neutral ghost, so it is a bug rather than a scope (leg #: 2.5b review, finding 9 -- which is what the two-prefix arrangement kept re-creating). -_SCOPED_VAR = re.compile(r"\b(\w+)\.id STARTS WITH (?:\$(?:p|bp)|pos\.module_prefix)\b") +_SCOPED_VAR = re.compile(r"\b(\w+)\.id STARTS WITH (?:\$(?:p|bp|callable_prefix)|pos\.module_prefix)\b") #: A variable pinned to an id -- in the node pattern (``{id: $x}``) or in a ``WHERE`` #: (``x.id = $y`` / ``x.id IN $ys``). A ``can://`` id embeds the application that minted it, so a @@ -1388,9 +1394,21 @@ def test_no_statement_names_retired_or_untargeted_vocabulary(): assert untargeted not in s, f"{name} names {untargeted!r}, a label this backend deliberately does not target yet (cants#95): {s[:160]!r}" +#: The ``any()`` form of the **application scope** -- ``any(p IN $prefixes WHERE n.id STARTS WITH p)`` +#: and anything else that iterates a scope parameter. Named by its parameter rather than by the +#: keyword, because ``any()`` over a list that is *not* the scope is an ordinary predicate: leg 4b's +#: ``_TAINT`` carries two of them (``$cut_callables`` and ``$cuts``, the sanitizer cut), and +#: :func:`~cldk.analysis.commons.graphs.sdg_taint_query` documents them as measured to inline into +#: the ``ShortestPath`` operator -- they do not choose the seek, they filter what it walks. The +#: original spelling of this ban was the bare keyword plus ``STARTS WITH``, which caught the cut as +#: collateral and would have been "fixed" by moving the cut out of the pattern -- the false-refutation +#: bug the whole leg exists to avoid. +_SCOPE_BY_ANY = re.compile(r"any\(\s*\w+ IN \$(?:p|bp|prefixes)\b") + + def test_no_statement_spells_the_scope_with_any(): """``any(p IN $prefixes WHERE …)`` plans as a label scan; a bare ``STARTS WITH`` seeks.""" - assert [name for name, s in _every_statement().items() if "any(" in s and "STARTS WITH" in s] == [] + assert [name for name, s in _every_statement().items() if _SCOPE_BY_ANY.search(s)] == [] @pytest.mark.parametrize("name", sorted(_every_statement())) @@ -1428,5 +1446,10 @@ def test_seek_labels_follow_the_measured_rule(): s = _render(statement) # a template's `{{id:$x}}` is an id point lookup; judge what runs for m in re.finditer(r"\(\w*:([\w:|]+) ?\{id ?: ?\$\w+\}\)", s): assert m.group(1).startswith("CanNode:") or m.group(1) in ("Application",), f"{name}: id point lookup without :CanNode -- {m.group(0)}" - if _is_scoped(s) and not re.search(r"\{id ?: ?\$\w+\}", s): + # ``.id IN $srcs`` (leg 4b's ``_TAINT``) is the same unique-index seek as ``{id: $src}``, once + # per value -- which is why ``_MATCHES_BY_ID`` already treats the three spellings alike. The + # measurement this rule rests on is about a ``STARTS WITH`` *range* seek being what finds the + # anchor; a statement that pins its anchors by id has nothing to range-seek, however many ids + # it pins. + if _is_scoped(s) and not re.search(r"\{id ?: ?\$\w+\}|\.id (?:=|IN) \$", s): assert "CanNode" not in s, f"{name}: a prefix-scoped statement names :CanNode (measured: bare 1.62 ms vs 32.35 ms) -- {s[:120]!r}" diff --git a/tests/analysis/typescript/test_typescript_taint.py b/tests/analysis/typescript/test_typescript_taint.py index cb55f113..e22e219a 100644 --- a/tests/analysis/typescript/test_typescript_taint.py +++ b/tests/analysis/typescript/test_typescript_taint.py @@ -22,14 +22,26 @@ the walk), the two deliberate divergences from ``paths_between`` (a same-position pair is skipped rather than raised; a bounded ``depth`` yields no ``exhausted`` pair), and the three membership conditions of ``exhausted``. + +The last group is the **graph** backend's half of the walk, over a fake driver rather than a server. +There is no live TypeScript graph in this repo's verification set, so what a fake driver can prove is +bounded and worth saying plainly: the statement text is the one ``sdg_taint_query`` built, the +parameters are bound as the contract says (``cap = max_paths + 1``, the application scope, the two +cut lists), and a row is translated into a witness the way ``paths_between``'s rows are. It proves +nothing about what Cypher *does* with that statement -- that the cut inlines into ``ShortestPath``, +that ``allShortestPaths`` returns what the design assumes. Only +``tests/analysis/python/test_python_taint_live.py`` proves that, and only for Python. """ import pytest from cldk.analysis.commons.results import Diagnostic, FlowPath, PathHop, SliceNode -from cldk.analysis.typescript.backend import TSAnalysisBackend +from cldk.analysis.typescript.backend import SDG_REL_PATTERN, TSAnalysisBackend +from cldk.analysis.typescript.neo4j.neo4j_backend import TSNeo4jBackend from cldk.utils.exceptions.exceptions import SelectorNotInGraph +from .conftest import FakeDriver + def _value(name: str, within: str) -> SliceNode: """A resolved value, addressed as this surface addresses one: a name plus the callable it enters.""" @@ -263,3 +275,127 @@ def test_the_two_walk_hooks_are_stubs_rather_than_abstract_methods(): TSAnalysisBackend._taint_walk(None, [], [], cuts=[], cut_callables=[], depth=None, max_paths=1) with pytest.raises(NotImplementedError): TSAnalysisBackend._edge_vars_in(None, "can://app/typescript/app.ts/f") + + +# ============================================================================================== +# The graph backend's half, over a fake driver: statement text, parameter binding, row translation. +# ============================================================================================== +#: One module, so ``_slice_row``'s ``file`` can be verified against the application's module keys the +#: way it is against a real graph's -- a body-node id embeds the module key and the graph stores no +#: path to project instead. +_MODULE = "app.ts" +_HANDLE = f"can://app/typescript/{_MODULE}/handle" +_SINK = f"can://app/typescript/{_MODULE}/query" + + +def _row(src: str, dst: str, *, var: str = "answer") -> dict: + """One witness as the statement projects it: ``ns`` per node, ``rs`` per hop, one fewer hop than + nodes. The keys are :attr:`TSNeo4jBackend._TAINT`'s projection, which is + :attr:`~TSNeo4jBackend._PATHS`' verbatim -- that shared projection is what makes a taint witness + and a ``paths_between`` witness describe a node identically.""" + return { + "src": src, + "dst": dst, + "ns": [ + {"ref": src, "kind": "formal_in", "of": "raw", "line": None, "callable": "app.handle", "c_line": 7}, + {"ref": dst, "kind": "formal_in", "of": "cleaned", "line": 12, "callable": "app.query", "c_line": 11}, + ], + "rs": [{"via": "TS_DDG", "var": var, "prov": ["reaching-defs"]}], + } + + +def _graph(rows=(), edge_vars=(), record=None): + """A ``TSNeo4jBackend`` over a fake driver that answers the attach probes, the taint statement and + the edge-variable statement, and records the parameters each was bound with.""" + + def _responder(query, params): + if record is not None: + record.append((query, dict(params))) + if "TS_HAS_MODULE" in query: + return [{"k": _MODULE, "id": f"can://app/typescript/{_MODULE}"}] + if "AS ok" in query: + return [{"ok": True}] + if "collect(DISTINCT r.var) AS vars" in query: + return [{"vars": list(edge_vars)}] + if "AS src, b.id AS dst" in query: + return list(rows) + return [] + + return TSNeo4jBackend._from_driver(FakeDriver(responder=_responder), application_name="app") + + +def test_the_graph_walk_issues_the_generated_statement_and_binds_the_cap_one_past_max_paths(): + """The extra row is the whole truncation mechanism (Ruling H / E5): bind ``$cap`` to + ``max_paths`` and ``taint()`` reports ``complete=True`` on a result it silently cut. The scope + parameter is bound in the same call because :attr:`_TAINT` carries the interior predicate -- + an unbound ``$p`` is a Cypher error, so this is what says the two agree.""" + record = [] + graph = _graph(record=record) + src, dst = _value("raw", "handle"), _value("cleaned", "query") + graph._taint_walk([src], [dst], cuts=[{"var": "answer", "prefix": _HANDLE}], cut_callables=[_SINK], depth=None, max_paths=3) + query, params = record[-1] + assert query == TSNeo4jBackend._TAINT.format(rels=SDG_REL_PATTERN, depth="") + assert params == { + "srcs": [src.ref], + "dsts": [dst.ref], + "cuts": [{"var": "answer", "prefix": _HANDLE}], + "cut_callables": [_SINK], + "cap": 4, + "p": "can://app/", + } + + +def test_an_explicit_depth_reaches_the_statement_as_the_quantifiers_upper_bound(): + """``depth=None`` renders ``*1..`` and a bound renders ``*1..5``; the walk is the only place that + substitution happens, so a backend that forgot it would answer every call unbounded -- and an + unbounded answer to a bounded question is the direction that manufactures witnesses.""" + record = [] + _graph(record=record)._taint_walk([_value("raw", "handle")], [_value("cleaned", "query")], cuts=[], cut_callables=[], depth=5, max_paths=1) + assert record[-1][0] == TSNeo4jBackend._TAINT.format(rels=SDG_REL_PATTERN, depth="5") + assert "*1..5]->" in record[-1][0] + + +def test_the_graph_walk_returns_every_row_untrimmed_and_keyed_by_the_pair_the_server_reported(): + """Grouping and trimming are ``taint()``'s, so the walk hands back what it found -- including the + ``max_paths + 1``-th row. Trimming here would put the cap in two places and make ``complete`` + unprovable from either. + + The pairing comes from the statement's own ``a.id AS src`` / ``b.id AS dst`` and never from the + order the rows arrive in: the m*n batching is only useful if the grouping survives it.""" + src, dst = _value("raw", "handle"), _value("cleaned", "query") + rows, blocked = _graph(rows=[_row(src.ref, dst.ref), _row(src.ref, dst.ref, var="second")])._taint_walk( + [src], [dst], cuts=[], cut_callables=[], depth=None, max_paths=1 + ) + assert [(r[0], r[1]) for r in rows] == [(src.ref, dst.ref)] * 2, "two rows for a cap of one: the extra row is what reports truncation" + assert [r[2].hops[0].var for r in rows] == ["answer", "second"] + assert blocked == {}, "Ruling I: an empty ledger is a refusal to file, argued in the walk's docstring" + + +def test_a_graph_row_is_described_the_way_a_paths_between_row_is(): + """Same projection, same ``_slice_row``: ``file`` derived from the id's own module key (verified + against the application's, never split), ``via`` translated through the shared ``VIA`` table, and + a parameter-passing vertex with no span of its own borrowing the callable's first line.""" + src, dst = _value("raw", "handle"), _value("cleaned", "query") + (row,) = _graph(rows=[_row(src.ref, dst.ref)])._taint_walk([src], [dst], cuts=[], cut_callables=[], depth=None, max_paths=5)[0] + (hop,) = row[2].hops + assert hop.via == "data" and hop.var == "answer" and hop.prov == ["reaching-defs"] + assert (hop.frm.file, hop.frm.line, hop.frm.callable, hop.frm.kind, hop.frm.name) == (_MODULE, 7, "app.handle", "parameter", "raw") + assert (hop.to.file, hop.to.line, hop.to.kind) == (_MODULE, 12, "parameter") + + +def test_the_graph_edge_variable_domain_is_scoped_by_the_callables_own_ref_and_drops_the_nulls(): + """``$callable_prefix`` holds a **callable's** ``can://`` ref rather than the application's, which + is why it is spelled apart from ``$p``: what makes the statement application-scoped is a property + of the value bound to it -- a callable id embeds the application name -- and the scope audit + classifies it on that basis. + + ``collect(DISTINCT r.var)`` returns a ``null`` for every ``TS_CDG``/``TS_SUMMARY`` hop, which + carry no ``var`` at all. Keeping it would put ``None`` in a set ``resolve_sanitizers`` tests + membership against, and ``resolve_sanitizers`` has already refused a blank variable by then.""" + record = [] + graph = _graph(edge_vars=["answer", None, "raw"], record=record) + assert graph._edge_vars_in(_HANDLE) == frozenset({"answer", "raw"}) + query, params = record[-1] + assert query == TSNeo4jBackend._EDGE_VARS.format(rels=SDG_REL_PATTERN) + assert params == {"callable_prefix": _HANDLE} + assert "CanNode" not in query, "a STARTS WITH-scoped statement seeks best on the bare label (the measured seek rule)" From 61e208de4d610761b4dd54b961ee9ce5c9424045 Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Thu, 10 Sep 2026 09:52:18 -0400 Subject: [PATCH 41/65] feat(java): the local taint walk, and the param edge var it needs to cut at a call The in-process half of Java's walk: one shortest_walks call per deduplicated pair, both cuts as walker predicates so the breadth-first pass measures the shortest *satisfying* distance, and no level gate -- Java asks that one, and the port-lattice one, on the ABC's taint() before the walk is entered (Ruling F as amended). _sdg hardcoded (rel, None, ()) on J_PARAM_IN/J_PARAM_OUT. That was true until codeanalyzer-java 3.1.2 added JParamEdge.var, and the branch is pinned past it. The lie cost two capabilities: _edge_vars_in could not see a call-crossing variable, so resolve_sanitizers refused a real one as nonexistent; and allow_edge's var == c["var"] could never match a param edge, so a scoped variable cut could not cut at a call boundary, which is where a taint cut most wants to. Now read with getattr, so a 3.1.1 payload still attaches. The a4 fixture was emitted by 3.1.0 and its 355 param edges carry no var, so it cannot witness that fix; the test says so and asserts the plumbing instead. Whether the pinned analyzer writes var in practice is unverified here -- no jar, no JVM. Twelve tests over daytrader8's TradeDirect, every count measured through shortest_walks off the fixture rather than read back from the implementation: 9 witnesses over four pairs, the cap boundary and its prefix property, per-pair dedup, a callable cut that leaves its siblings' 6 witnesses standing, a callable cut containing the source, and arg0 -- a real edge variable under both buy and completeOrder -- cutting buy's two pairs and, under completeOrder, cutting nothing at all. That last one is the scoping proof: an unscoped cut on a name that recurs would refute flows the caller never named. taint() is also asserted to refuse while the port lattice carries no dependence edge, on both backends. Every pair would otherwise come back refuted for a reason that has nothing to do with the program. --- .../java/codeanalyzer/codeanalyzer.py | 77 +++++++- tests/analysis/java/test_java_dataflow.py | 170 +++++++++++++++++- 2 files changed, 243 insertions(+), 4 deletions(-) diff --git a/cldk/analysis/java/codeanalyzer/codeanalyzer.py b/cldk/analysis/java/codeanalyzer/codeanalyzer.py index 81459d87..031acc09 100644 --- a/cldk/analysis/java/codeanalyzer/codeanalyzer.py +++ b/cldk/analysis/java/codeanalyzer/codeanalyzer.py @@ -33,14 +33,14 @@ import subprocess from pathlib import Path from subprocess import CompletedProcess -from typing import Any, Dict, Iterable, List, Sequence, Tuple, Union +from typing import Any, Dict, FrozenSet, Iterable, List, Sequence, Tuple, Union import networkx as nx from pydantic import ValidationError from cldk.analysis import AnalysisLevel from cldk.analysis.commons.bounds import DEFAULT_PAGE_SIZE, check_page_size, edge_page -from cldk.analysis.commons.graphs import flow_path, shortest_walks, slice_resolved +from cldk.analysis.commons.graphs import flow_path, shortest_walks, slice_resolved, under_callable from cldk.analysis.commons.levels import ANALYZER_LEVELS, LEVEL_NAMES, analyzer_level from cldk.analysis.commons.results import Diagnostic, EdgePage, FlowPaths, Slice, SliceNode from cldk.analysis.java.backend import ( @@ -511,7 +511,12 @@ def link(src: str, dst: str, label: tuple) -> None: # are used as-is: joining them again would mint ids that name nothing. for rel, edges in (("J_PARAM_IN", self.application.param_in), ("J_PARAM_OUT", self.application.param_out)): for e in edges or []: - link(e.src, e.dst, (rel, None, ())) + # ``getattr``, not ``e.var``: ``JParamEdge`` gained the field in codeanalyzer-java + # 3.1.2 (codeanalyzer-java#250) and an older payload's edge carries none. Hard-coding ``None`` here (as + # this did) left ``_edge_vars_in`` blind to every call-crossing variable, so a + # real sanitizer was refused as nonexistent and ``allow_edge``'s ``var == c["var"]`` + # could never cut at a call boundary -- the one place a taint cut most wants to. + link(e.src, e.dst, (rel, getattr(e, "var", None), tuple(getattr(e, "prov", None) or ()))) self._sdg_cache = ({"forward": forward, "backward": backward}, nodes) return self._sdg_cache @@ -545,6 +550,72 @@ def _value_paths(self, a: SliceNode, b: SliceNode, depth: int | None, max_paths: paths = [flow_path([described[a.ref]] + [described[ref] for ref, _ in walk], [label for _, label in walk], via=VIA) for walk in walks[:max_paths]] return FlowPaths(paths=paths, complete=len(walks) <= max_paths) + def _taint_walk(self, srcs, dsts, *, cuts, cut_callables, depth, max_paths): + """The sanitized shortest walks, in process (see :meth:`JavaAnalysisBackend._taint_walk`). + + **No** ``self._require_dataflow()`` here, unlike Python's and TypeScript's local walks: + Java's level gate and its port-lattice gate both live on + :meth:`JavaAnalysisBackend.taint`, which opens them before the walk is entered, and asking + again would answer a question already answered. + + One :func:`~cldk.analysis.commons.graphs.shortest_walks` call **per pair**, which is what + makes ``max_paths + 1`` a per-pair cap here the way ``collect(p)[0..$cap]`` is one over + Cypher -- a single walk over the flattened lists would let one prolific pair starve the rest. + Pairs are deduplicated by resolved position first, for the reason + :func:`~cldk.analysis.commons.graphs.taint_verdict` deduplicates the requested ones: two + selectors naming one position are one pair, and walking it twice would report each witness + twice and make a cap of *m* yield *2m*. The graph side gets that free from ``a.id IN $srcs``. + + Both cuts are :func:`~cldk.analysis.commons.graphs.shortest_walks`' predicates rather than a + filter over what it returns, which is the property the design rests on: the breadth-first + pass must measure the shortest *satisfying* distance, or a sanitized short route hides a + clean longer one and the pair comes back refuted. ``allow_edge`` reads the hop's **start** + node, mirroring the Cypher predicate's ``startNode(r)``, so a variable cut severs only the + callable the caller named it in -- daytrader8 carries ``arg0`` under both ``buy`` and + ``completeOrder``, and cutting one leaves the other's four witnesses standing. Both are + ``None`` when nothing is sanitized: the documented "no filtering" default, and no per-node + cost on the common call. + + :func:`~cldk.analysis.commons.graphs.under_callable`, never a bare ``startswith``: a Java + ``can://`` callable id ends in ``)``, so a prefix collision needs a same-arity overload of a + longer name and cannot happen -- but the predicate is shared with TypeScript, where it can + (Ruling K), and one spelling on all three backends is what keeps that from being re-derived. + + The ledger comes back empty. Java's frontier signal exists in the payload -- a + ``JCallSite`` whose ``callee_signature`` is empty -- but filing a diagnostic voids + ``exhausted`` for the whole batch (Ruling I), and a signal that cannot be told apart from an + ordinary call into the JDK would void every refutation in every application that makes one. + Same consequence, equally uncatchable from in here: a pair whose flow leaves through an + unresolved dispatch is certified ``exhausted``. + """ + adjacency, nodes = self._sdg() + allow_node = (lambda nid: not under_callable(nid, cut_callables)) if cut_callables else None + allow_edge = (lambda frm, _rel, var: not any(var == c["var"] and under_callable(frm, (c["prefix"],)) for c in cuts)) if cuts else None + pairs: Dict[Tuple[str, str], SliceNode] = {} + for a in srcs: + for b in dsts: + pairs.setdefault((a.ref, b.ref), a) + rows = [] + for (src_ref, dst_ref), a in pairs.items(): + walks = shortest_walks(adjacency["forward"], src_ref, dst_ref, depth, max_paths + 1, via=VIA, allow_edge=allow_edge, allow_node=allow_node) + described = {ref: self._body_slice_node(ref, *nodes[ref]) for walk in walks for ref, _ in walk if ref in nodes} + described[src_ref] = a + rows.extend((src_ref, dst_ref, flow_path([described[src_ref]] + [described[ref] for ref, _ in walk], [label for _, label in walk], via=VIA)) for walk in walks) + return rows, {} + + def _edge_vars_in(self, callable_id: str) -> FrozenSet[str]: + """The edge variables scoped to this callable (see :meth:`JavaAnalysisBackend._edge_vars_in`). + + Off the adjacency :meth:`_sdg` already caches, so a variable sanitizer costs a scan of it and + no second traversal. Edges *leaving* a node under ``callable_id`` -- the same ``startNode`` + scoping the cut itself uses, so this validates exactly the domain the cut can match. + ``J_CDG`` and the two port relationships on a pre-3.1.2 payload carry no ``var``; those + those ``None`` values are dropped, because ``resolve_sanitizers`` refuses a blank variable before it + ever asks. + """ + forward = self._sdg()[0]["forward"] + return frozenset(var for src, outs in forward.items() if under_callable(src, (callable_id,)) for labels in outs.values() for _rel, var, _prov in labels if var) + def _value_reaches(self, src: str, dsts: Sequence[str], depth: int | None) -> bool: """See :meth:`JavaAnalysisBackend._value_reaches`.""" return not self._reach(src, "forward", depth).isdisjoint(set(dsts) - {src}) diff --git a/tests/analysis/java/test_java_dataflow.py b/tests/analysis/java/test_java_dataflow.py index 70127d0a..6b9f0db8 100644 --- a/tests/analysis/java/test_java_dataflow.py +++ b/tests/analysis/java/test_java_dataflow.py @@ -46,7 +46,7 @@ from cldk.analysis.java.java_analysis import JavaAnalysis from cldk.analysis.python.backend import PythonAnalysisBackend from cldk.analysis.python.python_analysis import PythonAnalysis -from cldk.models.java.models import JCdgEdge, JCfgEdge, JDdgEdge +from cldk.models.java.models import JCdgEdge, JCfgEdge, JDdgEdge, JParamEdge from cldk.utils.exceptions import AmbiguousName, SelectorNotInGraph from cldk.utils.exceptions.exceptions import CodeanalyzerExecutionException, CodeanalyzerUsageException @@ -632,3 +632,171 @@ def test_the_slice_and_the_call_graph_accessors_are_not_guarded(both, ref): """The gap is stated exactly, not widened: the call-graph half and the backward slice answer.""" assert isinstance(ref.slice_backward("conn", within=GET_STATEMENT), Slice) assert both.reaches(SELL, GET_STATEMENT) and both.callers_of(GET_STATEMENT) and both.backward_cone([GET_STATEMENT]) + + +# ---------------------------------------------------------------------------------------------- +# Leg 4b, Task 7: the local ``taint`` walk, offline. +# +# ``taint()`` is a refutation instrument: its ``exhausted`` list certifies "no flow exists between +# this source and this sink", so **over-cutting is far worse than under-cutting**. Cutting more than +# the caller named removes paths, removing paths adds pairs to ``exhausted``, and a wrong +# ``exhausted`` closes an alert on a live flow. Under-cutting merely over-reports. Every count below +# was measured off this fixture through ``shortest_walks`` directly, never read back out of the +# implementation, and every cut is asserted to leave its *siblings* standing. +# +# Sources are a **callee's** ``formal_in`` (Ruling J), for the reason the Python fixture documents: +# a caller's own parameter and its def-site are disjoint upstream. +# ---------------------------------------------------------------------------------------------- +BUY = f"{DIRECT}.buy(java.lang.String, java.lang.String, double, int)" +COMPLETE_ORDER = f"{DIRECT}.completeOrder(java.lang.Integer, boolean)" +SET_IN_GLOBAL_TXN = f"{DIRECT}.setInGlobalTxn(boolean)" +ROLL_BACK = f"{DIRECT}.rollBack(java.sql.Connection, java.lang.Exception)" + +#: As a caller writes them: four pairs with 2 + 4 + 1 + 2 = 9 witnesses at unbounded depth. +TAINT_SOURCES = [("orderProcessingMode", BUY), ("orderID", COMPLETE_ORDER)] +TAINT_SINKS = [("inGlobalTxn", SET_IN_GLOBAL_TXN), ("conn", ROLL_BACK)] +#: One pair, 2 witnesses -- the only shape a cap of 1 can be measured on. +TAINT_PAIR = ([("orderProcessingMode", BUY)], [("inGlobalTxn", SET_IN_GLOBAL_TXN)]) + + +def test_the_local_java_walk_does_not_re_ask_the_level_gate(ref): + """Ruling F **as amended**: Python's and TypeScript's local walks open with + ``self._require_dataflow()``, and Java's must not -- Java carries that gate on the ABC's + ``taint()``, which asks it before the walk is entered. The hook is called directly on a backend + whose ``analysis_level`` says level 1: it answers rather than raising, because by the time a walk + runs the question has been settled, and ``taint()`` is asserted to raise on the same backend. + + Two gates, not one: the port-lattice refusal is also ``taint()``'s, in the same place the five + sibling flow accessors ask for it.""" + ref.analysis_level = "symbol_table" + try: + assert ref._taint_walk([], [], cuts=[], cut_callables=[], depth=None, max_paths=1) == ([], {}) + with pytest.raises(CodeanalyzerUsageException, match="program_dependency_graph"): + ref.taint(TAINT_SOURCES, TAINT_SINKS) + finally: + ref.analysis_level = "system_dependency_graph" + + +def test_taint_refuses_while_the_port_lattice_carries_no_dependence_edge(disconnected): + """The sixth guarded accessor. On a pre-3.0.3 emission every pair would come back refuted for a + reason that has nothing to do with the program -- an ``exhausted`` list that is an artefact of + the analyzer -- which is exactly the output this leg must never produce. Both backends refuse, + and the graph backend refuses before it would have reached Cypher.""" + with pytest.raises(CodeanalyzerExecutionException, match="port"): + disconnected.taint(TAINT_SOURCES, TAINT_SINKS) + + +def test_a_java_param_edge_carries_the_variable_the_analyzer_put_on_it(ref): + """``J_PARAM_IN``/``J_PARAM_OUT`` must reach the adjacency with whatever ``var`` the payload put + on them. This backend hardcoded ``None``, which was true until codeanalyzer-java 3.1.2 + (codeanalyzer-java#250) added the property, and became a lie that cost two things: + ``_edge_vars_in`` could not see a call-crossing variable, so ``resolve_sanitizers`` refused a + real one as nonexistent (Ruling A exists to prevent that); and ``allow_edge``'s + ``var == c["var"]`` could never match a param edge, so a scoped variable cut was structurally + incapable of cutting at a call boundary. + + **This fixture cannot witness the fix.** It was emitted by 3.1.0, whose 258 ``param_in`` and 97 + ``param_out`` edges carry no ``var`` key at all, so the assertion is what the *plumbing* does + with an edge that has one -- built here rather than measured, and honest about which. Whether the + pinned analyzer writes ``var`` in practice is unverified in this repo: there is no jar and no JVM. + """ + adjacency = ref._sdg()[0]["forward"] + params = [(rel, var) for outs in adjacency.values() for labels in outs.values() for rel, var, _prov in labels if rel.startswith("J_PARAM")] + assert len(params) == 355, "a4 was emitted by 3.1.0: 258 param_in + 97 param_out, none carrying a var" + assert all(var is None for _rel, var in params), "this fixture's param edges carry no var; the next assertion is the plumbing, not the data" + edge = JParamEdge(src="a", dst="b", var="conn") + assert (getattr(edge, "var", None), tuple(getattr(edge, "prov", None) or ())) == ("conn", ()), "the label the adjacency stores for a 3.1.2 param edge" + + +def test_the_local_java_walk_finds_the_measured_witnesses_and_refutes_nothing(ref): + """The anchor the rest of this group narrows: 9 witnesses over four pairs, each ending on a + ``J_PARAM_IN`` crossing into the sink callable's parameter. A walk that stopped at a call + boundary would still return rows; only the last hop says it crossed one.""" + r = ref.taint(TAINT_SOURCES, TAINT_SINKS, max_paths=10) + assert len(r.paths) == 9 and r.exhausted == [] and r.complete is True + assert all(p.hops[-1].via == "argument" and p.hops[-1].to.kind == "parameter" for p in r.paths) + assert Counter(tuple(h.via for h in p.hops) for p in r.paths) == { + ("data", "control", "control", "data", "argument"): 4, + ("data", "control", "data", "argument"): 3, + ("data", "data", "argument"): 2, + }, "a control hop is a real dependence and the four-hop chain through it is the majority here" + + +def test_the_local_java_walk_returns_one_row_past_the_cap_so_truncation_is_never_silent(ref): + """A walk that capped at ``max_paths`` rather than ``max_paths + 1`` would return a full-looking + result with ``complete=True`` -- a silent bound, which E5 forbids. ``taint()`` cannot detect + that from the rows it is handed, so the walk is tested here or nowhere.""" + at_one = ref.taint(*TAINT_PAIR, max_paths=1) + assert len(at_one.paths) == 1 and at_one.complete is False + at_two = ref.taint(*TAINT_PAIR, max_paths=2) + assert len(at_two.paths) == 2 and at_two.complete is True + + +def test_the_local_java_cap_keeps_a_prefix_of_one_total_order(ref): + """*Which* witness survives is stated, not incidental: ``shortest_walks``' replay sorts equal + length branches by ``(via, var, to)`` -- the components ``hop_sort_key`` documents, and what + Cypher's ``ORDER BY length(p), key`` produces -- so a cap is a prefix of a total order rather + than whichever branch the recursion reached first. Both witnesses of this pair are three hops + ``orderProcessingMode`` then ``arg0`` then the parameter crossing; they differ further in.""" + one, many = ref.taint(*TAINT_PAIR, max_paths=1), ref.taint(*TAINT_PAIR, max_paths=5) + assert one.paths == many.paths[:1], "the local taint cap is not a prefix of one total order" + assert [h.var for h in one.paths[0].hops] == ["orderProcessingMode", "arg0", None] + + +def test_the_local_java_walk_runs_once_per_distinct_pair_not_once_per_selector(ref): + """Two selectors naming one position are one pair. Walking it twice would report every witness + twice and make a cap of *m* yield *2m* -- the graph side gets this free from ``a.id IN $srcs``, + so the local side has to deduplicate to match.""" + once = ref.taint(*TAINT_PAIR, max_paths=10) + twice = ref.taint([("orderProcessingMode", BUY), ("orderProcessingMode", BUY)], [("inGlobalTxn", SET_IN_GLOBAL_TXN)], max_paths=10) + assert len(twice.paths) == len(once.paths) == 2 + + +def test_a_local_java_callable_cut_severs_its_own_pairs_and_leaves_the_siblings_alone(ref): + """Cutting ``setInGlobalTxn`` refutes the two pairs that end in it and leaves the ``rollBack`` + pairs' 4 + 2 witnesses untouched. A cut that closed all four would pass a naive "the sanitizer + worked" assertion while being the failure this leg is built to avoid.""" + r = ref.taint(TAINT_SOURCES, TAINT_SINKS, sanitizers=[SET_IN_GLOBAL_TXN], max_paths=10) + assert len(r.paths) == 6, "the two rollBack pairs survive: 4 + 2 witnesses" + assert r.exhausted == [("orderProcessingMode", "inGlobalTxn"), ("orderID", "inGlobalTxn")] + assert r.complete is True + + +def test_a_local_java_callable_cut_that_contains_the_source_yields_no_walk(ref): + """``allow_node`` is checked against ``src`` up front, because ``src`` is never itself a + ``steps()`` destination for either pass to filter. Without that check a source inside a cut + callable would still emit its first hop -- and a witness through a callable the caller declared + sanitized is a false positive with the sanitizer's own name on it.""" + r = ref.taint([("orderProcessingMode", BUY)], TAINT_SINKS, sanitizers=[BUY], max_paths=10) + assert r.paths == [] + assert r.exhausted == [("orderProcessingMode", "inGlobalTxn"), ("orderProcessingMode", "conn")] + assert r.complete is True + + +def test_a_local_java_variable_cut_severs_a_call_boundary_and_only_the_pairs_that_cross_it(ref): + """``arg0`` is the formal that ``buy``'s calls bind across a ``J_PARAM_IN``, so cutting it inside + ``buy`` is a cut *at* a call boundary -- the thing the hardcoded ``None`` above made impossible. + Both of ``buy``'s pairs are refuted and both of ``completeOrder``'s keep every witness.""" + r = ref.taint(TAINT_SOURCES, TAINT_SINKS, sanitizers=[("arg0", BUY)], max_paths=10) + assert len(r.paths) == 3, "completeOrder's two pairs survive: 1 + 2 witnesses" + assert {p.hops[0].frm.callable for p in r.paths} == {COMPLETE_ORDER} + assert r.exhausted == [("orderProcessingMode", "inGlobalTxn"), ("orderProcessingMode", "conn")] + assert r.complete is True + + +def test_a_local_java_variable_cut_is_scoped_to_the_callable_it_names(ref): + """The decisive scoping witness: ``arg0`` is a real edge variable under **both** ``buy`` and + ``completeOrder`` (so Ruling A admits either), and cutting it under ``completeOrder`` severs + nothing at all -- all 9 witnesses stand and nothing is refuted. An unscoped cut on a name that + recurs like this would sever flows the caller never named: over-cut, false refutation.""" + assert "arg0" in ref._edge_vars_in(ref.resolve_callable(COMPLETE_ORDER).ref) + r = ref.taint(TAINT_SOURCES, TAINT_SINKS, sanitizers=[("arg0", COMPLETE_ORDER)], max_paths=10) + assert len(r.paths) == 9 and r.exhausted == [] + + +def test_a_local_java_variable_sanitizer_that_names_nothing_still_raises(ref): + """Ruling A widened the domain to edge variables; it did not remove the check. A typo is refused + loudly rather than silently cutting nothing -- a sanitizer that cuts nothing is the over-report + direction, but a caller who believes it cut something is the over-cut direction one step later.""" + with pytest.raises(SelectorNotInGraph): + ref.taint(TAINT_SOURCES, TAINT_SINKS, sanitizers=[("nosuchvar", BUY)]) From 98187292489cb1194257148e336eacf9d2921606 Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Thu, 10 Sep 2026 09:59:50 -0400 Subject: [PATCH 42/65] feat(java): the graph taint walk, with the interior scope its endpoints cannot give it The Cypher half of Java's walk: one generated statement per batch, both cuts inside the pattern so they inline into ShortestPath rather than filter its rows, $cap bound one past max_paths, and the same node_label, rel_var and projection _PATHS uses -- so a taint witness and a paths_between witness describe a vertex identically, both through _body_slice_node and neither joining a callable back. The interior predicate is not decoration: a variable-length pattern binds only its endpoints, so scoping those leaves every node between them free and a walk could enter another application and come back. Both endpoints keep their own STARTS WITH alongside, because the audit judges per bound variable. _EDGE_VARS answers the sanitizer domain over $callable_prefix and drops the nulls that J_CDG, J_SUMMARY and a pre-3.1.2 port crossing collect. Two rules in the multi-application scope audit had to move: - the any() ban was on the keyword, and every taint statement now has one -- the sanitizer cut is an any() over a list of cut descriptors, not the application scope. Narrowed to any() over $prefix/$prefixes, which is what the ban was about. - _SCOPED_VAR did not know $callable_prefix, so _EDGE_VARS read as an unscoped prefix match. What makes it scoped is a property of the bound value: a callable id embeds the application name. The offline tests run taint() end to end over a fake driver -- resolution, both gates, the walk, the verdict, with only the rows canned -- and pin the statement text, the parameter binding and the row translation. They say nothing about what the server does with the statement, which is the verification debt this leg carries for Java. --- cldk/analysis/java/neo4j/neo4j_backend.py | 108 +++++++++++- ...test_java_neo4j_multi_application_scope.py | 18 +- tests/analysis/java/test_java_taint.py | 160 +++++++++++++++++- 3 files changed, 281 insertions(+), 5 deletions(-) diff --git a/cldk/analysis/java/neo4j/neo4j_backend.py b/cldk/analysis/java/neo4j/neo4j_backend.py index 0f32cd7b..00d638c1 100644 --- a/cldk/analysis/java/neo4j/neo4j_backend.py +++ b/cldk/analysis/java/neo4j/neo4j_backend.py @@ -117,7 +117,7 @@ import networkx as nx from cldk.analysis.commons.bounds import DEFAULT_PAGE_SIZE, EdgeOrder, check_page_size, cursor_params, encode_cursor, keyset_where -from cldk.analysis.commons.graphs import flow_path, sdg_path_query, slice_resolved +from cldk.analysis.commons.graphs import flow_path, sdg_path_query, sdg_taint_query, slice_resolved from cldk.analysis.commons.results import EdgePage, FlowPaths, Slice, SliceNode from cldk.analysis.java.backend import ( CDG_ORDER, @@ -934,6 +934,112 @@ def _value_paths(self, a: SliceNode, b: SliceNode, depth: int | None, max_paths: ] return FlowPaths(paths=paths, complete=len(rows) <= max_paths) + #: ``taint()``'s statement: the same shortest-path search as :attr:`_PATHS`, m sources against n + #: sinks in one traversal, with the sanitizer cut **inside** the pattern and the cap applied per + #: pair. Everything that differs from :attr:`_PATHS` is argued in + #: :func:`~cldk.analysis.commons.graphs.sdg_taint_query`; the two share this backend's + #: ``node_label``, its ``rel_var`` and its projection verbatim, which is what makes a taint + #: witness and a ``paths_between`` witness describe a vertex identically -- neither joins a + #: callable back, because :meth:`~cldk.analysis.java.backend.JavaAnalysisBackend._body_slice_node` + #: recovers the owner, the file and the parameter names from the id prefix and the index this + #: backend already holds. + #: + #: The interior predicate is here for :attr:`_PATHS`'s reason and not by analogy with it: a + #: variable-length pattern binds only its endpoints, so scoping those leaves every node between + #: them free and a walk could enter another application and come back. Both endpoints keep their + #: own ``STARTS WITH`` alongside, redundantly and deliberately, because the audit judges **per + #: bound variable**. + _TAINT = sdg_taint_query( + "J", + node_label="JBodyNode", + endpoint_scope=_scoped, + interior_scope=_scoped, + projection="ref: n.id, kind: n.kind, line: n.start_line", + rel_var="e", + ) + + #: The variable names on SDG edges *leaving* a node inside ``$callable_prefix`` -- ``startNode``, + #: the same end of the hop :attr:`_TAINT`'s cut predicate reads, so a sanitizer this validates is + #: one that predicate can actually match (Ruling A / + #: :func:`~cldk.analysis.commons.resolve.resolve_sanitizers`). + #: + #: The parameter is named apart from every other statement's ``$prefix`` because it holds a + #: different thing: a **callable's** ``can://`` ref, not the application's. It is still + #: application-scoped, by construction rather than by convention -- a callable id embeds the + #: application name -- and ``test_java_neo4j_multi_application_scope.py`` classifies it on that + #: basis. + #: + #: A bare ``STARTS WITH``, deliberately wider than :attr:`_TAINT`'s delimited cut + #: (:func:`~cldk.analysis.commons.graphs.under_callable`): a sibling callable whose name merely + #: starts with this one contributes its edge variables here, so a variable may be *accepted* that + #: the cut cannot then match. That direction under-cuts -- a cut severing nothing over-reports -- + #: and the direction this leg must refuse is the other one. A Java ``can://`` callable id ends in + #: ``)``, so the collision needs a same-arity overload of a longer name and cannot arise; + #: TypeScript's can (Ruling K), which is why the two ends are spelled the same way on all three + #: backends. One round trip per variable sanitizer, which is as often as a caller writes one. + _EDGE_VARS = "MATCH (n:JBodyNode)-[e:{rels}]->() WHERE n.id STARTS WITH $callable_prefix RETURN collect(DISTINCT e.var) AS vars" + + def _taint_walk(self, srcs, dsts, *, cuts, cut_callables, depth, max_paths): + """The sanitized shortest walks, server-side (see :meth:`JavaAnalysisBackend._taint_walk`). + + One statement for the whole batch, and one row per witness -- ``a.id AS src`` / ``b.id AS + dst`` carry the pairing back, because the m*n batching is only useful if the grouping + survives it. Ordering, the per-pair ``$cap`` and both cuts are the statement's + (:func:`~cldk.analysis.commons.graphs.sdg_taint_query`), so nothing is re-sorted or + re-filtered here: a Python-side filter is the false-refutation bug that function exists to + avoid, and a Python-side sort would silently disagree with + :func:`~cldk.analysis.commons.graphs.path_order`. Rows come back untrimmed, including the + ``max_paths + 1``-th, which is what lets ``taint()`` report truncation without counting + twice. + + Neither gate is asked here. :meth:`JavaAnalysisBackend.taint` asks + :meth:`_require_dataflow` (a no-op on this backend -- ``--emit neo4j`` always runs at full + depth) and :meth:`_require_connected_ports` before the walk is entered. + + **The ledger comes back empty, and that is a refusal to file rather than a missing signal.** + Java's frontier signal exists on this graph as a ``kind:'call'`` body node with no outgoing + ``J_RESOLVES_TO``, which is what :meth:`_resolution_edges_present` already probes for. It is + not filed because the granularity is wrong in the one direction that matters: a diagnostic + empties ``exhausted`` for the *whole batch* (Ruling I), so a signal that also fires on the + ordinary case -- a call into the JDK, which on this graph resolves into a ``:JExternal`` + ghost or nothing at all -- would void every refutation in every application that calls a + library. Telling "unresolved dispatch" apart from "resolved external" well enough to file + only the first is future work, and leg 4b's corpus check is where the separation gets + measured. + + Consequence, stated because nothing here can catch it: a pair whose flow leaves through an + unresolved dispatch is certified ``exhausted``. + """ + query = self._TAINT.format(rels=SDG_REL_PATTERN, depth="" if depth is None else depth) + rows = self._run( + query, + srcs=[n.ref for n in srcs], + dsts=[n.ref for n in dsts], + cuts=cuts, + cut_callables=cut_callables, + cap=max_paths + 1, + prefix=self._scope_prefix, + ) + return [ + ( + r["src"], + r["dst"], + flow_path([self._body_slice_node(n["ref"], n["kind"], n["line"]) for n in r["ns"]], [(e["via"], e["var"], e["prov"]) for e in r["rs"]], via=VIA), + ) + for r in rows + ], {} + + def _edge_vars_in(self, callable_id: str) -> FrozenSet[str]: + """The edge variables scoped to this callable (see :meth:`JavaAnalysisBackend._edge_vars_in`). + + ``collect(DISTINCT e.var)`` returns a ``null`` for every hop that carries no ``var`` -- + ``J_CDG`` and ``J_SUMMARY`` by construction, and ``J_PARAM_IN``/``J_PARAM_OUT`` on a graph + emitted before codeanalyzer-java 3.1.2 -- and those are dropped, because + ``resolve_sanitizers`` refuses a blank variable before it ever asks. + """ + rows = self._run(self._EDGE_VARS.format(rels=SDG_REL_PATTERN), callable_prefix=callable_id) + return frozenset(v for v in rows[0]["vars"] if v) + #: ``WITH DISTINCT m`` before the membership test is what makes this a pruning BFS instead of a #: trail enumeration. Every hop is inside the application, by the same whole-path predicate #: :attr:`_SLICE` and :attr:`_PATHS` carry -- which this claimed before it had one, while diff --git a/tests/analysis/java/test_java_neo4j_multi_application_scope.py b/tests/analysis/java/test_java_neo4j_multi_application_scope.py index 9adb6438..1a629bba 100644 --- a/tests/analysis/java/test_java_neo4j_multi_application_scope.py +++ b/tests/analysis/java/test_java_neo4j_multi_application_scope.py @@ -649,7 +649,14 @@ def test_a_module_row_that_is_not_a_type_is_refused_by_the_model(): # ===================================================================================== # The audit: every statement, class-level and inline, carries the application scope # ===================================================================================== -_SCOPED_VAR = re.compile(r"\b(\w+)\.id STARTS WITH \$prefix\b") +#: ``$callable_prefix`` is the taint sanitizer domain's parameter (``JNeo4jBackend._EDGE_VARS``) and +#: is listed here for a reason worth stating rather than by analogy with ``$prefix``: it holds a +#: **callable's** ``can://`` ref, not the application's, so what makes the statement +#: application-scoped is a property of the value bound to it -- a callable id embeds the application +#: name -- and the value itself is minted by this backend from its own resolver. The alternative, +#: adding ``AND n.id STARTS WITH $prefix`` beside it, is the same broad-then-filter plan the +#: ``$prefixes`` note below measured at 15x. +_SCOPED_VAR = re.compile(r"\b(\w+)\.id STARTS WITH \$(?:prefix|callable_prefix)\b") #: The second scoped spelling: ``UNWIND $prefixes AS p … WHERE x.id STARTS WITH p``. It is the #: **narrower** one -- each element is a single callable's id prefix, which is itself inside the #: application prefix -- and it is what the per-callable body-node fetch issues. Adding @@ -1008,10 +1015,17 @@ def test_the_interior_audit_sees_every_variable_length_hop_and_not_only_shortest assert {"_SLICE", "_VALUE_REACHES"} <= selected, "the two variable-length walks are in the audit's domain" +#: ``any()`` **over a scope parameter**. The ban is on that shape and not on the keyword: the taint +#: statement's sanitizer cut is an ``any(c IN $cuts …)`` / ``any(q IN $cut_callables …)`` over a list +#: of cut descriptors, which is not the application scope and is measured to inline into +#: ``ShortestPath`` rather than plan as a scan. +_SCOPE_BY_ANY = re.compile(r"any\(\s*\w+ IN \$prefix(?:es)?\b") + + def test_no_statement_spells_the_scope_with_any(): """``any(p IN $prefixes WHERE …)`` plans as a label scan; Java has one prefix, so the predicate is a bare ``STARTS WITH`` and there is nothing for ``any()`` to iterate.""" - assert [name for name, s in _every_statement().items() if "any(" in s] == [] + assert [name for name, s in _every_statement().items() if _SCOPE_BY_ANY.search(s)] == [] @pytest.mark.parametrize("name", sorted(_every_statement())) diff --git a/tests/analysis/java/test_java_taint.py b/tests/analysis/java/test_java_taint.py index 185ed66e..a178864d 100644 --- a/tests/analysis/java/test_java_taint.py +++ b/tests/analysis/java/test_java_taint.py @@ -25,15 +25,26 @@ Java's extra clause is the gate: ``_require_connected_ports`` sits *after* resolution, so a caller with a typo hears about their typo and not about a gap in the analysis. + +The last group is the **graph** backend's half of the walk, over a fake driver rather than a server, +and it is worth saying plainly what that can and cannot prove. There is no live Java graph in this +repo's verification set, so what is pinned is the statement text ``sdg_taint_query`` built, the +parameters bound to it (``cap = max_paths + 1``, the application scope prefix, the two cut lists) and +the translation of canned rows into witnesses through the same ``_body_slice_node`` the slice uses. +It proves nothing about what Cypher *does* with that statement -- that the cut inlines into +``ShortestPath``, that ``allShortestPaths`` returns what the design assumes. Only +``tests/analysis/python/test_python_taint_live.py`` proves that, and only for Python. """ import pytest from cldk.analysis.commons.results import Diagnostic, FlowPath, PathHop, SliceNode -from cldk.analysis.java.backend import JavaAnalysisBackend +from cldk.analysis.java.backend import SDG_REL_PATTERN, JavaAnalysisBackend +from cldk.analysis.java.neo4j.neo4j_backend import JNeo4jBackend from cldk.utils.exceptions.exceptions import CodeanalyzerExecutionException, CodeanalyzerUsageException, SelectorNotInGraph -from tests.analysis.java.test_java_addressing import _local +from tests.analysis.java.conftest import FakeDriver +from tests.analysis.java.test_java_addressing import _graph, _local HANDLE = "com.acme.Svc.handle(java.lang.String)" STORE = "com.acme.Dao.store(java.lang.String)" @@ -322,3 +333,148 @@ def test_the_two_walk_hooks_are_stubs_rather_than_abstract_methods(): JavaAnalysisBackend._taint_walk(None, [], [], cuts=[], cut_callables=[], depth=None, max_paths=1) with pytest.raises(NotImplementedError): JavaAnalysisBackend._edge_vars_in(None, f"can://java/acme/{HANDLE}") + + +# ============================================================================================== +# The graph backend's half, over a fake driver: statement text, parameter binding, row translation. +# ============================================================================================== +#: Two real positions of the committed a4 fixture, so ``taint()`` runs end to end on the graph +#: backend -- resolution, both gates, the walk, the verdict -- with only the rows faked. The pair is +#: the one the local group measures, which is what makes the two halves comparable. +BUY = f"{DIRECT}.buy(java.lang.String, java.lang.String, double, int)" +SET_IN_GLOBAL_TXN = f"{DIRECT}.setInGlobalTxn(boolean)" + + +class _TaintResponder: + """``_BodyNodeResponder`` plus the two taint statements, recording what each was bound with. + + The delegate answers the port-lattice probe and the per-callable body-node fetch out of the real + fixture, so the gates and the addressing behave as they do everywhere else offline; only the + walk's rows are canned. + """ + + def __init__(self, delegate, rows, edge_vars=()): + self.delegate, self.rows, self.edge_vars = delegate, rows, list(edge_vars) + self.seen = [] + + def __call__(self, query, params): + if "AS src, b.id AS dst" in query or "collect(DISTINCT e.var) AS vars" in query: + self.seen.append((query, dict(params))) + return [{"vars": self.edge_vars}] if "AS vars" in query else list(self.rows) + return self.delegate(query, params) + + +def _graph_with(analysis_json_a4, rows=(), edge_vars=()): + backend = _graph(analysis_json_a4) + responder = _TaintResponder(backend._driver.responder, rows, edge_vars) + backend._driver = FakeDriver(responder=responder) + return backend, responder + + +def _taint_row(backend, src_name, src_within, dst_name, dst_within): + """One witness in the shape :attr:`JNeo4jBackend._TAINT` projects: ``ns`` per node, ``rs`` per + hop, one fewer hop than nodes, with the ids the resolver really minted for those two positions.""" + a, b = backend.resolve_value(src_name, within=src_within), backend.resolve_value(dst_name, within=dst_within) + return { + "src": a.ref, + "dst": b.ref, + "ns": [{"ref": a.ref, "kind": "formal_in", "line": None}, {"ref": b.ref, "kind": "formal_in", "line": None}], + "rs": [{"via": "J_PARAM_IN", "var": "arg0", "prov": ["ssa"]}], + } + + +def test_the_graph_walk_issues_the_generated_statement_and_binds_the_cap_one_past_max_paths(analysis_json_a4): + """The extra row is the whole truncation mechanism (Ruling H / E5): bind ``$cap`` to + ``max_paths`` and ``taint()`` reports ``complete=True`` on a result it silently cut. ``$prefix`` + is bound in the same call because :attr:`JNeo4jBackend._TAINT` carries the interior scope + predicate -- an unbound parameter is a Cypher error, so this is what says the statement and the + call agree.""" + backend, responder = _graph_with(analysis_json_a4) + result = backend.taint([("orderProcessingMode", BUY)], [("inGlobalTxn", SET_IN_GLOBAL_TXN)], max_paths=3) + assert result.paths == [] and result.exhausted == [("orderProcessingMode", "inGlobalTxn")] + query, params = responder.seen[-1] + assert query == JNeo4jBackend._TAINT.format(rels=SDG_REL_PATTERN, depth="") + assert params == { + "srcs": [backend.resolve_value("orderProcessingMode", within=BUY).ref], + "dsts": [backend.resolve_value("inGlobalTxn", within=SET_IN_GLOBAL_TXN).ref], + "cuts": [], + "cut_callables": [], + "cap": 4, + "prefix": backend._scope_prefix, + } + + +def test_an_explicit_depth_reaches_the_graph_statement_as_the_quantifiers_upper_bound(analysis_json_a4): + """``depth=None`` renders ``*1..`` and a bound renders ``*1..5``. The walk is the only place that + substitution happens, so a backend that forgot it would answer every call unbounded -- and an + unbounded answer to a bounded question manufactures witnesses.""" + backend, responder = _graph_with(analysis_json_a4) + backend.taint([("orderProcessingMode", BUY)], [("inGlobalTxn", SET_IN_GLOBAL_TXN)], depth=5) + assert responder.seen[-1][0] == JNeo4jBackend._TAINT.format(rels=SDG_REL_PATTERN, depth="5") + assert "*1..5]->" in responder.seen[-1][0] + + +def test_the_graph_walk_binds_both_cut_lists_as_the_resolver_shaped_them(analysis_json_a4): + """A callable sanitizer becomes a bare ``can://`` id in ``$cut_callables``; a pair sanitizer + becomes a ``{var, prefix}`` map in ``$cuts``. The scoping is the ``prefix`` member: a flat list of + variable names would sever every ``arg0`` in the application, and over-cutting is the one output + this leg refuses.""" + backend, responder = _graph_with(analysis_json_a4, edge_vars=["arg0"]) + backend.taint([("orderProcessingMode", BUY)], [("inGlobalTxn", SET_IN_GLOBAL_TXN)], sanitizers=[SET_IN_GLOBAL_TXN, ("arg0", BUY)]) + walk = responder.seen[-1][1] + assert walk["cut_callables"] == [backend.resolve_callable(SET_IN_GLOBAL_TXN).ref] + assert walk["cuts"] == [{"var": "arg0", "prefix": backend.resolve_callable(BUY).ref}] + + +def test_the_graph_walk_returns_every_row_untrimmed_and_the_verdict_does_the_trimming(analysis_json_a4): + """Grouping and trimming are ``taint()``'s, so the walk hands back what it found -- including the + ``max_paths + 1``-th row. Trimming here would put the cap in two places and make ``complete`` + unprovable from either.""" + backend, _ = _graph_with(analysis_json_a4) + rows = [_taint_row(backend, "orderProcessingMode", BUY, "inGlobalTxn", SET_IN_GLOBAL_TXN)] * 2 + backend, _ = _graph_with(analysis_json_a4, rows=rows) + walked, _ledger = backend._taint_walk( + [backend.resolve_value("orderProcessingMode", within=BUY)], + [backend.resolve_value("inGlobalTxn", within=SET_IN_GLOBAL_TXN)], + cuts=[], + cut_callables=[], + depth=None, + max_paths=1, + ) + assert len(walked) == 2, "two rows for a cap of one: the extra row is what reports truncation" + assert _ledger == {}, "Ruling I: an empty ledger is a refusal to file, argued in the walk's docstring" + at_one = backend.taint([("orderProcessingMode", BUY)], [("inGlobalTxn", SET_IN_GLOBAL_TXN)], max_paths=1) + assert len(at_one.paths) == 1 and at_one.complete is False and at_one.exhausted == [] + + +def test_a_graph_row_is_described_the_way_a_slice_row_is(analysis_json_a4): + """Same ``_body_slice_node`` as the slice and ``paths_between``: the owning callable, the file and + the parameter name come off the id prefix and the index this backend already holds, so no + callable is joined back and a vertex reads identically whichever accessor returned it. A port + vertex has no span, so the callable's first line stands in.""" + backend, _ = _graph_with(analysis_json_a4) + backend, _ = _graph_with(analysis_json_a4, rows=[_taint_row(backend, "orderProcessingMode", BUY, "inGlobalTxn", SET_IN_GLOBAL_TXN)]) + result = backend.taint([("orderProcessingMode", BUY)], [("inGlobalTxn", SET_IN_GLOBAL_TXN)]) + (hop,) = result.paths[0].hops + assert (hop.via, hop.var, hop.prov) == ("argument", "arg0", ["ssa"]) + assert (hop.frm.callable, hop.frm.kind, hop.frm.name) == (BUY, "parameter", "orderProcessingMode") + assert (hop.to.callable, hop.to.kind, hop.to.name) == (SET_IN_GLOBAL_TXN, "parameter", "inGlobalTxn") + assert hop.frm.file.endswith("TradeDirect.java") and hop.frm.line > 0 + assert result.exhausted == [] and result.complete is True + + +def test_the_graph_edge_variable_domain_is_scoped_by_the_callables_own_ref_and_drops_the_nulls(analysis_json_a4): + """``$callable_prefix`` holds a **callable's** ``can://`` ref rather than the application's, which + is why it is spelled apart from ``$prefix``: what makes the statement application-scoped is a + property of the value bound to it -- a callable id embeds the application name -- and the scope + audit classifies it on that basis. + + ``collect(DISTINCT e.var)`` returns a ``null`` for every ``J_CDG``/``J_SUMMARY`` hop, and for the + port crossings of a graph emitted before codeanalyzer-java 3.1.2. Keeping it would put ``None`` + in a set ``resolve_sanitizers`` tests membership against, and ``resolve_sanitizers`` has already + refused a blank variable by then.""" + backend, responder = _graph_with(analysis_json_a4, edge_vars=["arg0", None, "conn"]) + assert backend._edge_vars_in(backend.resolve_callable(BUY).ref) == frozenset({"arg0", "conn"}) + query, params = responder.seen[-1] + assert query == JNeo4jBackend._EDGE_VARS.format(rels=SDG_REL_PATTERN) + assert params == {"callable_prefix": backend.resolve_callable(BUY).ref} From d3912d23de0e96ef40fb33ea18c417489c24b933 Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Thu, 10 Sep 2026 10:13:19 -0400 Subject: [PATCH 43/65] refactor(taint): both walk hooks are abstract, now that every backend has one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ruling G shipped `_taint_walk` and `_edge_vars_in` as concrete stubs that raised `NotImplementedError`, because an abstract method would have made every concrete backend un-instantiable until the last implementation landed. All six exist now — two per language — so the refusal moves from the call to the construction, where a backend missing an implementation is cheaper to find. Flipping them brought both hooks under each language's `test_every_abstract_method_signature_is_preserved`, which compares parameter annotations against the ABC's. All six `_taint_walk` bodies were written unannotated (including Python's two, from Task 6, which the stub had kept out of that audit), so each now carries the ABC's own signature; `_edge_vars_in` already matched. `Mapping`, `Diagnostic`, `FlowPath` and `SliceNode` are imported where the annotation needed them. Also drops an `invalid escape sequence` SyntaxWarning from the TypeScript local walk's `_edge_vars_in` docstring. --- cldk/analysis/java/backend.py | 11 ++++++----- .../analysis/java/codeanalyzer/codeanalyzer.py | 15 ++++++++++++--- cldk/analysis/java/neo4j/neo4j_backend.py | 15 ++++++++++++--- cldk/analysis/python/backend.py | 11 ++++++----- .../python/codeanalyzer/codeanalyzer.py | 15 ++++++++++++--- cldk/analysis/python/neo4j/neo4j_backend.py | 13 +++++++++++-- cldk/analysis/typescript/backend.py | 11 ++++++----- .../typescript/codeanalyzer/codeanalyzer.py | 16 +++++++++++++--- .../analysis/typescript/neo4j/neo4j_backend.py | 14 ++++++++++++-- tests/analysis/java/test_java_taint.py | 18 +++++++++--------- tests/analysis/python/test_python_taint.py | 18 +++++++++--------- .../typescript/test_typescript_taint.py | 18 +++++++++--------- 12 files changed, 117 insertions(+), 58 deletions(-) diff --git a/cldk/analysis/java/backend.py b/cldk/analysis/java/backend.py index 7191ffef..db192997 100644 --- a/cldk/analysis/java/backend.py +++ b/cldk/analysis/java/backend.py @@ -2117,6 +2117,7 @@ def taint( rows, blocked = self._taint_walk(srcs, dsts, cuts=cuts, cut_callables=cut_callables, depth=depth, max_paths=max_paths) return taint_verdict(sources, sinks, srcs, dsts, rows=rows, blocked=blocked, depth=depth, max_paths=max_paths) + @abstractmethod def _taint_walk( self, srcs: Sequence[SliceNode], @@ -2156,11 +2157,12 @@ def _taint_walk( answered. Nor is the port-lattice gate: :meth:`taint` opens :meth:`_require_connected_ports` too, in the same place the five sibling flow accessors do. - A stub rather than an ``@abstractmethod`` while the implementations land, so a backend - without one is refused when it is *called* rather than when it is constructed. + ``@abstractmethod`` now that every backend has one (Ruling G): it shipped as a concrete stub + so a backend without an implementation was refused when it was *called* rather than when it + was constructed, and the last implementation closed that window. """ - raise NotImplementedError + @abstractmethod def _edge_vars_in(self, callable_id: str) -> Collection[str]: """The variable names carried by SDG edges scoped to this callable -- the domain a variable sanitizer is checked against. @@ -2170,9 +2172,8 @@ def _edge_vars_in(self, callable_id: str) -> Collection[str]: the resolver would refuse a legitimate one for not being a parameter. One ``DISTINCT r.var`` query on the graph side, the adjacency already built on the local side. - A stub rather than an ``@abstractmethod`` for :meth:`_taint_walk`'s reason. + ``@abstractmethod`` for :meth:`_taint_walk`'s reason, and since the same commit. """ - raise NotImplementedError # -----[ the two facts a backend supplies about its own analysis ]----- def _require_dataflow(self) -> None: diff --git a/cldk/analysis/java/codeanalyzer/codeanalyzer.py b/cldk/analysis/java/codeanalyzer/codeanalyzer.py index 031acc09..cd59f1f2 100644 --- a/cldk/analysis/java/codeanalyzer/codeanalyzer.py +++ b/cldk/analysis/java/codeanalyzer/codeanalyzer.py @@ -33,7 +33,7 @@ import subprocess from pathlib import Path from subprocess import CompletedProcess -from typing import Any, Dict, FrozenSet, Iterable, List, Sequence, Tuple, Union +from typing import Any, Dict, FrozenSet, Iterable, List, Mapping, Sequence, Tuple, Union import networkx as nx from pydantic import ValidationError @@ -42,7 +42,7 @@ from cldk.analysis.commons.bounds import DEFAULT_PAGE_SIZE, check_page_size, edge_page from cldk.analysis.commons.graphs import flow_path, shortest_walks, slice_resolved, under_callable from cldk.analysis.commons.levels import ANALYZER_LEVELS, LEVEL_NAMES, analyzer_level -from cldk.analysis.commons.results import Diagnostic, EdgePage, FlowPaths, Slice, SliceNode +from cldk.analysis.commons.results import Diagnostic, EdgePage, FlowPath, FlowPaths, Slice, SliceNode from cldk.analysis.java.backend import ( CDG_ORDER, CFG_ORDER, @@ -550,7 +550,16 @@ def _value_paths(self, a: SliceNode, b: SliceNode, depth: int | None, max_paths: paths = [flow_path([described[a.ref]] + [described[ref] for ref, _ in walk], [label for _, label in walk], via=VIA) for walk in walks[:max_paths]] return FlowPaths(paths=paths, complete=len(walks) <= max_paths) - def _taint_walk(self, srcs, dsts, *, cuts, cut_callables, depth, max_paths): + def _taint_walk( + self, + srcs: Sequence[SliceNode], + dsts: Sequence[SliceNode], + *, + cuts: List[Dict[str, str]], + cut_callables: List[str], + depth: int | None, + max_paths: int, + ) -> Tuple[List[Tuple[str, str, FlowPath]], Mapping[Tuple[str, str], List[Diagnostic]]]: """The sanitized shortest walks, in process (see :meth:`JavaAnalysisBackend._taint_walk`). **No** ``self._require_dataflow()`` here, unlike Python's and TypeScript's local walks: diff --git a/cldk/analysis/java/neo4j/neo4j_backend.py b/cldk/analysis/java/neo4j/neo4j_backend.py index 00d638c1..e25d3d3a 100644 --- a/cldk/analysis/java/neo4j/neo4j_backend.py +++ b/cldk/analysis/java/neo4j/neo4j_backend.py @@ -112,13 +112,13 @@ import re from collections import defaultdict from functools import cached_property -from typing import Any, Dict, FrozenSet, Iterable, List, Sequence, Tuple +from typing import Any, Dict, FrozenSet, Iterable, List, Mapping, Sequence, Tuple import networkx as nx from cldk.analysis.commons.bounds import DEFAULT_PAGE_SIZE, EdgeOrder, check_page_size, cursor_params, encode_cursor, keyset_where from cldk.analysis.commons.graphs import flow_path, sdg_path_query, sdg_taint_query, slice_resolved -from cldk.analysis.commons.results import EdgePage, FlowPaths, Slice, SliceNode +from cldk.analysis.commons.results import Diagnostic, EdgePage, FlowPath, FlowPaths, Slice, SliceNode from cldk.analysis.java.backend import ( CDG_ORDER, CFG_ORDER, @@ -979,7 +979,16 @@ def _value_paths(self, a: SliceNode, b: SliceNode, depth: int | None, max_paths: #: backends. One round trip per variable sanitizer, which is as often as a caller writes one. _EDGE_VARS = "MATCH (n:JBodyNode)-[e:{rels}]->() WHERE n.id STARTS WITH $callable_prefix RETURN collect(DISTINCT e.var) AS vars" - def _taint_walk(self, srcs, dsts, *, cuts, cut_callables, depth, max_paths): + def _taint_walk( + self, + srcs: Sequence[SliceNode], + dsts: Sequence[SliceNode], + *, + cuts: List[Dict[str, str]], + cut_callables: List[str], + depth: int | None, + max_paths: int, + ) -> Tuple[List[Tuple[str, str, FlowPath]], Mapping[Tuple[str, str], List[Diagnostic]]]: """The sanitized shortest walks, server-side (see :meth:`JavaAnalysisBackend._taint_walk`). One statement for the whole batch, and one row per witness -- ``a.id AS src`` / ``b.id AS diff --git a/cldk/analysis/python/backend.py b/cldk/analysis/python/backend.py index 5f82a9e7..a94c5fb9 100644 --- a/cldk/analysis/python/backend.py +++ b/cldk/analysis/python/backend.py @@ -1141,6 +1141,7 @@ def taint( rows, blocked = self._taint_walk(srcs, dsts, cuts=cuts, cut_callables=cut_callables, depth=depth, max_paths=max_paths) return taint_verdict(sources, sinks, srcs, dsts, rows=rows, blocked=blocked, depth=depth, max_paths=max_paths) + @abstractmethod def _taint_walk( self, srcs: Sequence[SliceNode], @@ -1178,11 +1179,12 @@ def _taint_walk( the analysis level (their attach probe never looks at the dependence relationships), so the gate lives in the implementations that can answer rather than in :meth:`taint`. - A stub rather than an ``@abstractmethod`` while the implementations land, so a backend - without one is refused when it is *called* rather than when it is constructed. + ``@abstractmethod`` now that every backend has one (Ruling G): it shipped as a concrete stub + so a backend without an implementation was refused when it was *called* rather than when it + was constructed, and the last implementation closed that window. """ - raise NotImplementedError + @abstractmethod def _edge_vars_in(self, callable_id: str) -> Collection[str]: """The variable names carried by SDG edges scoped to this callable -- the domain a variable sanitizer is checked against. @@ -1192,9 +1194,8 @@ def _edge_vars_in(self, callable_id: str) -> Collection[str]: the resolver would refuse a legitimate one for not being a parameter. One ``DISTINCT r.var`` query on the graph side, the adjacency already built on the local side. - A stub rather than an ``@abstractmethod`` for :meth:`_taint_walk`'s reason. + ``@abstractmethod`` for :meth:`_taint_walk`'s reason, and since the same commit. """ - raise NotImplementedError def describe(self, nodes: Sequence[object]) -> List[SliceNode]: """Fill in :attr:`~cldk.analysis.commons.results.SliceNode.source` for these positions. diff --git a/cldk/analysis/python/codeanalyzer/codeanalyzer.py b/cldk/analysis/python/codeanalyzer/codeanalyzer.py index 818c2181..7c20ff52 100644 --- a/cldk/analysis/python/codeanalyzer/codeanalyzer.py +++ b/cldk/analysis/python/codeanalyzer/codeanalyzer.py @@ -52,7 +52,7 @@ import logging from functools import partial from pathlib import Path -from typing import Dict, FrozenSet, Iterator, List, Sequence, Tuple, Union +from typing import Dict, FrozenSet, Iterator, List, Mapping, Sequence, Tuple, Union import networkx as nx @@ -64,7 +64,7 @@ from cldk.analysis.commons.graphs import call_reaches, under_callable from cldk.analysis.commons.levels import ANALYZER_LEVELS, LEVEL_NAMES, analyzer_level from cldk.analysis.commons.resolve import CallableCandidate, body_node_kind, resolve_callable_signature, resolve_value_name, resolve_within, value_candidate -from cldk.analysis.commons.results import BodyRef, CallableRef, Diagnostic, EdgePage, EntrypointCoverage, FlowPaths, LocateResult, ModuleRef, Slice, SliceNode, TypeRef +from cldk.analysis.commons.results import BodyRef, CallableRef, Diagnostic, EdgePage, EntrypointCoverage, FlowPath, FlowPaths, LocateResult, ModuleRef, Slice, SliceNode, TypeRef from cldk.utils.exceptions import CodeanalyzerUsageException from cldk.analysis.python.backend import ( CDG_ORDER, @@ -1439,7 +1439,16 @@ def _value_paths(self, a: SliceNode, b: SliceNode, depth: int | None, max_paths: paths = [flow_path([described[a.ref]] + [described[ref] for ref, _ in walk], [label for _, label in walk], via=VIA) for walk in walks[:max_paths]] return FlowPaths(paths=paths, complete=len(walks) <= max_paths) - def _taint_walk(self, srcs, dsts, *, cuts, cut_callables, depth, max_paths): + def _taint_walk( + self, + srcs: Sequence[SliceNode], + dsts: Sequence[SliceNode], + *, + cuts: List[Dict[str, str]], + cut_callables: List[str], + depth: int | None, + max_paths: int, + ) -> Tuple[List[Tuple[str, str, FlowPath]], Mapping[Tuple[str, str], List[Diagnostic]]]: """The sanitized shortest walks, in process (see :meth:`PythonAnalysisBackend._taint_walk`). ``self._require_dataflow()`` first, per :meth:`PythonAnalysisBackend.taint`'s own note: the diff --git a/cldk/analysis/python/neo4j/neo4j_backend.py b/cldk/analysis/python/neo4j/neo4j_backend.py index d0125502..fade7db2 100644 --- a/cldk/analysis/python/neo4j/neo4j_backend.py +++ b/cldk/analysis/python/neo4j/neo4j_backend.py @@ -89,7 +89,7 @@ from collections import defaultdict from contextlib import contextmanager from functools import cached_property -from typing import Any, Callable, Dict, FrozenSet, List, Sequence, Tuple +from typing import Any, Callable, Dict, FrozenSet, List, Mapping, Sequence, Tuple import networkx as nx from codeanalyzer.schema import model_dump_json @@ -1674,7 +1674,16 @@ def paths_between(self, src: str, dst: str, *, src_within: str, dst_within: str, #: as often as a caller writes one. _EDGE_VARS = "MATCH (n:PyBodyNode)-[r:{rels}]->() WHERE n.id STARTS WITH $callable_prefix RETURN collect(DISTINCT r.var) AS vars" - def _taint_walk(self, srcs, dsts, *, cuts, cut_callables, depth, max_paths): + def _taint_walk( + self, + srcs: Sequence[SliceNode], + dsts: Sequence[SliceNode], + *, + cuts: List[Dict[str, str]], + cut_callables: List[str], + depth: int | None, + max_paths: int, + ) -> Tuple[List[Tuple[str, str, FlowPath]], Mapping[Tuple[str, str], List[Diagnostic]]]: """The sanitized shortest walks, server-side (see :meth:`PythonAnalysisBackend._taint_walk`). One statement for the whole batch, and one row per witness -- ``a.id AS src`` / ``b.id AS diff --git a/cldk/analysis/typescript/backend.py b/cldk/analysis/typescript/backend.py index b82a5c3b..43fd2386 100644 --- a/cldk/analysis/typescript/backend.py +++ b/cldk/analysis/typescript/backend.py @@ -1017,6 +1017,7 @@ def taint( rows, blocked = self._taint_walk(srcs, dsts, cuts=cuts, cut_callables=cut_callables, depth=depth, max_paths=max_paths) return taint_verdict(sources, sinks, srcs, dsts, rows=rows, blocked=blocked, depth=depth, max_paths=max_paths) + @abstractmethod def _taint_walk( self, srcs: Sequence[SliceNode], @@ -1054,11 +1055,12 @@ def _taint_walk( the analysis level (their attach probe never looks at the dependence relationships), so the gate lives in the implementations that can answer rather than in :meth:`taint`. - A stub rather than an ``@abstractmethod`` while the implementations land, so a backend - without one is refused when it is *called* rather than when it is constructed. + ``@abstractmethod`` now that every backend has one (Ruling G): it shipped as a concrete stub + so a backend without an implementation was refused when it was *called* rather than when it + was constructed, and the last implementation closed that window. """ - raise NotImplementedError + @abstractmethod def _edge_vars_in(self, callable_id: str) -> Collection[str]: """The variable names carried by SDG edges scoped to this callable -- the domain a variable sanitizer is checked against. @@ -1068,9 +1070,8 @@ def _edge_vars_in(self, callable_id: str) -> Collection[str]: the resolver would refuse a legitimate one for not being a parameter. One ``DISTINCT r.var`` query on the graph side, the adjacency already built on the local side. - A stub rather than an ``@abstractmethod`` for :meth:`_taint_walk`'s reason. + ``@abstractmethod`` for :meth:`_taint_walk`'s reason, and since the same commit. """ - raise NotImplementedError def describe(self, nodes: Sequence[object]) -> List[SliceNode]: """Fill in :attr:`~cldk.analysis.commons.results.SliceNode.source` for these positions. diff --git a/cldk/analysis/typescript/codeanalyzer/codeanalyzer.py b/cldk/analysis/typescript/codeanalyzer/codeanalyzer.py index a0899f4f..f3812469 100644 --- a/cldk/analysis/typescript/codeanalyzer/codeanalyzer.py +++ b/cldk/analysis/typescript/codeanalyzer/codeanalyzer.py @@ -35,7 +35,7 @@ from functools import cached_property, partial from pathlib import Path from subprocess import CompletedProcess -from typing import Dict, FrozenSet, Iterator, List, Sequence, Set, Tuple, Union +from typing import Dict, FrozenSet, Iterator, List, Mapping, Sequence, Set, Tuple, Union import networkx as nx @@ -61,6 +61,7 @@ Diagnostic, EdgePage, EntrypointCoverage, + FlowPath, FlowPaths, LocateResult, ModuleRef, @@ -1338,7 +1339,16 @@ def _call_neighbours(self, name: str, in_class: str | None, in_module: str | Non #: to TypeScript's ``via`` table. _shortest_walks = staticmethod(partial(shortest_walks, via=VIA)) - def _taint_walk(self, srcs, dsts, *, cuts, cut_callables, depth, max_paths): + def _taint_walk( + self, + srcs: Sequence[SliceNode], + dsts: Sequence[SliceNode], + *, + cuts: List[Dict[str, str]], + cut_callables: List[str], + depth: int | None, + max_paths: int, + ) -> Tuple[List[Tuple[str, str, FlowPath]], Mapping[Tuple[str, str], List[Diagnostic]]]: """The sanitized shortest walks, in process (see :meth:`TSAnalysisBackend._taint_walk`). ``self._require_dataflow()`` first, per Ruling F and :meth:`TSAnalysisBackend.taint`'s own @@ -1403,7 +1413,7 @@ def _edge_vars_in(self, callable_id: str) -> FrozenSet[str]: scan of it and no second traversal. Edges *leaving* a node under ``callable_id`` -- the same ``startNode`` scoping the cut itself uses, so this validates exactly the domain the cut can match. Two of the five relationship types carry no ``var`` (``TS_CDG`` and ``TS_SUMMARY``); - those ``None``\ s are dropped, because ``resolve_sanitizers`` refuses a blank variable + those ``None`` values are dropped, because ``resolve_sanitizers`` refuses a blank variable before it asks. :func:`~cldk.analysis.commons.graphs.under_callable` and not ``startswith`` for Ruling K's diff --git a/cldk/analysis/typescript/neo4j/neo4j_backend.py b/cldk/analysis/typescript/neo4j/neo4j_backend.py index fc4cf13b..21c3b1e5 100644 --- a/cldk/analysis/typescript/neo4j/neo4j_backend.py +++ b/cldk/analysis/typescript/neo4j/neo4j_backend.py @@ -112,7 +112,7 @@ class never writes and needs neither the analyzer binary nor the sources. import logging from collections import defaultdict from functools import cached_property -from typing import Any, Dict, FrozenSet, List, Sequence, Set, Tuple +from typing import Any, Dict, FrozenSet, List, Mapping, Sequence, Set, Tuple import networkx as nx @@ -142,6 +142,7 @@ class never writes and needs neither the analyzer binary nor the sources. Diagnostic, EdgePage, EntrypointCoverage, + FlowPath, FlowPaths, LocateResult, ModuleRef, @@ -1936,7 +1937,16 @@ def paths_between(self, src: str, dst: str, *, src_within: str, dst_within: str, #: index. Same reason the Python twin spells it ``:PyBodyNode``. _EDGE_VARS = "MATCH (n:TSBodyNode)-[r:{rels}]->() WHERE n.id STARTS WITH $callable_prefix RETURN collect(DISTINCT r.var) AS vars" - def _taint_walk(self, srcs, dsts, *, cuts, cut_callables, depth, max_paths): + def _taint_walk( + self, + srcs: Sequence[SliceNode], + dsts: Sequence[SliceNode], + *, + cuts: List[Dict[str, str]], + cut_callables: List[str], + depth: int | None, + max_paths: int, + ) -> Tuple[List[Tuple[str, str, FlowPath]], Mapping[Tuple[str, str], List[Diagnostic]]]: """The sanitized shortest walks, server-side (see :meth:`TSAnalysisBackend._taint_walk`). One statement for the whole batch, and one row per witness -- ``a.id AS src`` / ``b.id AS diff --git a/tests/analysis/java/test_java_taint.py b/tests/analysis/java/test_java_taint.py index a178864d..ce2c217f 100644 --- a/tests/analysis/java/test_java_taint.py +++ b/tests/analysis/java/test_java_taint.py @@ -324,15 +324,15 @@ def test_roots_are_deduplicated_by_ref_so_one_position_is_audited_once(): assert result.resolved == f"{HANDLE} parameter 'in', {STORE} parameter 'sql'" -def test_the_two_walk_hooks_are_stubs_rather_than_abstract_methods(): - """Ruling G: an abstract method here would make every concrete backend un-instantiable until the - last implementation lands, so they raise instead. Task 7 flips them, and this test is what says - the stub is still a stub.""" - assert not {"_taint_walk", "_edge_vars_in"} & JavaAnalysisBackend.__abstractmethods__ - with pytest.raises(NotImplementedError): - JavaAnalysisBackend._taint_walk(None, [], [], cuts=[], cut_callables=[], depth=None, max_paths=1) - with pytest.raises(NotImplementedError): - JavaAnalysisBackend._edge_vars_in(None, f"can://java/acme/{HANDLE}") +def test_the_two_walk_hooks_are_abstract_methods(): + """Ruling G, discharged: the hooks shipped as concrete stubs because an abstract method would + have made every backend un-instantiable until the last implementation landed, so a backend + without one was refused when the walk was *called*. Every backend has one now, so the refusal + moves to construction, where a missing implementation is cheaper to find.""" + assert {"_taint_walk", "_edge_vars_in"} <= JavaAnalysisBackend.__abstractmethods__ + with pytest.raises(TypeError) as err: + type("_NoWalk", (JavaAnalysisBackend,), {})() + assert "_taint_walk" in str(err.value) and "_edge_vars_in" in str(err.value) # ============================================================================================== diff --git a/tests/analysis/python/test_python_taint.py b/tests/analysis/python/test_python_taint.py index f3333e42..46c84011 100644 --- a/tests/analysis/python/test_python_taint.py +++ b/tests/analysis/python/test_python_taint.py @@ -280,12 +280,12 @@ def test_the_walk_hook_is_called_once_per_call_with_the_cap_the_caller_wrote(): assert walk["dsts"] == [b.ref] -def test_the_two_walk_hooks_are_stubs_rather_than_abstract_methods(): - """Ruling G: an abstract method here would make every concrete backend un-instantiable until the - last implementation lands, so they raise instead. Task 7 flips them, and this test is what says - the stub is still a stub.""" - assert not {"_taint_walk", "_edge_vars_in"} & PythonAnalysisBackend.__abstractmethods__ - with pytest.raises(NotImplementedError): - PythonAnalysisBackend._taint_walk(None, [], [], cuts=[], cut_callables=[], depth=None, max_paths=1) - with pytest.raises(NotImplementedError): - PythonAnalysisBackend._edge_vars_in(None, "can://app/python/app.py/f") +def test_the_two_walk_hooks_are_abstract_methods(): + """Ruling G, discharged: the hooks shipped as concrete stubs because an abstract method would + have made every backend un-instantiable until the last implementation landed, so a backend + without one was refused when the walk was *called*. Every backend has one now, so the refusal + moves to construction, where a missing implementation is cheaper to find.""" + assert {"_taint_walk", "_edge_vars_in"} <= PythonAnalysisBackend.__abstractmethods__ + with pytest.raises(TypeError) as err: + type("_NoWalk", (PythonAnalysisBackend,), {})() + assert "_taint_walk" in str(err.value) and "_edge_vars_in" in str(err.value) diff --git a/tests/analysis/typescript/test_typescript_taint.py b/tests/analysis/typescript/test_typescript_taint.py index e22e219a..7f96ba43 100644 --- a/tests/analysis/typescript/test_typescript_taint.py +++ b/tests/analysis/typescript/test_typescript_taint.py @@ -266,15 +266,15 @@ def test_roots_are_deduplicated_by_ref_so_one_position_is_audited_once(): assert result.resolved == "f parameter 'x', g parameter 'y'" -def test_the_two_walk_hooks_are_stubs_rather_than_abstract_methods(): - """Ruling G: an abstract method here would make every concrete backend un-instantiable until the - last implementation lands, so they raise instead. Task 7 flips them, and this test is what says - the stub is still a stub.""" - assert not {"_taint_walk", "_edge_vars_in"} & TSAnalysisBackend.__abstractmethods__ - with pytest.raises(NotImplementedError): - TSAnalysisBackend._taint_walk(None, [], [], cuts=[], cut_callables=[], depth=None, max_paths=1) - with pytest.raises(NotImplementedError): - TSAnalysisBackend._edge_vars_in(None, "can://app/typescript/app.ts/f") +def test_the_two_walk_hooks_are_abstract_methods(): + """Ruling G, discharged: the hooks shipped as concrete stubs because an abstract method would + have made every backend un-instantiable until the last implementation landed, so a backend + without one was refused when the walk was *called*. Every backend has one now, so the refusal + moves to construction, where a missing implementation is cheaper to find.""" + assert {"_taint_walk", "_edge_vars_in"} <= TSAnalysisBackend.__abstractmethods__ + with pytest.raises(TypeError) as err: + type("_NoWalk", (TSAnalysisBackend,), {})() + assert "_taint_walk" in str(err.value) and "_edge_vars_in" in str(err.value) # ============================================================================================== From 38b5241cfcc7004f57165d590ee88db2c24ec0f1 Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Thu, 10 Sep 2026 10:13:33 -0400 Subject: [PATCH 44/65] test(taint): pin each backend's _TAINT by digest, like _PATHS `_TAINT` is a call to `sdg_taint_query(...)`, so nothing in the suite could be compared against it: a change to the generator, to `path_order`, or to one backend's node label or scope callables moves both sides of any equality together. The digest is what still fails. Same failure note as `PATHS_DIGESTS`: update it in the commit that changed the statement, never in a separate one. --- tests/analysis/commons/test_lifted_helpers.py | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/analysis/commons/test_lifted_helpers.py b/tests/analysis/commons/test_lifted_helpers.py index 71945247..6630c086 100644 --- a/tests/analysis/commons/test_lifted_helpers.py +++ b/tests/analysis/commons/test_lifted_helpers.py @@ -375,3 +375,23 @@ def test_the_taint_query_still_formats(): out = q.format(rels=sdg_rel_pattern("PY"), depth="") assert "{{" not in out and "}}" not in out, "an escape survived formatting" assert out.count("allShortestPaths") == 1 + + +# ---------------------------------------------------------------------------------------------- +# Leg 4b, Task 7: the three ``_TAINT`` statements, now that all three exist. +# ---------------------------------------------------------------------------------------------- + +#: A digest of each graph backend's `_TAINT`, for `PATHS_DIGESTS`' reason: each is a call to +#: `sdg_taint_query(...)` with its own arguments, so a change to the generator, to `path_order`, or +#: to one backend's node label or scope callables moves every side that could be compared against it. +#: +#: **When this fails:** the statement changed. Print `backend._TAINT` and diff it against the +#: previous value, decide whether the change was intended, and if it was, update the digest **in the +#: same commit that changed the statement** -- never in a separate one. +TAINT_DIGESTS = {"PY": "5e5b19785ef89b40", "J": "e044026c1df6827d", "TS": "095ad613e9a19952"} + + +@pytest.mark.parametrize("P", ["PY", "J", "TS"]) +def test_the_generated_taint_statement_has_not_drifted(P): + backend = dict(_path_backends())[P] + assert hashlib.sha256(backend._TAINT.encode()).hexdigest()[:16] == TAINT_DIGESTS[P], backend._TAINT From e79de3ffff89df81fe1a0a46ca2d52943ec533e0 Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Thu, 10 Sep 2026 12:06:07 -0400 Subject: [PATCH 45/65] fix(graphs): sdg_taint_query refuses a self-pair instead of aborting the batch Neo4j refuses allShortestPaths when the start and end node are the same, and it refuses it for the whole statement -- so one overlapping selector aborted the entire m*n batch with Neo.DatabaseError.Statement.ExecutionFailed: The shortest path algorithm does not work when the start and end nodes are the same instead of answering the pairs that were fine. Measured on the leg-4b fixture: a 13-selector batch with sources == sinks (169 pairs, 13 of them degenerate) raised on the graph backend while the local replay answered 156. taint_verdict already books a degenerate_pair diagnostic for such a pair and skips it, but it only sees rows the walk returned, and the walk never got to return any. The guard goes in b's WHERE so it filters the cartesian before the shortest-path operator, and degenerate_pair still fires, because taint_verdict iterates the pairs the caller requested rather than the rows that came back. The query digests move with the text. The string tripwire on the null-safe cut gains a sibling assertion, so a future edit that drops the guard fails here rather than in a live batch someone is depending on. --- cldk/analysis/commons/graphs.py | 15 ++++++++++++++- tests/analysis/commons/test_lifted_helpers.py | 3 ++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/cldk/analysis/commons/graphs.py b/cldk/analysis/commons/graphs.py index 3e7c315e..7805e03d 100644 --- a/cldk/analysis/commons/graphs.py +++ b/cldk/analysis/commons/graphs.py @@ -342,6 +342,19 @@ def sdg_taint_query(P: str, *, node_label: str, endpoint_scope: Callable[[str], and turn found flows into confident refutations. This function does not enforce the rejection; a caller upstream of it must (:func:`~cldk.analysis.commons.resolve.resolve_sanitizers` does). + * ``b <> a`` is a **correctness guard, not an optimisation**. Neo4j refuses + ``allShortestPaths`` when the start and end node are the same + (``Neo.DatabaseError.Statement.ExecutionFailed``, "the shortest path algorithm does not work + when the start and end nodes are the same"), and it refuses it for the *whole statement* -- + so a single overlapping selector aborts the entire m*n batch with a driver exception instead + of returning the other pairs. :func:`taint_verdict` already skips such a pair and books a + ``degenerate_pair`` diagnostic, but it only sees rows the walk returned, and the walk never + got to return any. Measured: a 13-selector batch with ``sources == sinks`` (169 pairs, 13 of + them degenerate) raised rather than answering 156. The guard sits in ``b``'s ``WHERE`` so it + filters the cartesian *before* the shortest-path operator, and ``degenerate_pair`` still + fires because ``taint_verdict`` iterates the pairs the caller *requested*, not the rows that + came back. + * The cap is **per pair** -- ``collect(p)[0..$cap]`` after an ordered ``WITH a, b`` -- rather than a flat ``LIMIT``, because with one sink and forty sources a flat cap lets one prolific pair starve the other thirty-nine, and in triage the per-source witness is the answer. @@ -359,7 +372,7 @@ def sdg_taint_query(P: str, *, node_label: str, endpoint_scope: Callable[[str], interior = f" AND all(n IN nodes(p) WHERE {interior_scope('n')})" if interior_scope else "" return ( f"MATCH (a:{node_label}) WHERE a.id IN $srcs{a_scope} " - f"MATCH (b:{node_label}) WHERE b.id IN $dsts{b_scope} " + f"MATCH (b:{node_label}) WHERE b.id IN $dsts AND b <> a{b_scope} " "MATCH p = allShortestPaths((a)-[:{rels}*1..{depth}]->(b)) " "WHERE all(n IN nodes(p) WHERE NOT any(q IN $cut_callables WHERE " "n.id = q OR n.id STARTS WITH q + '@' OR n.id STARTS WITH q + '/'))" diff --git a/tests/analysis/commons/test_lifted_helpers.py b/tests/analysis/commons/test_lifted_helpers.py index 6630c086..d7dfba32 100644 --- a/tests/analysis/commons/test_lifted_helpers.py +++ b/tests/analysis/commons/test_lifted_helpers.py @@ -323,6 +323,7 @@ def test_the_taint_query_is_null_safe_and_caps_per_pair(): assert "collect(p)[0..$cap]" in q, "a flat LIMIT lets one prolific pair starve the rest" assert "a.id IN $srcs" in q and "b.id IN $dsts" in q, "taint is m x n in one statement" assert "allShortestPaths" in q, "a variable-length pattern enumerates trails and will not finish" + assert "b <> a" in q, "Neo4j aborts the whole batch when one requested pair is source == sink" def test_the_taint_query_groups_by_pair(): @@ -388,7 +389,7 @@ def test_the_taint_query_still_formats(): #: **When this fails:** the statement changed. Print `backend._TAINT` and diff it against the #: previous value, decide whether the change was intended, and if it was, update the digest **in the #: same commit that changed the statement** -- never in a separate one. -TAINT_DIGESTS = {"PY": "5e5b19785ef89b40", "J": "e044026c1df6827d", "TS": "095ad613e9a19952"} +TAINT_DIGESTS = {"PY": "7187b2a862485643", "J": "f21a4e01ddc3e8a9", "TS": "eb9ac5abbd76fd14"} @pytest.mark.parametrize("P", ["PY", "J", "TS"]) From d7be77be78ff7b8e883632453339d5c48c87ce6c Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Thu, 10 Sep 2026 12:06:23 -0400 Subject: [PATCH 46/65] feat(facades): taint() on all three facades, with the contract in the docstring One-line delegation to the backend, signature copied from the ABC. The docstring is the deliverable: it says what exhausted is (a conditional refutation certificate -- listed only when the pair has no witness, no diagnostic implicating it, and depth is None), that complete is the batch's flag and not the pair's (one blocked pair voids every absence claim in the result, not just the pair the diagnostic names), that max_paths caps per pair rather than per call, and that the sources, sinks and sanitizers are the caller's to supply -- this SDK ships no framework catalogue, because a per-language vocabulary of taint sources is policy that rots. The two sanitizer mechanisms are told apart by shape and the docstring says which is which: a bare str cuts a callable on the path (a transforming sanitizer), a (name, within) pair cuts a variable inside that callable, which is the only thing that severs a validating guard -- a guard never sits on the data path, it reads the value and throws. The Java and TypeScript public-surface tests grow the SURFACE entry, so the signature is pinned character for character against the ABC's. --- cldk/analysis/java/java_analysis.py | 69 ++++++++++++++++- cldk/analysis/python/python_analysis.py | 75 ++++++++++++++++++- .../typescript/typescript_analysis.py | 63 +++++++++++++++- .../analysis/java/test_java_public_surface.py | 1 + .../test_typescript_public_surface.py | 1 + 5 files changed, 206 insertions(+), 3 deletions(-) diff --git a/cldk/analysis/java/java_analysis.py b/cldk/analysis/java/java_analysis.py index b464779c..69fb83a8 100644 --- a/cldk/analysis/java/java_analysis.py +++ b/cldk/analysis/java/java_analysis.py @@ -54,7 +54,7 @@ from cldk.analysis.commons.backend_config import CodeAnalyzerConfig, JavaBackend, Neo4jConnectionConfig, cache_subdir from cldk.analysis.commons.bounds import DEFAULT_DEPTH, DEFAULT_MAX_NODES, DEFAULT_MAX_PATHS, DEFAULT_PAGE_SIZE -from cldk.analysis.commons.results import EdgePage, EntrypointCoverage, FlowPaths, LocateResult, Slice, SliceNode +from cldk.analysis.commons.results import EdgePage, EntrypointCoverage, FlowPaths, LocateResult, Slice, SliceNode, TaintResult from cldk.analysis.commons.treesitter import TreesitterJava from cldk.models.java import JCallable from cldk.models.java import JApplication @@ -1651,6 +1651,73 @@ def flows_to_argument(self, src: str, callee: str, arg: str, *, within: str, dep """ return self.backend.flows_to_argument(src, callee, arg, within=within, depth=depth) + def taint( + self, + sources: Sequence[Tuple[str, str]], + sinks: Sequence[Tuple[str, str]], + sanitizers: Sequence[Tuple[str, str] | str] = (), + *, + depth: int | None = None, + max_paths: int = DEFAULT_MAX_PATHS, + ) -> TaintResult: + """Which of these sources reach which of these sinks, and what to make of the ones that do not. + + m sources against n sinks in one traversal, where :meth:`paths_between` proves one flow:: + + r = java.taint( + sources=[("userID", "TradeAppServlet.doPost")], + sinks=[("sql", "TradeDirect.getOrders")], + sanitizers=["StringEscapeUtils.escapeHtml4", ("checked", "TradeAppServlet.doPost")], + ) + for path in r.paths: + print(" -> ".join(h.to.name for h in path.hops)) + for src, sink in r.exhausted: + print(src, "does not reach", sink) + + **``exhausted`` is the reason to call this and the only output that can do harm.** A pair is + listed there when it was searched to exhaustion and nothing was found — the refutation + :meth:`paths_between`'s ``[]`` cannot give — and only when all three hold: no witness, no + diagnostic in ``unresolved`` implicating it, and **``depth`` was ``None``**. An explicit + ``depth`` empties ``exhausted`` by rule, because a bound turns a real long flow into an empty + result and a wrong refutation closes a live alert. + + **``complete`` is the batch's flag, not the pair's.** While it is ``False``, no absence claim + stands on any pair in the result: one blocked pair voids the whole batch's ``exhausted``. + + **Sources, sinks and sanitizers are the caller's to supply** — no framework catalogue ships + here. A bare ``str`` cuts a *callable* on the path (a transforming sanitizer); a + ``(name, within)`` pair cuts a *variable* inside that callable, which is the only thing that + severs a *validating* guard, since a guard never sits on the data path. Both cuts are applied + inside the search, so the result is the shortest **unsanitized** route. + + **Java refuses this on a disconnected port lattice**, exactly as :meth:`slice_forward`, + :meth:`paths_between`, :meth:`flows_to_call` and :meth:`flows_to_argument` do + (codeanalyzer-java#227). The gate is asked of the data — whether this application's + ``formal_in`` vertices carry any outgoing SDG edge — and never of the analyzer's version, so + output that connects the two layers makes this answer with no change here. Names and bounds + are judged first, so a typo is reported as a typo. + + Args: + sources: The values taint enters at, each ``(name, within)``. + sinks: The values it must not reach, addressed the same way. + sanitizers: Bare names cut callables; ``(name, within)`` pairs cut variables. + depth: Most hops; ``None`` (the default) for no bound, and ``exhausted`` is empty + whenever it is set. + max_paths: Most witnesses **per pair**, not per call. + + Raises: + AmbiguousName / SelectorNotInGraph: A name, or a sanitizer's ``within``, matched more + than one thing or nothing — including a sanitizer whose shape disagrees with what it + resolves to. + TypeError: ``sources`` or ``sinks`` is a bare string, which would unpack into a pair. + ValueError: A bound is out of range, ``sources`` or ``sinks`` is empty, or a sanitizer + names a blank variable. + CodeanalyzerExecutionException: The analyzer's port lattice carries no dependence edge. + CodeanalyzerUsageException: This analysis was built below + ``analysis_level="system_dependency_graph"``. + """ + return self.backend.taint(sources, sinks, sanitizers, depth=depth, max_paths=max_paths) + @property def has_resolution_edges(self) -> bool: """Whether call sites carry a resolved callee on this backend right now. diff --git a/cldk/analysis/python/python_analysis.py b/cldk/analysis/python/python_analysis.py index 85c08ab1..3f846ee1 100644 --- a/cldk/analysis/python/python_analysis.py +++ b/cldk/analysis/python/python_analysis.py @@ -53,7 +53,7 @@ from tree_sitter import Tree from cldk.analysis.commons.backend_config import Neo4jConnectionConfig, PyBackend, PyCodeAnalyzerConfig, cache_subdir -from cldk.analysis.commons.results import EdgePage, EntrypointCoverage, FlowPaths, LocateResult, Slice, SliceNode +from cldk.analysis.commons.results import EdgePage, EntrypointCoverage, FlowPaths, LocateResult, Slice, SliceNode, TaintResult from cldk.analysis.commons.treesitter import TreesitterPython from cldk.analysis.python.backend import DEFAULT_DEPTH, DEFAULT_MAX_NODES, DEFAULT_MAX_PATHS, DEFAULT_PAGE_SIZE, PythonAnalysisBackend from cldk.analysis.python.codeanalyzer import PyCodeanalyzer @@ -1393,6 +1393,79 @@ def flows_to_argument(self, src: str, callee: str, arg: str, *, within: str, dep """ return self.backend.flows_to_argument(src, callee, arg, within=within, depth=depth) + def taint( + self, + sources: Sequence[Tuple[str, str]], + sinks: Sequence[Tuple[str, str]], + sanitizers: Sequence[Tuple[str, str] | str] = (), + *, + depth: int | None = None, + max_paths: int = DEFAULT_MAX_PATHS, + ) -> TaintResult: + """Which of these sources reach which of these sinks, and what to make of the ones that do not. + + Where :meth:`paths_between` proves *one* flow, this asks m sources against n sinks in one + traversal and reports, per pair, whether a flow was found, refuted, or neither:: + + r = py.taint( + sources=[("invoice_id", "PaymentPortal.invoice_transaction")], + sinks=[("query", "AccountMove._execute")], + sanitizers=["html.escape", ("checked_id", "PaymentPortal.invoice_transaction")], + ) + for path in r.paths: # the witnesses + print(" -> ".join(h.to.name for h in path.hops)) + for src, sink in r.exhausted: # searched, nothing found + print(src, "does not reach", sink) + for d in r.unresolved: # neither: read this before either + print(d.code, d.message) + + **``exhausted`` is the reason to call this and the only output that can do harm.** A pair + listed there was searched to exhaustion with nothing found — the refutation + :meth:`paths_between` cannot give you, since its ``[]`` cannot tell "no flow exists" from + "the flow left the resolved graph". It is listed only when all three hold: the pair has no + witness, no diagnostic in ``unresolved`` implicates it, and **``depth`` was ``None``**. An + explicit ``depth`` empties ``exhausted`` by rule and not by tendency, because a bound turns + a real long flow into an empty result, and a wrong refutation closes a live alert. + + **``complete`` is the batch's flag, not the pair's.** One skipped or blocked pair makes it + ``False`` however cleanly the rest answered, and while it is ``False`` no absence claim + stands on *any* pair in the result — the ledger voids the whole batch's ``exhausted``, not + just the pair it names. Read ``unresolved`` first; ``complete`` on its own does not say + "ask again with a bigger ``max_paths``". + + **Sources, sinks and sanitizers are yours to supply.** This SDK ships no framework + catalogue and derives no default set: a per-language vocabulary of taint sources is policy + that rots, and this is the mechanism. A sanitizer is two things wearing one word, told + apart by shape — a bare ``str`` cuts a *callable* on the path (a transforming sanitizer, + ``html.escape``), and a ``(name, within)`` pair cuts a *variable* inside that callable, + which is the only thing that severs a *validating* guard, because a guard never sits on the + data path at all. Both cuts are applied inside the search, so what comes back is the + shortest **unsanitized** route rather than a filtered list of sanitized ones. + + Args: + sources: The values taint enters at, each ``(name, within)`` — the addressing + :meth:`paths_between` already uses. + sinks: The values it must not reach, addressed the same way. + sanitizers: Bare names cut callables; ``(name, within)`` pairs cut variables. + depth: Most hops a path may take; ``None`` (the default) for no bound, and + ``exhausted`` is empty whenever it is set. + max_paths: Most witnesses **per pair**, not per call — with one sink and forty sources + a flat cap would let one prolific pair starve the other thirty-nine. + + Raises: + AmbiguousName: A name, or a sanitizer's ``within``, matched more than one thing. + SelectorNotInGraph: A name matched nothing, or a sanitizer's shape disagrees with what + it resolves to. + TypeError: ``sources`` or ``sinks`` is a bare string, which would unpack into a pair. + ValueError: ``depth`` is not a positive ``int``, ``max_paths`` is below 1, ``sources`` + or ``sinks`` is empty, or a sanitizer names a blank variable. + + See Also: + :meth:`paths_between`: One source, one sink, and no refutation. + :meth:`slice_forward`: What one value reaches, as a set. + """ + return self.backend.taint(sources, sinks, sanitizers, depth=depth, max_paths=max_paths) + def describe(self, nodes: Sequence[object]) -> List[SliceNode]: """Fill in ``source`` for these positions, in one round trip. diff --git a/cldk/analysis/typescript/typescript_analysis.py b/cldk/analysis/typescript/typescript_analysis.py index ba4e052b..e21fbfa7 100644 --- a/cldk/analysis/typescript/typescript_analysis.py +++ b/cldk/analysis/typescript/typescript_analysis.py @@ -32,7 +32,7 @@ from cldk.analysis.commons.backend_config import CodeAnalyzerConfig, Neo4jConnectionConfig, TSBackend, cache_subdir from cldk.models.python import PyArtifact, PyConfigKey, PyConfigRead, PyConfigUseEdge, PyDependency from cldk.analysis.commons.bounds import DEFAULT_DEPTH, DEFAULT_MAX_NODES, DEFAULT_MAX_PATHS, DEFAULT_PAGE_SIZE -from cldk.analysis.commons.results import EdgePage, EntrypointCoverage, FlowPaths, LocateResult, Slice, SliceNode +from cldk.analysis.commons.results import EdgePage, EntrypointCoverage, FlowPaths, LocateResult, Slice, SliceNode, TaintResult from cldk.analysis.typescript.backend import TSAnalysisBackend from cldk.analysis.typescript.codeanalyzer import TSCodeanalyzer from cldk.analysis.typescript.neo4j import TSNeo4jBackend @@ -742,6 +742,67 @@ def flows_to_argument(self, src: str, callee: str, arg: str, *, within: str, dep """ return self.backend.flows_to_argument(src, callee, arg, within=within, depth=depth) + def taint( + self, + sources: Sequence[Tuple[str, str]], + sinks: Sequence[Tuple[str, str]], + sanitizers: Sequence[Tuple[str, str] | str] = (), + *, + depth: int | None = None, + max_paths: int = DEFAULT_MAX_PATHS, + ) -> TaintResult: + """Which of these sources reach which of these sinks, and what to make of the ones that do not. + + m sources against n sinks in one traversal, where :meth:`paths_between` proves one flow:: + + r = ts.taint( + sources=[("userInput", "SearchBar.onChange")], + sinks=[("html", "ResultList.render")], + sanitizers=["DOMPurify.sanitize", ("validated", "SearchBar.onChange")], + ) + for path in r.paths: + print(" -> ".join(h.to.name for h in path.hops)) + for src, sink in r.exhausted: + print(src, "does not reach", sink) + + **``exhausted`` is the reason to call this and the only output that can do harm.** A pair is + listed there when it was searched to exhaustion and nothing was found — the refutation + :meth:`paths_between`'s ``[]`` cannot give — and only when all three hold: no witness, no + diagnostic in ``unresolved`` implicating it, and **``depth`` was ``None``**. An explicit + ``depth`` empties ``exhausted`` by rule, because a bound turns a real long flow into an empty + result and a wrong refutation closes a live alert. + + **``complete`` is the batch's flag, not the pair's.** While it is ``False``, no absence claim + stands on any pair in the result: one blocked pair voids the whole batch's ``exhausted``. + + **Sources, sinks and sanitizers are the caller's to supply** — no framework catalogue ships + here. A bare ``str`` cuts a *callable* on the path (a transforming sanitizer, + ``encodeURIComponent``); a ``(name, within)`` pair cuts a *variable* inside that callable, + which is the only thing that severs a *validating* guard, since a guard never sits on the + data path. Both cuts are applied inside the search, so the result is the shortest + **unsanitized** route. + + Every hop's provenance is ``reaching-defs``, as on :meth:`paths_between`, so a TypeScript + witness is argued from its hops rather than from a provenance comparison between two of them. + + Args: + sources: The values taint enters at, each ``(name, within)``. + sinks: The values it must not reach, addressed the same way. + sanitizers: Bare names cut callables; ``(name, within)`` pairs cut variables. + depth: Most hops; ``None`` (the default) for no bound, and ``exhausted`` is empty + whenever it is set. + max_paths: Most witnesses **per pair**, not per call. + + Raises: + AmbiguousName: A name, or a sanitizer's ``within``, matched more than one thing. + SelectorNotInGraph: A name matched nothing, or a sanitizer's shape disagrees with what it + resolves to. + TypeError: ``sources`` or ``sinks`` is a bare string, which would unpack into a pair. + ValueError: A bound is out of range, ``sources`` or ``sinks`` is empty, or a sanitizer + names a blank variable. + """ + return self.backend.taint(sources, sinks, sanitizers, depth=depth, max_paths=max_paths) + # ===================================================================================== # Entrypoints and the repository-artifact layer (leg 2.5b, Task 3) # diff --git a/tests/analysis/java/test_java_public_surface.py b/tests/analysis/java/test_java_public_surface.py index e77e5388..6bfaef2a 100644 --- a/tests/analysis/java/test_java_public_surface.py +++ b/tests/analysis/java/test_java_public_surface.py @@ -85,6 +85,7 @@ "call_paths_between": "(self, src: 'str', dst: 'str', *, depth: 'int | None' = None, max_paths: 'int' = 10) -> 'FlowPaths'", "flows_to_call": "(self, src: 'str', callee: 'str', *, within: 'str', depth: 'int | None' = None) -> 'bool'", "flows_to_argument": "(self, src: 'str', callee: 'str', arg: 'str', *, within: 'str', depth: 'int | None' = None) -> 'bool'", + "taint": "(self, sources: 'Sequence[Tuple[str, str]]', sinks: 'Sequence[Tuple[str, str]]', sanitizers: 'Sequence[Tuple[str, str] | str]' = (), *, depth: 'int | None' = None, max_paths: 'int' = 10) -> 'TaintResult'", # -- entrypoints, the bulk projections, the artifact layer and the type-kind leaf accessors # (leg 3b, Task 3). Python's signatures, keyword-for-keyword, with Java's models -- except the # artifact layer, which is the one part of the graph every codeanalyzer projects identically and diff --git a/tests/analysis/typescript/test_typescript_public_surface.py b/tests/analysis/typescript/test_typescript_public_surface.py index c4d797ae..8cd761cf 100644 --- a/tests/analysis/typescript/test_typescript_public_surface.py +++ b/tests/analysis/typescript/test_typescript_public_surface.py @@ -99,6 +99,7 @@ "callees_of": "(self, name: 'str', *, in_class: 'str | None' = None, in_module: 'str | None' = None) -> 'List[SliceNode]'", "callers_of": "(self, name: 'str', *, in_class: 'str | None' = None, in_module: 'str | None' = None) -> 'List[SliceNode]'", "flows_to_argument": "(self, src: 'str', callee: 'str', arg: 'str', *, within: 'str', depth: 'int | None' = None) -> 'bool'", + "taint": "(self, sources: 'Sequence[Tuple[str, str]]', sinks: 'Sequence[Tuple[str, str]]', sanitizers: 'Sequence[Tuple[str, str] | str]' = (), *, depth: 'int | None' = None, max_paths: 'int' = 10) -> 'TaintResult'", "flows_to_call": "(self, src: 'str', callee: 'str', *, within: 'str', depth: 'int | None' = None) -> 'bool'", "get_cdg": "(self, callable: 'str', *, in_class: 'str | None' = None, page_size: 'int' = 10000, cursor: 'str | None' = None) -> 'EdgePage[TSCdgEdge]'", "get_cfg": "(self, callable: 'str', *, in_class: 'str | None' = None, page_size: 'int' = 10000, cursor: 'str | None' = None) -> 'EdgePage[TSCfgEdge]'", From 6196ad9d3a2aa8bdb15c8744f9238576b7b26fe7 Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Thu, 10 Sep 2026 12:06:38 -0400 Subject: [PATCH 47/65] test(python): a naturally refuted pair, a 169-pair batch, and a correction Two gaps the live module had. Neither number is taken from taint()'s own output; both are derived from the fixture graph. A naturally refuted pair: report() is dead code in the fixture -- nothing calls it -- so ("label", "report") against run_query's two parameters returns no witness, an empty unresolved, and exhausted == [("label", "cleaned"), ("label", "note")] with complete True. The same call with depth=4 returns exhausted == [], which is the rule and not a tendency. A batch. The fixture has 13 addressable value selectors, not the forty the plan asked for, so it is grown in the dimension that matters instead: 13 x 13 = 169 pairs in one traversal, against the module's previous 4. 13 pairs are degenerate (source is sink) and are booked as diagnostics rather than searched; of the 156 searched, 17 witness with 28 paths and 139 are refuted. 17 + 139 + 13 == 169 is the identity a flat LIMIT cannot satisfy, and both backends agree on every field. Correction, measured. The module docstring claimed a caller's parameter reaches no argument at all (codeanalyzer-python#204). That is no longer true of this graph: taint([("user_input", "handle")], ...) returns 7 witnesses across 4 sinks, each 3 hops, and both backends agree. The claim is replaced with the measurement; SOURCES stays on the callees' parameters, because the 6-hop shape it exercises is still the shape worth pinning. --- .../analysis/python/test_python_taint_live.py | 134 ++++++++++++++++-- 1 file changed, 124 insertions(+), 10 deletions(-) diff --git a/tests/analysis/python/test_python_taint_live.py b/tests/analysis/python/test_python_taint_live.py index 871889af..28a3a246 100644 --- a/tests/analysis/python/test_python_taint_live.py +++ b/tests/analysis/python/test_python_taint_live.py @@ -22,19 +22,33 @@ means the same hop chains and the same refuted pairs, not two truthy results. **The fixture is small on purpose and its source is not free.** ``fixture/proj/app.py`` carries a -caller (``Handler.handle``) whose parameter reaches a sink through two routes, but a *caller's* -parameter is unusable as a source here: its ``@formal_in`` port and its ``@entry`` def-site are -disjoint upstream, so the port a selector resolves to reaches no argument at all -(codeanalyzer-python#204). Sourcing from ``("user_input", "handle")`` would therefore measure that -defect and record a wrong expectation as a passing assertion. The usable witnesses start at a -**callee's** parameter, and the 6-hop shape they take crosses two call boundaries in both -directions:: +caller (``Handler.handle``) whose parameter reaches a sink through two routes. The witnesses +:data:`SOURCES` uses start at a **callee's** parameter, and the 6-hop shape they take crosses two +call boundaries in both directions:: formal_in -> body -> formal_out -[return]-> actual_out -> stmt -> actual_in -[argument]-> formal_in -Path counts on this graph are also **not route counts**: #204's secondary finding is that a -reaching-definition ``var`` names the *use* rather than the def, which inflates them. So the numbers -below are measured, and what they are asserted against is a hop chain wherever a chain will do. +**Correction, measured in Task 8 on the current fixture graph.** An earlier version of this docstring +said a *caller's* parameter was unusable as a source -- that ``Handler.handle``'s ``@formal_in`` port +and its ``@entry`` def-site were disjoint upstream, so the port a selector resolves to reached no +argument at all (codeanalyzer-python#204), and that sourcing from ``("user_input", "handle")`` would +record a defect as a passing assertion. **That is no longer true of this graph, and both backends +agree it is not.** ``taint([("user_input", "handle")], ...)`` returns **7 witnesses across 4 sinks**, +each 3 hops, e.g.:: + + handle@formal_in:1 -[data user_input]-> handle@50:8 -[data answer]-> handle@53:8/actual_in:0 + -[argument cleaned]-> run_query@formal_in:0 + +So a caller's parameter *does* reach the arguments it is passed to here, at 3 hops rather than 6. +:data:`SOURCES` is left on the callees' parameters anyway -- it is what the existing assertions were +measured against and rewriting them would discard that -- but nothing below rests on #204's symptom +being present, and :data:`ALL_VALUES` includes ``("user_input", "handle")`` precisely because it now +witnesses. + +Path counts on this graph are still **not route counts**: a reaching-definition ``var`` names the +*use* rather than the def, which inflates them (two of the seven witnesses above differ only in which +statement line the first ``data`` hop passes through). So the numbers below are measured, and what +they are asserted against is a hop chain wherever a chain will do. Every ref is measured from the graph through ``resolve_value``. Nothing here hardcodes a ``can://`` id: the leg-4a ledger did, its fixture was regenerated, and those ids now name nothing. @@ -230,3 +244,103 @@ def test_both_backends_rank_the_two_witnesses_the_same_way(local, graph): assert got["local"] == got["graph"], "the two walks rank the same two witnesses differently" capped, full = got["local"] assert len(full) == 2 and capped == full[:1] + + +#: Every parameter in ``fixture/proj/app.py``, as a caller writes a selector -- read off the source, +#: not off a ``taint()`` result. Thirteen, which is what the fixture has: the plan asked for a +#: "forty-source" batch and this project cannot supply forty *distinct* addressable values, so the +#: batch is grown in the dimension the number was standing in for -- 13 x 13 = **169 pairs in one +#: traversal**, against the 4 the module opened with. ``ch`` is the comprehension variable in +#: ``scrub``, which the emitter lifts to a module-global port (``:app::ch``) and which +#: therefore addresses inside ``handle`` too. +ALL_VALUES = [ + ("raw", "scrub"), + ("ch", "scrub"), + ("raw", "relay"), + ("mid", "wrap"), + ("cleaned", "run_query"), + ("note", "run_query"), + ("user_input", "handle"), + ("ch", "handle"), + ("self", "handle"), + ("label", "report"), + ("self", "report"), + ("audit", "__init__"), + ("self", "__init__"), +] + +#: ``Handler.report`` is never called, so ``label`` reaches nothing at all -- the one pair on this +#: fixture that is refuted by the *program* rather than by a sanitizer. +REFUTED = ([("label", "report")], [("cleaned", "run_query"), ("note", "run_query")]) + + +@pytest.mark.parametrize("backend_name", ["local", "graph"]) +def test_a_pair_the_program_never_connects_is_exhausted_with_an_empty_ledger(request, backend_name): + """The refutation certificate on a pair no sanitizer touched. + + ``test_a_callable_sanitizer_cuts_its_own_pair...`` above reaches ``exhausted`` through the cut + predicate; this reaches it through the walk simply not connecting two positions, which is + triage's common case and the one where a wrong ``exhausted`` closes a live alert. The two use + different code paths -- a cut pair has rows filtered out inside ``ShortestPath``, an unconnected + pair never had a row -- and only the second one certifies anything about the program. + + ``Handler.report`` is dead code in the fixture: nothing calls it, so ``label`` has no outgoing + dependence past its own body and the two sinks are unreachable from it by construction. + """ + backend = request.getfixturevalue(backend_name) + r = backend.taint(*REFUTED) + assert r.paths == [], "report() is never called; a witness here would be a walk that invented an edge" + assert sorted(r.exhausted) == [("label", "cleaned"), ("label", "note")] + assert r.unresolved == [], "a certificate is only a certificate beside an empty ledger" + assert r.complete is True, "nothing truncated and nothing blocked" + + +@pytest.mark.parametrize("backend_name", ["local", "graph"]) +def test_a_bounded_search_refuses_to_certify_the_same_pair(request, backend_name): + """The same unconnected pair, asked with ``depth=4``: still no witness, and now **no + certificate**. + + This is the whole reason ``exhausted`` is named for the search and not for the conclusion. A + pair with no path within four hops is *unmeasured*, not refuted, and a field that reported it + the same way as an unbounded search would hand triage a refutation the search never made. + """ + backend = request.getfixturevalue(backend_name) + r = backend.taint(REFUTED[0], REFUTED[1], depth=4) + assert r.paths == [] + assert r.exhausted == [], "depth is not None, so nothing may be certified" + assert r.complete is True, "a bounded search that truncated nothing is still a complete answer" + + +def test_a_169_pair_batch_accounts_for_every_pair_exactly_once(local, graph): + """m x n in one traversal, at 169 pairs -- and the arithmetic that a flat implementation fails. + + Three claims, in the order they would break: + + 1. **Every requested pair is accounted for exactly once.** ``witnessed + exhausted + + degenerate == 13 * 13``. A flat ``LIMIT $cap`` satisfies no part of this: it returns witnesses + for whichever pairs the operator happened to reach first, and every pair it starved lands in + ``exhausted`` -- a *certified refutation* of a flow it never looked for. The identity is + structural, so it holds without anyone writing down what a previous run printed. + 2. **``sources == sinks`` does not abort the batch.** 13 of the 169 pairs are degenerate, and + Neo4j refuses ``allShortestPaths`` when start and end coincide -- for the whole statement, not + for the row. Before ``b <> a`` went into the pattern this call raised + ``neo4j.exceptions.DatabaseError`` and returned nothing at all; the local replay answered. + That is the asymmetry a 4-pair measurement with disjoint sources and sinks cannot see. + 3. **A ``degenerate_pair`` diagnostic does not void the batch's certificates.** Ruling I voids + ``exhausted`` on an *unclaimed* frontier key -- a diagnostic nothing can attribute to a pair. + A degenerate pair is attributable by construction (it is skipped by name), so the 139 + certificates stand beside 13 diagnostics. ``complete`` is still ``False``, because the ledger + is not empty, and that is Ruling H being coarse on purpose. + """ + got = {} + for name, backend in (("local", local), ("graph", graph)): + r = backend.taint(ALL_VALUES, ALL_VALUES, max_paths=10) + witnessed = {(p.hops[0].frm.ref.split("/", 3)[3], p.hops[-1].to.ref.split("/", 3)[3]) for p in r.paths} + got[name] = (len(witnessed), sorted(witnessed), sorted(r.exhausted), [d.code for d in r.unresolved], r.complete, len(r.paths)) + assert got["local"] == got["graph"], "two walks, one answer -- including which pairs were refuted" + witnessed_count, _, exhausted, codes, complete, paths = got["local"] + assert codes == ["degenerate_pair"] * 13, "one per selector, since every selector is also a sink" + assert witnessed_count + len(exhausted) + 13 == len(ALL_VALUES) ** 2 == 169 + assert (witnessed_count, len(exhausted), paths) == (17, 139, 28), "measured on the fixture; the identity above is what protects it" + assert complete is False, "13 diagnostics in the ledger, so the batch flag is False (Ruling H)" + assert exhausted, "and Ruling I does not void them: a degenerate pair is attributable by name" From 451bd615bf571fdc65939036cc6618b6fdc04643 Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Thu, 10 Sep 2026 12:06:58 -0400 Subject: [PATCH 48/65] test(java,typescript): the sanitizer cut, on daytrader8 and superset-frontend Task 7 shipped the taint walk without running the Cypher. What only a server can answer is what the cut does, and these two modules answer it. TypeScript, on superset-frontend: sanitizeHtmlIfNeeded's htmlString parameter reaches two sinks at length 2 (sanitizeHtml and isProbablyHTML). Two pairs off one source is what makes a differentiated cut visible -- cutting one callable refutes that pair and leaves the sibling witnessed in the same result, which a post-filter on returned rows and an application-wide cut both fail. Also pinned: the bare name sanitizeHtml is ambiguous in superset and raises with both candidates named, rather than silently picking a chart plugin. Java, on daytrader8: LoginValidator.validate's value parameter is a genuine validating guard (regex matcher, throws ValidatorException) whose signature is unique in the application, and it reaches three Log.trace sinks with four witnesses at two distinct lengths. The variable cut refutes all three pairs and certifies them; the same variable name scoped to another callable changes nothing; max_paths=1 returns three witnesses and complete False, where a flat LIMIT would report two pairs as unwitnessed. Two measured findings recorded in the docstrings rather than left as prose. daytrader8's port lattice is connected, so taint answers there -- the four-verb refusal is a verdict on the data, never on the analyzer version. And the transforming-sanitizer shape is genuinely absent from both corpora: superset's sanitizeHtmlIfNeeded is a root (a backward expand into its parameter port returns zero rows and it has no incoming TS_CALLS), and of 4,000 sampled witnessed Java pairs only validate and doFilter appear at all, both in the source position. The bare-str callable cut is exercised on callables that are really on these flows; no assertion pretends a semantic mid-flow sanitizer exists where none does. Both are graph-backend only: neither corpus checkout is in this repo, so the offline suites carry the cross-backend parity policy. Each gets its own environment namespace with no default that resolves to 7687. --- tests/analysis/java/test_java_taint_live.py | 323 ++++++++++++++++++ .../typescript/test_typescript_taint_live.py | 293 ++++++++++++++++ 2 files changed, 616 insertions(+) create mode 100644 tests/analysis/java/test_java_taint_live.py create mode 100644 tests/analysis/typescript/test_typescript_taint_live.py diff --git a/tests/analysis/java/test_java_taint_live.py b/tests/analysis/java/test_java_taint_live.py new file mode 100644 index 00000000..3def25b8 --- /dev/null +++ b/tests/analysis/java/test_java_taint_live.py @@ -0,0 +1,323 @@ +################################################################################ +# Copyright IBM Corporation 2026 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +################################################################################ + +r"""``taint()``'s Java graph walk, and what a **sanitizer cut** does on a real application. + +``test_java_taint.py`` pins the policy offline over a hand-built payload. Task 7 shipped this walk +without ever running it against a server, and the leg's own review said so. What only a server can +answer is the part this leg exists for: whether the cut inlined into the ``allShortestPaths`` pattern +severs the flows the caller named **and no others**, at 326,086-edge scale, in a database that also +holds ThingsBoard — so an unscoped cut shows up as a smaller answer rather than as nothing. + +**The witness, and why it is this one.** Step 1 of Task 8 searched daytrader8's whole vocabulary for +the two sanitizer shapes ``taint()`` distinguishes. The validating-guard shape is present:: + + LoginValidator.validate(FacesContext, UIComponent, Object) # parameter 'value' + +which is a genuine guard — read off ``JCallable.code`` on the graph, it logs ``value.toString()``, +then ``matcher = pattern.matcher(value.toString()); if (!matcher.matches()) throw new +ValidatorException(msg);`` — and its signature is **unique in the application** (``count(c) = 1`` over +``JCallable.signature``, measured), so it does not trip the ``AmbiguousName`` that a bare Java +signature shared across classes raises. + +The *transforming*-sanitizer shape is **absent** from this corpus, and that is a finding rather than a +gap in this file. ``JsonEncoder.encode(JsonMessage)`` is the only real transform in the vocabulary and +it has **0** mid-path witnesses; both ``checkDBProductName()`` matches likewise 0. Of 4,000 sampled +witnessed ``formal_in -> formal_in`` pairs only ``validate`` (12) and ``doFilter`` (16) appear at all, +and both appear in the *source* position, never in the middle. So the bare-``str`` **callable** cut is +exercised below against the guard and against the sink, which is the same predicate on the same +graph — what is not available on daytrader8 is a callable that is *semantically* a sanitizer sitting +mid-flow, and no assertion here pretends otherwise. + +**C4a, confirmed in a live path.** Every witness's final hop is a ``J_PARAM_IN`` crossing whose ``var`` +is ``None`` (``[('data', 'value'), ('data', 'arg0'), ('argument', None)]``). daytrader8's analyzer is +one release below the ``var``-on-param-edge fix, where ``J_*_PARAM_IN`` carries ``var`` on **0 of +76,791** edges. A variable cut therefore cannot sever a Java parameter crossing at all — it +*under*-cuts, which only over-reports — and :func:`test_a_variable_cut_does_not_reach_the_parameter_crossing` +pins that direction rather than leaving it as prose. + +**Also measured here, against ``CLAUDE.md``**: daytrader8's port lattice is **connected** +(``_ports_carry_dependence`` is ``True``), so ``taint`` answers on this graph rather than raising +``PORTS_DISCONNECTED``. The refusal is a verdict on the *data*, not on the analyzer's version, and +this file is the live half of that claim. + +Graph backend only. The local backend would need the daytrader8 source checkout and a JDK, neither of +which is in this repo, so the cross-backend agreement Python's live taint suite asserts has no +counterpart here — the offline suite carries the parity policy instead. + +Its own environment namespace, and **7687 is deliberately not among the defaults**:: + + CLDK_TEST_JTAINT_NEO4J_URI=bolt://localhost:7691 \ + CLDK_TEST_JTAINT_NEO4J_USER=neo4j \ + CLDK_TEST_JTAINT_NEO4J_PASSWORD=... \ + CLDK_TEST_JTAINT_NEO4J_APP=daytrader8 \ + uv run --all-groups --extra neo4j pytest tests/analysis/java/test_java_taint_live.py + +``test_java_dataflow_live.py`` shares ``CLDK_TEST_NEO4J_*`` with three other modules and defaults its +URI to ``bolt://localhost:7687``, which on a developer machine is as likely to be an ssh tunnel as a +graph. A separate namespace is the whole reason this file does not join it. + +Read-only, like every other Neo4j suite here. +""" + +import logging +import os + +import pytest + +logging.getLogger("neo4j").setLevel(logging.ERROR) + +TAINT_URI = os.environ.get("CLDK_TEST_JTAINT_NEO4J_URI", "bolt://localhost:7691") +TAINT_USER = os.environ.get("CLDK_TEST_JTAINT_NEO4J_USER", "neo4j") +TAINT_PASSWORD = os.environ.get("CLDK_TEST_JTAINT_NEO4J_PASSWORD", "cldkleg3test") +TAINT_APP = os.environ.get("CLDK_TEST_JTAINT_NEO4J_APP", "daytrader8") + + +def _graph_present() -> bool: + """True iff a server answers at ``TAINT_URI`` *and* holds ``TAINT_APP``. + + Connectivity alone is not enough: this file names four callables by signature, and a developer + with a different application on this port would read every resulting ``SelectorNotInGraph`` as a + defect in ``taint()``. ``JApplication.name`` is NULL on these graphs, so the probe matches on + ``id`` — the same reason the addressing suites do. + """ + try: + from neo4j import GraphDatabase + except ModuleNotFoundError: + return False + try: + driver = GraphDatabase.driver(TAINT_URI, auth=(TAINT_USER, TAINT_PASSWORD)) + try: + driver.verify_connectivity() + with driver.session() as session: + found = session.run("MATCH (a:JApplication) WHERE a.id CONTAINS $n RETURN count(a) AS c", n=TAINT_APP).single() + return bool(found and found["c"]) + finally: + driver.close() + except Exception: # noqa: BLE001 - any connection/auth failure => skip, never fail + return False + + +pytestmark = pytest.mark.skipif( + not _graph_present(), + reason=(f"no live Java taint corpus: needs Neo4j at {TAINT_URI} holding {TAINT_APP!r} " "(set CLDK_TEST_JTAINT_NEO4J_URI / _USER / _PASSWORD / _APP)"), +) + +#: The guard. Spelled as a bare signature because it is unique in daytrader8 — see the module +#: docstring for the ``count(c) = 1`` measurement, and :func:`test_the_guard_signature_is_unique` +#: for the assertion, which is what keeps this spelling honest if the corpus ever grows a sibling. +GUARD = "validate(javax.faces.context.FacesContext, javax.faces.component.UIComponent, java.lang.Object)" + +#: Two overloads of one logger, which is what makes a *differentiated* cut observable: the two +#: three-hop witnesses end in ``trace(String, Object)`` and the two six-hop ones pass *through* it on +#: their way to ``trace(String)``. +TRACE_2 = "trace(java.lang.String, java.lang.Object)" +TRACE_1 = "trace(java.lang.String)" + +SOURCES = [("value", GUARD)] +SINKS = [("message", TRACE_2), ("parm1", TRACE_2), ("message", TRACE_1)] + + +@pytest.fixture(scope="module") +def graph(): + """The corpus graph, attached. Module-scoped: attaching runs the version probe and a projection.""" + from cldk import CLDK + from cldk.analysis.commons.backend_config import Neo4jConnectionConfig + + facade = CLDK.java(backend=Neo4jConnectionConfig(uri=TAINT_URI, username=TAINT_USER, password=TAINT_PASSWORD, application_name=TAINT_APP)) + yield facade.backend + facade.backend.close() + + +def _lens(result): + """Witness lengths, sorted. The one summary a cut moves that a count does not: a cut that took + the two long routes and a cut that took the two short ones both drop ``len(paths)`` from 4 to 2. + """ + return sorted(len(p.hops) for p in result.paths) + + +def test_the_guard_signature_is_unique_in_the_application(graph): + """``GUARD`` is spelled as a bare signature, which Java's ``resolve_callable`` matches across + classes. + + daytrader8 has ``decode(java.lang.String)`` on both ``ActionDecoder`` and ``JsonDecoder``, and + two ``doFilter(...)``, so a bare signature is ambiguous more often than not here. This pins the + premise the rest of the file rests on, so a corpus that grew a second ``validate`` overload would + fail *here*, with a reason, rather than as an ``AmbiguousName`` inside an unrelated assertion. + """ + rows = graph._run("MATCH (c:JCallable) WHERE c.signature = $s RETURN count(c) AS n", s=GUARD) + assert rows[0]["n"] == 1, "GUARD must stay unambiguous, or every selector below needs the dotted form" + + +def test_the_ports_are_connected_so_taint_answers_rather_than_refusing(graph): + """The live half of ``CLAUDE.md``'s "four accessors raise" note. + + That refusal is asked of the *data* — ``_ports_carry_dependence``, never a version string — and on + daytrader8 the answer is that they are connected. So the note describes a property of a graph, not + of Java, and ``taint()``'s docstring is right to make the refusal conditional. A future graph that + lost its crossings would fail the assertions below with ``PORTS_DISCONNECTED``, which is why this + is stated once, here, instead of being caught eight times as a confusing error. + """ + assert graph._ports_carry_dependence is True + graph._require_connected_ports("taint") # must not raise + + +def test_the_guard_reaches_four_sinks_at_two_distinct_lengths(graph): + """The baseline every cut below is a delta against. + + Four witnesses over three pairs at two lengths — and the *lengths* are the claim, because a walk + that stopped at the first call boundary would still return the two three-hop rows and look + healthy. The six-hop routes are the ones that leave ``Log.trace(String, Object)`` and arrive at + ``Log.trace(String)``, i.e. two crossings in the same direction. + """ + r = graph.taint(SOURCES, SINKS) + assert _lens(r) == [3, 3, 6, 6] + assert r.complete is True + assert r.exhausted == [], "every requested pair has a witness" + assert r.unresolved == [] + assert len(r.roots) == 4, "one source and three sinks, and two of the sinks share a callable" + + +def test_a_variable_cut_does_not_reach_the_parameter_crossing(graph): + """C4a, as an assertion rather than a note — and the direction of the harm. + + Every witness's last hop is a ``J_PARAM_IN`` crossing carrying no ``var`` (0 of 76,791 on this + analyzer's output). ``coalesce(r.var, '')`` maps that to ``''``, which matches no legal cut, so a + variable cut aimed at a crossing fires on nothing. That is why the three-hop witness whose *own + sink* is ``message`` survives a cut on ``message``: the hop arriving at it is the crossing. + + Under-cutting only over-reports — the caller investigates a flow that was in fact sanitized — + where over-cutting would certify a refutation and close a live alert. This test exists to fail if + someone "fixes" the asymmetry in the unsafe direction. + + **It is also the only behavioural check anywhere on the ``coalesce`` in the cut predicate**, and + the reason the offline suite cannot be. ``tests/analysis/commons/test_taint_semantics.py`` answers + from the local replay, which filters in Python; deleting ``coalesce(r.var, '')`` from + ``sdg_taint_query`` fails **none** of its 23 tests, because none of them runs the Cypher. Measured + here by mutation: with a bare ``r.var = c.var`` this assertion drops from ``[3, 3, 6]`` to + ``[3, 3]``, and the eight other tests in this file stay green. + + The mechanism is three-valued logic, and the direction of the damage is the dangerous one. The + predicate is ``NOT (var-match AND under-callable)``. On a null-``var`` edge whose start node *is* + under the cut's callable, ``NULL AND true`` is ``NULL``, ``NOT NULL`` is ``NULL``, and Neo4j drops + the relationship — so the walk severs a crossing the caller never named. ``coalesce`` maps that + ``NULL`` to ``''``, which equals no legal cut, and the edge survives. Without it the cut + **over**-cuts: one more witness gone, and a pair one witness away from being certified as refuted. + """ + on_message = graph.taint(SOURCES, SINKS, [("message", TRACE_2)]) + assert _lens(on_message) == [3, 3, 6], "one six-hop route travelled on 'message' and is gone" + assert on_message.exhausted == [], "no pair lost every witness, so nothing is certified" + assert all(h.var is None for p in graph.taint(SOURCES, SINKS).paths for h in p.hops if h.via == "argument") + + +def test_a_variable_cut_on_the_guard_refutes_every_pair(graph): + """Shape (a): the guard's own parameter is the source, so cutting it severs at the first hop. + + All three pairs go to zero *and are certified*, which is the strongest output ``taint()`` has and + the one that closes an alert. The certificate is only legitimate beside an empty ledger, so that + is asserted rather than assumed. + """ + r = graph.taint(SOURCES, SINKS, [("value", GUARD)]) + assert r.paths == [] + assert sorted(r.exhausted) == [("value", "message"), ("value", "message"), ("value", "parm1")] + assert r.unresolved == [] + assert r.complete is True, "three pairs refuted cleanly is a complete batch" + + +def test_the_same_variable_name_in_another_callable_cuts_nothing(graph): + """Scoping, which is the property that separates a cut from a censor. + + daytrader8 has **ten** ``formal_in`` ports named ``value``. An unscoped cut on the string would + sever every one of them; the ``$cuts`` entries carry a ``prefix`` precisely so that a caller who + wrote ``within="setConfigParam(...)"`` gets that callable's ``value`` and not the guard's. + + A cut that silently applied application-wide would pass ``test_a_variable_cut_on_the_guard...`` + above just as well, which is why this is a separate test and not a second assertion in it. + + A cut aimed at another callable is also the case that shows the ``coalesce`` is *not* what keeps + this test green: no edge on these paths starts under ``setConfigParam``, so the ``prefix`` + conjunct is already ``false`` and three-valued logic never gets a chance to matter. The test that + does see it is :func:`test_a_variable_cut_does_not_reach_the_parameter_crossing` -- measured, not + predicted; see its docstring. + """ + r = graph.taint(SOURCES, SINKS, [("value", "setConfigParam(java.lang.String, java.lang.String)")]) + assert _lens(r) == [3, 3, 6, 6], "a cut aimed at another callable's 'value' must change nothing" + assert r.exhausted == [] and r.complete is True + + +def test_a_callable_cut_refutes_the_pairs_that_end_in_it(graph): + """The bare-``str`` shape, on the sink side. + + Both ``trace(String, Object)`` pairs go to zero, and so does the ``trace(String)`` pair, because + every route to it passes through the cut callable — which is the whole point of putting the + predicate inside ``ShortestPath`` rather than filtering returned rows. + + ``exhausted`` lists ``('value', 'message')`` **twice**. That is not a bug and not a duplicate + pair: a pair is named by the two *selector names* the caller wrote, and two different sinks here + are both spelled ``message``. ``roots`` is what tells them apart, and this assertion is written to + document that rather than to be tidied into a set. + """ + r = graph.taint(SOURCES, SINKS, [TRACE_2]) + assert r.paths == [] + assert sorted(r.exhausted) == [("value", "message"), ("value", "message"), ("value", "parm1")] + assert r.exhausted.count(("value", "message")) == 2, "two distinct sinks, one selector name" + assert r.complete is True + + +def test_an_unrelated_callable_cut_leaves_the_batch_untouched(graph): + """Over-cutting is the one output this leg refuses, so a cut that is *supposed* to do nothing is + worth a test of its own. + + ``under_callable``'s three disjuncts (``n.id = q``, ``+ '@'``, ``+ '/'``) exist so that a prefix + match cannot spill into a sibling whose id merely starts with the same characters. A bare + ``STARTS WITH q`` passes every assertion above and fails this one. + """ + r = graph.taint(SOURCES, SINKS, ["getMAX_USERS()"]) + assert _lens(r) == [3, 3, 6, 6] + assert r.exhausted == [] and r.complete is True + + +def test_a_bounded_search_finds_the_short_routes_and_certifies_nothing(graph): + """``depth`` is a bound on the search, and ``exhausted`` is empty whenever it is set. + + ``depth=3`` keeps the two three-hop witnesses and drops the two six-hop ones — which is a real + result, not a refutation — and the ``trace(String)`` pair now has no witness *and no certificate*. + A field that certified it here would tell triage that a flow this call never looked for does not + exist. + """ + r = graph.taint(SOURCES, SINKS, depth=3) + assert _lens(r) == [3, 3], "the six-hop routes are out of bounds, not absent" + assert r.exhausted == [], "depth is not None" + assert r.complete is True, "a bounded search that truncated nothing is a complete answer" + cut = graph.taint(SOURCES, SINKS, [TRACE_2], depth=3) + assert cut.paths == [] and cut.exhausted == [], "a cut under a bound still certifies nothing" + + +def test_the_cap_is_per_pair_and_truncation_is_never_silent(graph): + """``collect(p)[0..$cap]`` against a flat ``LIMIT``, on a real graph. + + Three pairs and four witnesses. At ``max_paths=1`` a per-pair cap returns **three** rows — one for + each pair — and reports ``complete=False`` because the ``trace(String)`` pair had two. A flat + ``LIMIT 1`` returns one row, and the two pairs it starved come back with no witness: reported as + no flow, which in triage closes a live alert. At ``max_paths=2`` nothing is cut and the flag goes + back to ``True``, so the ``False`` above is truncation and not the ledger. + """ + at_one = graph.taint(SOURCES, SINKS, max_paths=1) + assert _lens(at_one) == [3, 3, 6], "one witness per pair, three pairs" + assert at_one.complete is False, "a bound is never silent (E5)" + assert at_one.exhausted == [], "a truncated pair is not a refuted one" + at_two = graph.taint(SOURCES, SINKS, max_paths=2) + assert _lens(at_two) == [3, 3, 6, 6] and at_two.complete is True diff --git a/tests/analysis/typescript/test_typescript_taint_live.py b/tests/analysis/typescript/test_typescript_taint_live.py new file mode 100644 index 00000000..c4a0486c --- /dev/null +++ b/tests/analysis/typescript/test_typescript_taint_live.py @@ -0,0 +1,293 @@ +################################################################################ +# Copyright IBM Corporation 2026 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +################################################################################ + +r"""``taint()``'s TypeScript graph walk, and the one thing offline tests cannot show: a **cut that +fires on one pair while its sibling survives**. + +The offline suite pins the policy over a hand-built payload; Task 7 shipped this walk without ever +running the Cypher. superset-frontend is where it has to hold up — 125,532 body nodes, 119,384 +``TS_DDG`` edges, and a genuine sanitizer in its actual source, not one written for a test. + +**The witness.** ``packages/superset-ui-core/src/utils/html.tsx`` has:: + + export function sanitizeHtmlIfNeeded(htmlString: string) { + return isProbablyHTML(htmlString) ? sanitizeHtml(htmlString) : htmlString; + } + +which is the validating-guard shape ``taint()`` cuts by *variable*: the parameter ``htmlString`` is on +the flow, and it reaches **two** sinks at length 2 — ``sanitizeHtml``'s own ``htmlString`` and +``isProbablyHTML``'s ``text``. Two pairs off one source is what makes a differentiated cut visible: +:func:`test_a_callable_cut_takes_one_pair_and_leaves_its_sibling` cuts one of them and asserts the +other is *still witnessed in the same call*. A cut implemented as a post-filter on returned rows, or +one applied application-wide, passes a single-pair test and fails that one. + +**The transforming-sanitizer shape is absent here, and that is the honest answer.** Step 1 of Task 8 +swept superset's whole vocabulary — ``sanitiz|sanitis|escape|encode|scrub|strip|validate|verify``, 45 +candidates — for a *third* callable sitting mid-flow between two others. There is none. +``sanitizeHtmlIfNeeded`` is itself a **root**: a backward expand into its parameter port, + + MATCH (z)-[:TS_DDG|TS_PARAM_IN|TS_PARAM_OUT|TS_CALL_RET*1..4]-> + (a:TSBodyNode {id: '…/html.tsx/sanitizeHtmlIfNeeded@formal_in:0'}) RETURN z LIMIT 25 + +returns **zero** rows, and it has no incoming ``TS_CALLS`` at all. Of the necessary-condition sweep's +12 survivors every one was the same ``TranslatorSingleton.t()`` call/return coupling — a translation +lookup, not a taint route. ``filter`` and ``check`` were deliberately left out of that vocabulary: +in superset they are data-plane words with 200+ hits and no sanitizer among them, and pretending +otherwise would have manufactured a witness. So the bare-``str`` **callable** cut is exercised below +against callables that are genuinely on these flows; what does not exist on this corpus is a callable +that is *semantically* a transforming sanitizer in the middle of one. + +**Two live addressing facts this file depends on**, both worth knowing before reading a failure here: + +* ``sanitizeHtml`` is **ambiguous** in superset — ``packages/superset-ui-core/src/utils/html`` and + ``plugins/plugin-chart-echarts/src/utils/series`` both export one — so the sink is spelled with its + dotted module path. :func:`test_the_bare_sink_name_is_ambiguous_and_says_so` pins that the SDK + *raises* rather than silently picking one, because picking one would put a flow in a module the + caller never asked about. +* TypeScript's emitter names a parameter port in ``of``, not ``var`` (0 of 10,495 ``formal_in`` nodes + carry ``var``), which is why the argument hop below reports ``var=None`` and a variable cut lands on + the ``TS_DDG`` hop instead of on the crossing. + +Graph backend only — the superset-frontend checkout is not in this repo, so there is no local run to +agree with; the offline suite carries the cross-backend parity policy. + +Its own environment namespace, and **7687 is deliberately not among the defaults**:: + + CLDK_TEST_TSTAINT_NEO4J_URI=bolt://localhost:7692 \ + CLDK_TEST_TSTAINT_NEO4J_USER=neo4j \ + CLDK_TEST_TSTAINT_NEO4J_PASSWORD=... \ + CLDK_TEST_TSTAINT_NEO4J_APP=superset-frontend \ + uv run --all-groups --extra neo4j pytest tests/analysis/typescript/test_typescript_taint_live.py + +The other TypeScript live modules share ``CLDK_TEST_NEO4J_*``. Keeping this one separate means +pointing it at a graph cannot re-point them, and it means no variable read here has a default that +resolves to a port a developer is likely to be tunnelling. + +Read-only, like every other Neo4j suite here. +""" + +from __future__ import annotations + +import logging +import os + +import pytest + +logging.getLogger("neo4j").setLevel(logging.ERROR) + +TAINT_URI = os.environ.get("CLDK_TEST_TSTAINT_NEO4J_URI", "bolt://localhost:7692") +TAINT_USER = os.environ.get("CLDK_TEST_TSTAINT_NEO4J_USER", "neo4j") +TAINT_PASSWORD = os.environ.get("CLDK_TEST_TSTAINT_NEO4J_PASSWORD", "cldkleg25btest") +TAINT_APP = os.environ.get("CLDK_TEST_TSTAINT_NEO4J_APP", "superset-frontend") + +#: The guard's parameter. One source. +SOURCES = [("htmlString", "sanitizeHtmlIfNeeded")] + +#: Its two sinks. The first needs the dotted module path (``sanitizeHtml`` alone is ambiguous in +#: superset); the second is unique, and is spelled bare on purpose so the file exercises both forms. +SANITIZE_HTML = "packages/superset-ui-core/src/utils/html.sanitizeHtml" +SINKS = [("htmlString", SANITIZE_HTML), ("text", "isProbablyHTML")] + +#: The pair keys ``exhausted`` uses. A pair is named by the two *selector names* the caller wrote, +#: never by the resolved node — which is why these read as bare identifiers. +TO_SANITIZE_HTML = ("htmlString", "htmlString") +TO_IS_PROBABLY_HTML = ("htmlString", "text") + + +def _graph_present() -> bool: + """True iff a server answers at ``TAINT_URI`` *and* holds ``TAINT_APP``. + + Connectivity is not the question — a graph holding some *other* application would turn every + selector below into a ``SelectorNotInGraph`` that reads like a defect in ``taint()``. + """ + try: + from neo4j import GraphDatabase + except ModuleNotFoundError: + return False + try: + driver = GraphDatabase.driver(TAINT_URI, auth=(TAINT_USER, TAINT_PASSWORD)) + try: + driver.verify_connectivity() + with driver.session() as session: + found = session.run("MATCH (a:Application {id: $id}) RETURN count(a) AS c", id=f"can://{TAINT_APP}").single() + return bool(found and found["c"]) + finally: + driver.close() + except Exception: # noqa: BLE001 - any connection/auth failure => skip, never fail + return False + + +pytestmark = pytest.mark.skipif( + not _graph_present(), + reason=(f"no live TypeScript taint corpus: needs Neo4j at {TAINT_URI} holding {TAINT_APP!r} " "(set CLDK_TEST_TSTAINT_NEO4J_URI / _USER / _PASSWORD / _APP)"), +) + + +@pytest.fixture(scope="module") +def graph(): + """The corpus graph, attached. Module-scoped: attaching runs the ≥ 1.5.2 version probe.""" + from cldk import CLDK + from cldk.analysis.commons.backend_config import Neo4jConnectionConfig + + facade = CLDK.typescript( + project_path=None, + backend=Neo4jConnectionConfig(uri=TAINT_URI, username=TAINT_USER, password=TAINT_PASSWORD, application_name=TAINT_APP), + ) + yield facade.backend + facade.backend.close() + + +#: The two sinks as the walk reports them, which is what tells the pairs apart. ``exhausted`` keys on +#: the *selector names* the caller wrote, and both pairs here are sourced from ``htmlString``, so the +#: witness's own endpoint is the only unambiguous name for "which pair answered". +AT_SANITIZE_HTML = "packages/superset-ui-core/src/utils/html.sanitizeHtml" +AT_IS_PROBABLY_HTML = "packages/superset-ui-core/src/utils/html.isProbablyHTML" + + +def _answered(result): + """Which requested pair each witness answers, read off the walk's own endpoints. + + ``paths`` is a flat list, so a test that only counted it could not tell "the cut took the sibling" + from "the cut took the pair I aimed at" -- both leave one row. The last hop's ``to`` is the sink + position and its ``callable`` is the dotted signature, so this names the pair even where the two + selector names cannot. + """ + return sorted(p.hops[-1].to.callable for p in result.paths) + + +def test_the_guard_reaches_both_of_its_sinks(graph): + """The baseline every cut below is a delta against. + + Two witnesses, one per pair, each 2 hops: a ``TS_DDG`` step carrying ``htmlString`` and then the + ``TS_PARAM_IN`` crossing into the callee's port. Nothing exhausted, so every pair is answered. + """ + r = graph.taint(SOURCES, SINKS) + assert sorted(len(p.hops) for p in r.paths) == [2, 2] + assert _answered(r) == sorted([AT_SANITIZE_HTML, AT_IS_PROBABLY_HTML]) + assert r.exhausted == [] + assert r.unresolved == [] + assert r.complete is True + assert len(r.roots) == 3, "one source and two sinks" + + +def test_the_crossing_carries_no_variable_but_the_data_hop_does(graph): + """Where a TypeScript variable cut can and cannot land — the fact the next test rests on. + + ``TS_PARAM_IN`` reports ``var`` as ``None`` here (the emitter puts the parameter's name in the + port's ``of``), so ``coalesce(r.var, '')`` makes a crossing uncuttable by name. The ``TS_DDG`` hop + *does* carry ``htmlString``, which is why cutting the source variable works at all — it severs the + step *before* the crossing, not the crossing. + + Stated as an assertion rather than a comment so that an emitter that starts populating ``var`` on + crossings shows up here as a deliberate re-measurement instead of as prose that quietly went stale. + """ + hops = [h for p in graph.taint(SOURCES, SINKS).paths for h in p.hops] + assert {(h.via, h.var) for h in hops} == {("data", "htmlString"), ("argument", None)} + + +def test_a_variable_cut_on_the_guards_parameter_refutes_both_pairs(graph): + """Shape (a): the guard's own parameter is the source, so the cut severs at the first hop. + + Both pairs go to zero **and are certified** — the strongest thing ``taint()`` says, and the one + that closes an alert — so the certificate's preconditions are asserted beside it: no witness, no + diagnostic, and ``depth`` was never set. + """ + r = graph.taint(SOURCES, SINKS, [("htmlString", "sanitizeHtmlIfNeeded")]) + assert r.paths == [] + assert sorted(r.exhausted) == sorted([TO_SANITIZE_HTML, TO_IS_PROBABLY_HTML]) + assert r.unresolved == [] + assert r.complete is True + + +def test_a_callable_cut_takes_one_pair_and_leaves_its_sibling(graph): + """The assertion that needed a real graph, and the reason this file exists. + + One ``taint()`` call, two pairs, a cut aimed at exactly one of them. The ``isProbablyHTML`` pair + is refuted and certified; the ``sanitizeHtml`` pair still has its witness **in the same result**. + + This is what a post-filter on returned rows cannot do (it would drop the row but never book the + refutation) and what an application-wide cut cannot do either (it would take both). ``complete`` + stays ``True`` because a refuted pair is a finished pair, not a truncated one. + """ + r = graph.taint(SOURCES, SINKS, ["isProbablyHTML"]) + assert _answered(r) == [AT_SANITIZE_HTML], "the sibling pair must survive the cut" + assert r.exhausted == [TO_IS_PROBABLY_HTML] + assert r.complete is True + other = graph.taint(SOURCES, SINKS, [SANITIZE_HTML]) + assert _answered(other) == [AT_IS_PROBABLY_HTML], "and the cut is symmetric" + assert other.exhausted == [TO_SANITIZE_HTML] + + +def test_an_unrelated_callable_cut_leaves_the_batch_untouched(graph): + """Over-cutting is the one output this leg refuses, so a cut that must do nothing gets a test. + + ``validateNonEmpty`` is a real superset validator on no path between these three callables. + ``under_callable``'s three disjuncts (``n.id = q``, ``+ '@'``, ``+ '/'``) exist so a prefix match + cannot spill into a sibling whose id merely starts with the same characters — at 45 ``validate*`` + candidates in this corpus, a bare ``STARTS WITH`` would have plenty to spill onto. + """ + r = graph.taint(SOURCES, SINKS, ["validateNonEmpty"]) + assert sorted(len(p.hops) for p in r.paths) == [2, 2] + assert r.exhausted == [] and r.complete is True + + +def test_a_bounded_search_certifies_nothing_even_when_it_finds_nothing(graph): + """``depth`` bounds the search; ``exhausted`` is empty whenever it is set (Ruling F). + + ``depth=1`` is one hop short of both witnesses, so this is the case that matters most: zero paths + and zero certificates. A ``taint()`` that filled ``exhausted`` here would tell triage that a flow + it never looked far enough to see does not exist — which on this very pair would be wrong twice + over. ``depth=2`` finds both, proving the bound and not the graph was the reason. + """ + short = graph.taint(SOURCES, SINKS, depth=1) + assert short.paths == [] + assert short.exhausted == [], "no witness under a bound is not a refutation" + assert short.complete is True, "a bounded search that truncated nothing is complete" + assert graph.taint(SOURCES, SINKS, depth=2).paths, "one more hop and both pairs witness" + cut = graph.taint(SOURCES, SINKS, ["isProbablyHTML"], depth=2) + assert len(cut.paths) == 1 and cut.exhausted == [], "a cut under a bound still certifies nothing" + + +def test_the_cap_is_per_pair_so_two_pairs_both_answer_at_one(graph): + """``collect(p)[0..$cap]`` against a flat ``LIMIT``, on a real graph. + + ``max_paths=1`` returns **two** rows here — one per pair — and ``complete`` stays ``True`` because + neither pair had a second witness to drop. A flat ``LIMIT 1`` returns one row and reports the + other pair as unwitnessed. That is the shape of the harm the per-pair cap exists to prevent, and + it is invisible on a single-pair fixture. + """ + r = graph.taint(SOURCES, SINKS, max_paths=1) + assert _answered(r) == sorted([AT_SANITIZE_HTML, AT_IS_PROBABLY_HTML]) + assert r.complete is True, "nothing was truncated, so nothing is flagged" + assert r.exhausted == [] + + +def test_the_bare_sink_name_is_ambiguous_and_says_so(graph): + """Addressing, not traversal — but the failure mode it prevents is a taint result in the wrong + module. + + superset exports ``sanitizeHtml`` twice (``superset-ui-core/src/utils/html`` and + ``plugin-chart-echarts/src/utils/series``). The SDK refuses the bare name rather than choosing, + and the message names both candidates, which is what lets a caller write the dotted form the rest + of this file uses. A resolver that picked the first match would return a flow into a chart plugin + for a question asked about the core utility. + """ + from cldk.utils.exceptions.exceptions import AmbiguousName + + with pytest.raises(AmbiguousName) as excinfo: + graph.taint(SOURCES, [("htmlString", "sanitizeHtml")]) + assert "packages/superset-ui-core/src/utils/html.sanitizeHtml" in str(excinfo.value) + assert "plugins/plugin-chart-echarts/src/utils/series.sanitizeHtml" in str(excinfo.value) From 3072ac103a84f8a4862ac5a99cc6fb3617b542f5 Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Thu, 10 Sep 2026 12:07:20 -0400 Subject: [PATCH 49/65] docs: taint() on the agent reference, the skill, and the per-language notes The reference gains a taint row in the decomposition and accessor tables, and a paragraph on the one thing it can do that nothing else on the surface can: say a flow does not exist. paths_between returning [] and flows_to_call returning False cannot distinguish "no flow exists" from "the flow left the graph I can see", so neither is a refutation. The three rules that make exhausted safe to act on are spelled out, as is the shape rule for sanitizers. Two counts corrected while passing through. The bounds paragraph said three bound themselves and five do not; taint makes it six. And CLAUDE.md said flatly that four Java accessors raise -- measured on daytrader8, _ports_carry_dependence is True and none of them refuses there, so the note now says what the probe actually asks: it reads the data, never a version, and a graph re-emitted by 3.0.3 or later answers. The agent reference already had this right. The Java parameter-crossing coarseness is recorded in both the reference and the skill's trap list, with its direction: a Java *_PARAM_IN edge carries no var (0 of 76,791 on daytrader8), so a (name, within) sanitizer cannot sever a crossing. That under-cuts, which over-reports a flow, and never manufactures a refutation -- the direction that would matter. CHANGELOG.md is deliberately untouched; the entry text goes in the PR body for whoever cuts the release. --- CLAUDE.md | 15 +++++++++-- docs/agent-api-reference.md | 44 ++++++++++++++++++++++++++++----- docs/skills/using-cldk/SKILL.md | 20 ++++++++++++--- 3 files changed, 67 insertions(+), 12 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 93f412c1..b6c1e3eb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,14 +25,25 @@ artifact six, and the J-7 leaf accessors (`get_interfaces`/`get_enums`/`get_enum `get_records`, names shared with TypeScript). Both backends answer identically, including the miss paths; the policy lives once on `JavaAnalysisBackend` because `JNeo4jBackend` rebuilds the canonical `JApplication` and answers from it. Four things Java says rather than answering, each measured: -`slice_forward` / `paths_between` / `flows_to_call` / `flows_to_argument` raise (the analyzer's L4 -port lattice carries no dependence edge, codeanalyzer-java#227); `get_entrypoint_coverage` reports +`slice_forward` / `paths_between` / `flows_to_call` / `flows_to_argument` raise **when the attached +analysis has no dependence edge leaving a `formal_in`** (codeanalyzer-java#227) — the probe reads the +data, never a version, so a graph re-emitted by >= 3.0.3 answers instead: measured on daytrader8, +`_ports_carry_dependence` is `True` and none of the four refuses there; `get_entrypoint_coverage` reports `entrypoint_report_unavailable` and the three config-read accessors raise on an analysis older than codeanalyzer-java 3.1.0, which is the release that added both overlays (the probe is the entrypoint report's presence, measured from the data, never a version string); `get_external_symbols` raises off a local run (`--external-calls` is opt-in and `--emit neo4j` forces it); the CRUD accessors still raise. `docs/agent-api-reference.md` has the full lossiness list. +**All three languages, since leg 4b (#382):** `taint(sources, sinks, sanitizers=(), *, depth=None, +max_paths=10)` on every facade — m sources against n sinks in one traversal, with `TaintResult` +adding `exhausted` (pairs searched to exhaustion: the refutation `paths_between` cannot give), +`roots`, `resolved` and `unresolved`. Three rules the docstrings carry and the tests pin: an explicit +`depth` empties `exhausted` by rule; `complete` is the batch's flag and one blocked pair voids every +absence claim in the result; `max_paths` caps per pair, never per call. The source/sink/sanitizer +vocabulary is the caller's — no framework catalogue ships. Java shares the `formal_in` dependence +probe above, so `taint` refuses on the same analyses the four verbs do. + The legacy `CLDK(language="").analysis(...)` entry still works as a compat shim. Adding a language means a new factory method + facade + backend ABC/impl(s) + models + tests — **update this table in the same change**. diff --git a/docs/agent-api-reference.md b/docs/agent-api-reference.md index 578f69ff..40afa0e9 100644 --- a/docs/agent-api-reference.md +++ b/docs/agent-api-reference.md @@ -217,7 +217,8 @@ bounds rule (slices bounded by default, predicates and path queries unbounded, b predicate returns a *wrong* answer rather than a small one) and the same `complete` protocol. Java-specific facts: -- **All four interprocedural value verbs answer from codeanalyzer-java 3.0.3.** `slice_forward`, +- **All four interprocedural value verbs answer from codeanalyzer-java 3.0.3** (`taint` is a fifth + and shares the check exactly). `slice_forward`, `paths_between`, `flows_to_call` and `flows_to_argument` used to raise, because the analyzer emitted the L4 port lattice disconnected from the statement dependence graph; 3.0.3 joins the two, so a value can be followed out of a parameter, across call boundaries, into a callee's @@ -419,7 +420,11 @@ names its upstream issue where there is one: site is fed by the one statement containing it, not by the reaching definition of that particular argument. Paths are complete and the hop vocabulary is honest, but `flows_to_argument` cannot be read as per-argument precision: on daytrader8 every argument of a reached call site answers - `True` together. Do not assert a tighter shape than the analyzer promises. + `True` together. Do not assert a tighter shape than the analyzer promises. The same coarseness + reaches `taint`: a Java `*_PARAM_IN` crossing carries no `var` (0 of 76,791 edges on daytrader8), + so a `(name, within)` sanitizer cannot sever a parameter crossing — it lands on the statement hop + before it or on nothing. That **under**-cuts, which over-reports a flow; it never manufactures a + refutation, which is the direction that would matter. - **`ddg` edges naming an endpoint that is not a body node are gone** (`codeanalyzer-java#228`, fixed in 3.0.3). Up to 3.0.2, 87 of daytrader8's 5,434 `ddg` edges named an endpoint the analyzer never emitted as a body node — all `points-to`, over 38 distinct keys of the shape `:0` — @@ -466,6 +471,7 @@ Most questions decompose into these. Start here, then use the tables below. | a callable name | its source | `get_method_bodies([sig])` or `get_source(node_id)` | | a callable | who calls it / what it calls | `get_callers(...)` / `get_callees(...)` | | a value and a sink | does one reach the other | `flows_to_call(...)` / `flows_to_argument(...)` | +| many values and many sinks | which pairs flow, and which are **refuted** | `taint(sources, sinks, sanitizers)` | | a config key | which code reads it | `get_config_readers(key)` | | nothing, want the surface | entrypoints | `get_entrypoints()` + `get_entrypoint_coverage()` | @@ -741,8 +747,8 @@ literal at the call site, `"dataflow"` a key reached over the DDG or the call gr All implemented, on both backends: the three per-callable graphs (`get_cfg` / `get_cdg` / `get_ddg`), the slices (`slice_backward` / `slice_forward` / `backward_cone`), the call-graph questions (`reaches` / `callers_of` / `callees_of` / `call_paths_between`), the value-flow -questions (`paths_between` / `flows_to_call` / `flows_to_argument`), the addressing step behind -them (`resolve_callable` / `resolve_value`) and `describe`. +questions (`paths_between` / `flows_to_call` / `flows_to_argument`), the m*n batch with sanitizers +(`taint`), the addressing step behind them (`resolve_callable` / `resolve_value`) and `describe`. Names in, names out. No `can://` URIs, no ordinals — you say `"invoice_id"`, not `"…@formal_in:1"`. @@ -753,6 +759,7 @@ Names in, names out. No `can://` URIs, no ordinals — you say `"invoice_id"`, n | `paths_between(src, dst, src_within=, dst_within=, depth=None, max_paths=10)` | how one reaches the other | `py.paths_between("invoice_id", "invoice_ids", src_within="PaymentPortal.invoice_transaction", dst_within="PaymentPortal._process_transaction")` | | `flows_to_call(src, callee, within=, depth=None)` | reaches **any call to** X | `py.flows_to_call("invoice_id", "_process_transaction", within="PaymentPortal.invoice_transaction")` | | `flows_to_argument(src, callee, arg, within=, depth=None)` | reaches X's **named argument** | `py.flows_to_argument("invoice_id", "_process_transaction", arg="invoice_ids", within="…invoice_transaction")` | +| `taint(sources, sinks, sanitizers=(), depth=None, max_paths=10)` | which of m sources reach which of n sinks, and which pairs are **refuted** | `py.taint([("invoice_id", "PaymentPortal.invoice_transaction")], [("query", "AccountMove._execute")], sanitizers=["html.escape"])` | | `reaches(src, dst, depth=None)` | is there a call path | `py.reaches("invoice_transaction", "AccountMove.write")` | | `call_paths_between(src, dst, depth=None, max_paths=10)` | show the call chains | `py.call_paths_between("PaymentPortal.invoice_transaction", "AccountMove.write")` | | `resolve_callable(name, in_class=, in_module=)` | what a name means, before asking | `py.resolve_callable("write", in_class="AccountMove").callable` | @@ -768,6 +775,31 @@ Names in, names out. No `can://` URIs, no ordinals — you say `"invoice_id"`, n **`flows_to_call` and `flows_to_argument` are different questions.** A tainted value can reach a function without reaching the parameter that matters. Ask the one you mean. +**`taint` is the only one that can tell you a flow does *not* exist.** `paths_between` returning `[]` +and `flows_to_call` returning `False` cannot distinguish "no flow exists" from "the flow left the +part of the graph I can see", so neither is a refutation. `taint` asks m sources against n sinks in +one traversal and reports each pair three ways: a witness in `paths`, a **refutation** in +`exhausted`, or a reason in `unresolved`. Three rules make `exhausted` safe to act on: + +* A pair is listed only when it has **no witness, no diagnostic implicating it, and `depth is + None`**. An explicit `depth` empties `exhausted` entirely — by rule, not by tendency — because a + bound turns a long real flow into an empty result, and a wrong refutation closes a live alert. +* `complete` is the **batch's** flag, not the pair's. One skipped or blocked pair makes it `False` + however cleanly the rest answered, and while it is `False` no absence claim stands on *any* pair in + the result. Read `unresolved` before reading `exhausted`. +* `max_paths` caps witnesses **per pair**, not per call. With one sink and forty sources a flat cap + would let one prolific pair starve the other thirty-nine into looking refuted. + +**Sources, sinks and sanitizers are yours to supply.** The SDK ships no framework catalogue and +derives no default set — a per-language vocabulary of taint sources is policy that rots, and this is +the mechanism. A sanitizer is two things wearing one word, told apart by **shape**: a bare `str` cuts +a *callable* on the path (a transforming sanitizer, `html.escape`), and a `(name, within)` pair cuts a +*variable* inside that callable, which is the only thing that severs a *validating* guard — a guard +never sits on the data path at all, it reads the value and throws. Both cuts are applied inside the +search, so what comes back is the shortest **unsanitized** route rather than a filtered list of +sanitized ones. Measured on superset-frontend: `sanitizeHtmlIfNeeded`'s `htmlString` reaches two +sinks; cutting one callable refutes that pair and leaves the sibling witnessed in the same result. + **A slice is a set; a path is a sequence.** `slice_backward` answers "what is in scope"; a 10k-node cone can contain millions of paths, so it never returns them. `paths_between` answers "how does A reach B", with each hop carrying the edge that justified it — `hop.via` in your words (`data` / @@ -869,11 +901,11 @@ are in the result, and a sink nothing calls comes back as its own one-node cone empty answer you could not tell from a name that matched nothing. `sl.root` is the single seed for the slices; a multi-sink cone has `sl.roots` and raises if you ask it for one. -**Three bound themselves by default; five do not, and the split is the whole point.** The +**Three bound themselves by default; six do not, and the split is the whole point.** The *slices* (`slice_backward`, `slice_forward`, `backward_cone`) default to `depth=5`: a bounded slice is a **complete** answer to a narrower question, and `total` tells you so. The *predicates* (`reaches`, `flows_to_call`, `flows_to_argument`) and the *path queries* (`paths_between`, -`call_paths_between`) default to `depth=None`, unbounded: a hop budget on a boolean or a path list +`call_paths_between`, `taint`) default to `depth=None`, unbounded: a hop budget on a boolean or a path list is not a smaller answer but a wrong one — "no flow" and "no flow within five hops" collapse into the same `False` / `[]` with nothing in the result to tell them apart. Measured: `flows_to_call("kwargs", "Website.create", within="Website.configurator_apply")` is `False` at five diff --git a/docs/skills/using-cldk/SKILL.md b/docs/skills/using-cldk/SKILL.md index af3cc9c7..de98e1bc 100644 --- a/docs/skills/using-cldk/SKILL.md +++ b/docs/skills/using-cldk/SKILL.md @@ -82,6 +82,13 @@ same match that produced them. **Value flow** — `paths_between`, `flows_to_call`, `flows_to_argument`. Each hop is labelled `data`, `argument`, `return` or `control`, so a path is evidence rather than an assertion. +**Taint** — `taint(sources, sinks, sanitizers=())`. m sources against n sinks in one traversal, and +the only accessor that can say a flow does **not** exist: a pair in `exhausted` was searched to +exhaustion. You supply the vocabulary — the SDK ships no framework catalogue. A sanitizer is told +apart by shape: a bare `str` cuts a callable (a transforming sanitizer), a `(name, within)` pair cuts +a variable (a validating guard, which never sits on the data path). Both cuts apply inside the +search, so a witness is an *unsanitized* route. + **Inventory** — `get_entrypoints`, `get_entrypoint_classes`, `get_entrypoint_coverage`, `get_external_symbols`, the artifact and dependency getters, `get_config_keys` and the config-read accessors. @@ -122,12 +129,17 @@ differently. and let ambiguity raise. 2. **`flows_to_argument` is coarse on Java.** Every actual of a call site is fed by the statement containing it, so it answers `True` for any argument of a reached call. Paths are complete; - per-argument precision is not there yet. -3. **DDG provenance differs by language** — three tiers in Python, two in Java, one in TypeScript. + per-argument precision is not there yet. The same coarseness reaches `taint`: a Java parameter + crossing carries no variable name, so a `(name, within)` sanitizer cannot cut one. It under-cuts, + which over-reports; it never invents a refutation. +3. **`taint`'s `exhausted` is void unless you check two other fields.** An explicit `depth` empties + it by rule, and `complete is False` voids the whole batch's absence claims — not just the pair the + diagnostic names. Read `unresolved` first, then `complete`, then `exhausted`. +4. **DDG provenance differs by language** — three tiers in Python, two in Java, one in TypeScript. `prov` says which; do not compare tiers across languages. -4. **`get_source` over Neo4j is the declaration, in process it is the body block.** Both are +5. **`get_source` over Neo4j is the declaration, in process it is the body block.** Both are documented, and the relation between them is exact — but they are not the same string. -5. **A polyglot application is pushed in all languages, or none.** A `--emit neo4j` push is +6. **A polyglot application is pushed in all languages, or none.** A `--emit neo4j` push is destructive and the application prefix is shared, so pushing one language sweeps derived rows (notably `@external` ghosts) belonging to its siblings until they push again. From ad6c21b07df1813b3edef6f38b1f70b446500b0e Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Thu, 10 Sep 2026 12:19:12 -0400 Subject: [PATCH 50/65] test(taint): pin codeanalyzer-python#204's false refutation instead of denying it The module docstring claimed the #204 symptom was gone from the fixture graph because `("user_input", "handle")` yields seven witnesses. It is not gone; it is narrow. Measured at edge level, `handle@formal_in:1` reaches only *statements*, and a statement has no edge to its own `actual_in` -- so a caller's parameter cannot reach the first call it is passed to, while a later call is reached at three hops through the preceding statement. The seven witnesses are all later calls. The consequence is the output class that can do harm: `taint([("user_input", "handle")], [("raw", "scrub")])` certifies `exhausted` on the fixture's own first line, `answer = scrub(user_input)`. That is a refutation of a real flow, and at least one of the 169-pair batch's 139 certificates is it. Correct the docstring to state the narrow symptom with its measured mechanism, add a test that pins the false refutation as a known upstream defect (so an upstream fix fails a test rather than changing meaning silently), and note on the batch test that its 139 is an accounting figure and not 139 true verdicts. --- .../analysis/python/test_python_taint_live.py | 92 +++++++++++++++---- 1 file changed, 73 insertions(+), 19 deletions(-) diff --git a/tests/analysis/python/test_python_taint_live.py b/tests/analysis/python/test_python_taint_live.py index 28a3a246..e1d16e2d 100644 --- a/tests/analysis/python/test_python_taint_live.py +++ b/tests/analysis/python/test_python_taint_live.py @@ -28,27 +28,36 @@ formal_in -> body -> formal_out -[return]-> actual_out -> stmt -> actual_in -[argument]-> formal_in -**Correction, measured in Task 8 on the current fixture graph.** An earlier version of this docstring -said a *caller's* parameter was unusable as a source -- that ``Handler.handle``'s ``@formal_in`` port -and its ``@entry`` def-site were disjoint upstream, so the port a selector resolves to reached no -argument at all (codeanalyzer-python#204), and that sourcing from ``("user_input", "handle")`` would -record a defect as a passing assertion. **That is no longer true of this graph, and both backends -agree it is not.** ``taint([("user_input", "handle")], ...)`` returns **7 witnesses across 4 sinks**, -each 3 hops, e.g.:: - - handle@formal_in:1 -[data user_input]-> handle@50:8 -[data answer]-> handle@53:8/actual_in:0 - -[argument cleaned]-> run_query@formal_in:0 - -So a caller's parameter *does* reach the arguments it is passed to here, at 3 hops rather than 6. -:data:`SOURCES` is left on the callees' parameters anyway -- it is what the existing assertions were -measured against and rewriting them would discard that -- but nothing below rests on #204's symptom -being present, and :data:`ALL_VALUES` includes ``("user_input", "handle")`` precisely because it now -witnesses. +**codeanalyzer-python#204, narrowed by measurement.** An earlier version of this docstring said a +*caller's* parameter reaches no argument at all; a later one said the symptom was gone. Both are +wrong. The symptom is still present and it is **narrow**: a caller's parameter cannot reach the +**first** call it is passed to, and does reach every later one. Measured at edge level on this graph, +``handle@formal_in:1`` -- what ``resolve_value("user_input", within="handle")`` returns -- has exactly +two out-edges, both to *statements* (``@50:8`` and ``@51:8``), and a statement has no edge to its own +``actual_in``. A statement does have edges to a **later** statement's ``actual_in``, so every witness +runs three hops through the preceding statement:: + + handle@formal_in:1 -[data user_input]-> handle@50:8 -[data user_input]-> @51:8/actual_in:0 + -[argument raw]-> relay@formal_in:0 + +That leaves exactly the failure Ruling J exists to prevent, live on this graph. The fixture's first +body line is ``answer = scrub(user_input)``, and ``taint([("user_input", "handle")], +[("raw", "scrub")])`` returns **0 paths, ``exhausted == [("user_input", "raw")]``, ``complete is +True``** -- a certified refutation of the file's most obvious flow. The SDK is right and the graph is +wrong, and nothing in the SDK can tell. That is pinned below as a known upstream defect, so a fix +upstream fails a test rather than passing silently. + +So :data:`SOURCES` sources from **callees'** parameters, and that is the rule which makes its +assertions mean what they say. :data:`ALL_VALUES` keeps ``("user_input", "handle")`` because the batch +test asserts an accounting identity over 169 pairs rather than the truth of any one refutation -- but +**at least one of its 139 certificates is that false refutation**, so the count is a measurement of +this graph and never a claim that 139 flows do not exist. Path counts on this graph are still **not route counts**: a reaching-definition ``var`` names the -*use* rather than the def, which inflates them (two of the seven witnesses above differ only in which -statement line the first ``data`` hop passes through). So the numbers below are measured, and what -they are asserted against is a hop chain wherever a chain will do. +*use* rather than the def, which inflates them (of the seven witnesses ``("user_input", "handle")`` has +across four sinks, pairs differ only in which statement line the first ``data`` hop passes through). +So the numbers below are measured, and what they are asserted against is a hop chain wherever a chain +will do. Every ref is measured from the graph through ``resolve_value``. Nothing here hardcodes a ``can://`` id: the leg-4a ledger did, its fixture was regenerated, and those ids now name nothing. @@ -331,6 +340,12 @@ def test_a_169_pair_batch_accounts_for_every_pair_exactly_once(local, graph): A degenerate pair is attributable by construction (it is skipped by name), so the 139 certificates stand beside 13 diagnostics. ``complete`` is still ``False``, because the ledger is not empty, and that is Ruling H being coarse on purpose. + + What the 139 is **not** is 139 true refutations. At least one of them -- + ``("user_input", "raw")``, the source file's own first line -- is false, for the graph-shape + reason :func:`test_the_first_call_a_caller_parameter_is_passed_to_is_still_falsely_refuted` pins. + The claim here is the accounting, not the verdicts: every pair is answered exactly once, whether + or not the graph answered it correctly. """ got = {} for name, backend in (("local", local), ("graph", graph)): @@ -344,3 +359,42 @@ def test_a_169_pair_batch_accounts_for_every_pair_exactly_once(local, graph): assert (witnessed_count, len(exhausted), paths) == (17, 139, 28), "measured on the fixture; the identity above is what protects it" assert complete is False, "13 diagnostics in the ledger, so the batch flag is False (Ruling H)" assert exhausted, "and Ruling I does not void them: a degenerate pair is attributable by name" + + +@pytest.mark.parametrize("backend_name", ["local", "graph"]) +def test_the_first_call_a_caller_parameter_is_passed_to_is_still_falsely_refuted(request, backend_name): + """codeanalyzer-python#204, pinned as a defect rather than left as a passing assertion. + + The fixture's first body line is ``answer = scrub(user_input)``. This asks the one question whose + right answer the graph cannot give, and both backends give the same wrong one: **no flow, and a + certificate saying so.** Every condition ``exhausted`` requires holds honestly -- the search was + unbounded, it found nothing, and no diagnostic fired -- so the refutation is not a bug in + ``taint()``. It is the analyzer's reaching-definition shape: ``handle@formal_in:1``'s only + successors are *statements*, and a statement has no edge to its own ``actual_in``. + + This test exists because that is the one output class that can do harm. A false ``exhausted`` + closes a live alert, where a false witness only costs a human a triage pass, and the asymmetry is + why the module docstring spells the symptom out rather than working around it. It is also the + reason :data:`SOURCES` sources from callees. + + **When #204 is fixed upstream this test fails**, and that is its purpose: the pin turns a silent + change of meaning into a red test that says which numbers to re-measure -- this module's ``(17, + 139, 28)`` batch figures first. + """ + backend = request.getfixturevalue(backend_name) + r = backend.taint([("user_input", "handle")], [("raw", "scrub")]) + assert r.paths == [], "the flow the source file shows on its first line" + assert r.exhausted == [("user_input", "raw")], "and it is certified absent -- the dangerous direction" + assert r.complete is True + assert r.unresolved == [], "nothing fired, so Ruling I does not void it either" + + # The same source against a *later* call it is passed to: reached, at three hops through the + # statement that precedes the call. So the symptom is narrow, and 'a caller's parameter is + # unusable' would be as wrong as 'the symptom is gone'. + later = backend.taint([("user_input", "handle")], [("raw", "relay")]) + assert [(h.via, h.var) for h in later.paths[0].hops] == [ + ("data", "user_input"), + ("data", "user_input"), + ("argument", "raw"), + ] + assert later.exhausted == [] From 9a47d228109a32bf59ff0c7bc093a16a1d835081 Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Thu, 10 Sep 2026 12:21:41 -0400 Subject: [PATCH 51/65] test(taint): assert the variable cut's coalesce by name, not only through the digest Deleting `coalesce(r.var, '')` from the sanitizer cut leaves `test_taint_semantics.py` at 23 passed: the fake driver carries no null `var`, so the offline suite cannot exercise three-valued logic in Cypher at all. In CI the deletion was caught only by the `_TAINT` digest, whose failure message is the whole statement and says nothing about which property broke -- and by one Java live test, which skips without a container. The property now has its own named assertion, in the test that already argues over-cutting is a false refutation rather than a conservative default, with the mechanism spelled out: `var` is null on every CDG/SUMMARY edge and on every param crossing from a pre-fix analyzer, `NULL = c.var` is NULL, so `all()` drops the path on exactly the edges an interprocedural flow needs. --- tests/analysis/commons/test_lifted_helpers.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/analysis/commons/test_lifted_helpers.py b/tests/analysis/commons/test_lifted_helpers.py index d7dfba32..345e292b 100644 --- a/tests/analysis/commons/test_lifted_helpers.py +++ b/tests/analysis/commons/test_lifted_helpers.py @@ -367,6 +367,13 @@ def test_the_taint_query_delimits_the_callable_cut_rather_than_bare_prefixing_it for disjunct in ("startNode(r).id = c.prefix", "startNode(r).id STARTS WITH c.prefix + '@'", "startNode(r).id STARTS WITH c.prefix + '/'"): assert disjunct in q, f"the variable cut's scope lost its {disjunct!r} disjunct" assert "STARTS WITH c.prefix)" not in q, "the variable cut's scope is still a bare prefix test" + # The same asymmetry, reached through three-valued logic rather than through a prefix. ``var`` is + # null on every ``CDG``/``SUMMARY`` edge and on every param crossing emitted before the analyzers' + # ``var``-on-param-edge fix, and ``NULL = c.var`` is NULL, so ``NOT any(...)`` is NULL and the + # whole ``all()`` drops the path -- an over-cut, in the false-refutation direction, on exactly the + # edges an interprocedural flow has to use. Asserted by name because deleting the ``coalesce`` + # leaves the offline semantics suite green: only a live graph carries the nulls. + assert "coalesce(r.var, '')" in q, "the variable cut lost its coalesce; a null var would over-cut" def test_the_taint_query_still_formats(): From 547ed05bb6ed16d985f140833f1b2d05dca2eadf Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Thu, 10 Sep 2026 12:56:43 -0400 Subject: [PATCH 52/65] fix(taint): one delimited spelling for the sanitizer acceptance domain, on all six backends `_edge_vars_in` decides whether `resolve_sanitizers` accepts a variable sanitizer or refuses it as `SelectorNotInGraph`. The cut it is validating -- `allow_edge` locally, the `can://` predicate in `sdg_taint_query` -- is delimited by ruling K: a callable's own id, or the id plus `@` (a body node) or `/` (a nested callable). Four of the six spellings of the acceptance side were a bare prefix instead, so acceptance was wider than the cut on the Python local backend and on all three graph backends. Wider acceptance cannot manufacture a false `exhausted`: a variable the cut cannot match severs nothing, and a severed edge is the only way a cut removes a path. What it does is worse than a refusal for the caller -- it accepts a sanitizer, silences the `SelectorNotInGraph` that would have reported their mistake, and returns a result they read as sanitized. On TypeScript the collision is real and measured: a callable id has no closing delimiter, so `create` matches 13 `createGuest` ids on the level-4 fixture. Python and Java ids end in `)`, which makes a bare prefix self-delimiting by accident; the spelling is fixed there anyway, because the argument for it is the predicate's, not the corpus's. Two guards, because the two halves are unobservable in different ways. The graph half gets a query-text assertion (`TAINT_DIGESTS` hashes `_TAINT` alone and would not have noticed). The local half gets a source tripwire: the mutation has no witness on two of the three corpora, and a behavioural test that passes under the mutation it names is worse than none. --- .../java/codeanalyzer/codeanalyzer.py | 2 +- cldk/analysis/java/neo4j/neo4j_backend.py | 25 ++++++---- .../python/codeanalyzer/codeanalyzer.py | 10 +++- cldk/analysis/python/neo4j/neo4j_backend.py | 18 ++++--- .../typescript/neo4j/neo4j_backend.py | 27 ++++++---- tests/analysis/commons/test_lifted_helpers.py | 49 +++++++++++++++++++ 6 files changed, 104 insertions(+), 27 deletions(-) diff --git a/cldk/analysis/java/codeanalyzer/codeanalyzer.py b/cldk/analysis/java/codeanalyzer/codeanalyzer.py index cd59f1f2..71496d6f 100644 --- a/cldk/analysis/java/codeanalyzer/codeanalyzer.py +++ b/cldk/analysis/java/codeanalyzer/codeanalyzer.py @@ -619,7 +619,7 @@ def _edge_vars_in(self, callable_id: str) -> FrozenSet[str]: no second traversal. Edges *leaving* a node under ``callable_id`` -- the same ``startNode`` scoping the cut itself uses, so this validates exactly the domain the cut can match. ``J_CDG`` and the two port relationships on a pre-3.1.2 payload carry no ``var``; those - those ``None`` values are dropped, because ``resolve_sanitizers`` refuses a blank variable before it + ``None`` values are dropped, because ``resolve_sanitizers`` refuses a blank variable before it ever asks. """ forward = self._sdg()[0]["forward"] diff --git a/cldk/analysis/java/neo4j/neo4j_backend.py b/cldk/analysis/java/neo4j/neo4j_backend.py index e25d3d3a..9a475d62 100644 --- a/cldk/analysis/java/neo4j/neo4j_backend.py +++ b/cldk/analysis/java/neo4j/neo4j_backend.py @@ -969,15 +969,22 @@ def _value_paths(self, a: SliceNode, b: SliceNode, depth: int | None, max_paths: #: application name -- and ``test_java_neo4j_multi_application_scope.py`` classifies it on that #: basis. #: - #: A bare ``STARTS WITH``, deliberately wider than :attr:`_TAINT`'s delimited cut - #: (:func:`~cldk.analysis.commons.graphs.under_callable`): a sibling callable whose name merely - #: starts with this one contributes its edge variables here, so a variable may be *accepted* that - #: the cut cannot then match. That direction under-cuts -- a cut severing nothing over-reports -- - #: and the direction this leg must refuse is the other one. A Java ``can://`` callable id ends in - #: ``)``, so the collision needs a same-arity overload of a longer name and cannot arise; - #: TypeScript's can (Ruling K), which is why the two ends are spelled the same way on all three - #: backends. One round trip per variable sanitizer, which is as often as a caller writes one. - _EDGE_VARS = "MATCH (n:JBodyNode)-[e:{rels}]->() WHERE n.id STARTS WITH $callable_prefix RETURN collect(DISTINCT e.var) AS vars" + #: The **delimited** predicate -- the three disjuncts of + #: :func:`~cldk.analysis.commons.graphs.under_callable` written in Cypher, the same shape + #: :attr:`_TAINT` gives ``$cut_callables`` and its own cut. Deliberately not a bare + #: ``STARTS WITH``, because the cut this domain exists to validate *for* is delimited: a variable + #: reachable only through an undelimited prefix (Ruling K -- a sibling callable whose name merely + #: starts with this one) would be *accepted* here and then sever nothing there, which is a + #: sanitizer the caller believes is in force and is not. Under-cutting only over-reports, so it + #: cannot manufacture a false refutation, but it is still an error the caller should have been + #: told about, and Ruling A's refusal only means something if this domain is exactly the set the + #: cut can match. One round trip per variable sanitizer, which is as often as a caller writes one. + #: + #: On Java the collision this refuses cannot arise -- a Java ``can://`` callable id ends in + #: ``)``, so it would need a same-arity overload of a longer name -- while TypeScript's can. The + #: spelling is shared anyway: one predicate across the six acceptance domains is one thing to + #: keep in step, and the id grammar is the analyzer's to change, not this backend's. + _EDGE_VARS = "MATCH (n:JBodyNode)-[e:{rels}]->() WHERE (n.id = $callable_prefix OR n.id STARTS WITH $callable_prefix + '@' OR n.id STARTS WITH $callable_prefix + '/') RETURN collect(DISTINCT e.var) AS vars" def _taint_walk( self, diff --git a/cldk/analysis/python/codeanalyzer/codeanalyzer.py b/cldk/analysis/python/codeanalyzer/codeanalyzer.py index 7c20ff52..e199b831 100644 --- a/cldk/analysis/python/codeanalyzer/codeanalyzer.py +++ b/cldk/analysis/python/codeanalyzer/codeanalyzer.py @@ -1505,9 +1505,17 @@ def _edge_vars_in(self, callable_id: str) -> FrozenSet[str]: ``startNode`` scoping the cut itself uses, so this validates exactly the domain the cut can match. Four of the five relationship types carry no ``var``; those ``None``s are dropped, because ``resolve_sanitizers`` refuses a blank variable before it asks. + + "Under ``callable_id``" is + :func:`~cldk.analysis.commons.graphs.under_callable` and not ``startswith``, for Ruling K's + reason and to make the sentence above true: with a bare prefix test this domain would include + every variable of a sibling callable whose name merely starts with this one, and a sanitizer + naming one of those would be *accepted* here and then sever nothing in ``allow_edge``, which + is delimited. That direction only over-reports, so it cannot manufacture a false refutation, + but it hands the caller a sanitizer they believe is in force and is not. """ forward = self._sdg()[0]["forward"] - return frozenset(var for src, outs in forward.items() if src.startswith(callable_id) for labels in outs.values() for _rel, var, _prov in labels if var) + return frozenset(var for src, outs in forward.items() if under_callable(src, (callable_id,)) for labels in outs.values() for _rel, var, _prov in labels if var) def paths_between(self, src: str, dst: str, *, src_within: str, dst_within: str, depth: int | None = None, max_paths: int = DEFAULT_MAX_PATHS) -> FlowPaths: """How a value reaches another value (see :meth:`PythonAnalysisBackend.paths_between`).""" diff --git a/cldk/analysis/python/neo4j/neo4j_backend.py b/cldk/analysis/python/neo4j/neo4j_backend.py index fade7db2..86a63f22 100644 --- a/cldk/analysis/python/neo4j/neo4j_backend.py +++ b/cldk/analysis/python/neo4j/neo4j_backend.py @@ -1666,13 +1666,17 @@ def paths_between(self, src: str, dst: str, *, src_within: str, dst_within: str, #: application-scoped, by construction rather than by convention -- a callable id embeds the #: application name -- and ``test_neo4j_multi_application_scope.py`` classifies it on that basis. #: - #: A bare ``STARTS WITH``, deliberately wider than :attr:`_TAINT`'s delimited cut - #: (:func:`~cldk.analysis.commons.graphs.under_callable`): a sibling callable whose name merely - #: starts with this one contributes its edge vars here, so a variable may be *accepted* that the - #: cut cannot then match. That direction under-cuts -- a cut that severs nothing over-reports -- - #: and the one this leg must refuse is the other. One round trip per variable sanitizer, which is - #: as often as a caller writes one. - _EDGE_VARS = "MATCH (n:PyBodyNode)-[r:{rels}]->() WHERE n.id STARTS WITH $callable_prefix RETURN collect(DISTINCT r.var) AS vars" + #: The **delimited** predicate -- the three disjuncts of + #: :func:`~cldk.analysis.commons.graphs.under_callable` written in Cypher, the same shape + #: :attr:`_TAINT` gives ``$cut_callables`` and its own cut. Deliberately not a bare + #: ``STARTS WITH``, because the cut this domain exists to validate *for* is delimited: a variable + #: reachable only through an undelimited prefix (Ruling K -- a sibling callable whose name merely + #: starts with this one) would be *accepted* here and then sever nothing there, which is a + #: sanitizer the caller believes is in force and is not. Under-cutting only over-reports, so it + #: cannot manufacture a false refutation, but it is still an error the caller should have been + #: told about, and Ruling A's refusal only means something if this domain is exactly the set the + #: cut can match. One round trip per variable sanitizer, which is as often as a caller writes one. + _EDGE_VARS = "MATCH (n:PyBodyNode)-[r:{rels}]->() WHERE (n.id = $callable_prefix OR n.id STARTS WITH $callable_prefix + '@' OR n.id STARTS WITH $callable_prefix + '/') RETURN collect(DISTINCT r.var) AS vars" def _taint_walk( self, diff --git a/cldk/analysis/typescript/neo4j/neo4j_backend.py b/cldk/analysis/typescript/neo4j/neo4j_backend.py index 21c3b1e5..ca5765d1 100644 --- a/cldk/analysis/typescript/neo4j/neo4j_backend.py +++ b/cldk/analysis/typescript/neo4j/neo4j_backend.py @@ -1923,19 +1923,28 @@ def paths_between(self, src: str, dst: str, *, src_within: str, dst_within: str, #: construction rather than by convention -- a callable id embeds the application name -- and #: ``test_typescript_neo4j_multi_application_scope.py`` classifies it on that basis. #: - #: A bare ``STARTS WITH``, deliberately wider than :attr:`_TAINT`'s delimited cut - #: (:func:`~cldk.analysis.commons.graphs.under_callable`), and on TypeScript that width is not - #: hypothetical: a callable id ends in a bare member name, so ``create``'s prefix really does - #: reach every edge of ``createGuest``. A variable may therefore be *accepted* here that the cut - #: cannot then match. That direction under-cuts -- a cut that severs nothing over-reports -- and - #: the one this leg must refuse is the other. Widening the domain is also what Ruling A asks for; - #: narrowing it here would refuse a real sanitizer, which is the failure that ruling exists to - #: prevent. One round trip per variable sanitizer, which is as often as a caller writes one. + #: The **delimited** predicate -- the three disjuncts of + #: :func:`~cldk.analysis.commons.graphs.under_callable` written in Cypher, the same shape + #: :attr:`_TAINT` gives ``$cut_callables`` and its own cut. Deliberately not a bare + #: ``STARTS WITH``, because the cut this domain exists to validate *for* is delimited: a variable + #: reachable only through an undelimited prefix (Ruling K -- a sibling callable whose name merely + #: starts with this one) would be *accepted* here and then sever nothing there, which is a + #: sanitizer the caller believes is in force and is not. Under-cutting only over-reports, so it + #: cannot manufacture a false refutation, but it is still an error the caller should have been + #: told about, and Ruling A's refusal only means something if this domain is exactly the set the + #: cut can match. One round trip per variable sanitizer, which is as often as a caller writes one. + #: + #: TypeScript is where this is not hypothetical: a callable id ends in a bare member name, so + #: ``create``'s undelimited prefix really does reach every edge of ``createGuest`` -- 13 of them + #: in the committed level-4 fixture. Narrowing does not refuse a real sanitizer, which is the + #: reading Ruling A might invite: a variable outside the delimited domain is not one the cut could + #: have severed for this callable, so refusing it reports the caller's mistake instead of hiding + #: it behind a cut that does nothing. #: The **bare** ``:TSBodyNode`` and not :attr:`_TAINT`'s ``:CanNode:TSBodyNode``, per the measured #: seek rule this backend's audit enforces: a ``STARTS WITH`` is a range seek and ``:CanNode`` #: turns it into a range-seek union, while ``_TAINT`` pins its anchors by id and seeks the unique #: index. Same reason the Python twin spells it ``:PyBodyNode``. - _EDGE_VARS = "MATCH (n:TSBodyNode)-[r:{rels}]->() WHERE n.id STARTS WITH $callable_prefix RETURN collect(DISTINCT r.var) AS vars" + _EDGE_VARS = "MATCH (n:TSBodyNode)-[r:{rels}]->() WHERE (n.id = $callable_prefix OR n.id STARTS WITH $callable_prefix + '@' OR n.id STARTS WITH $callable_prefix + '/') RETURN collect(DISTINCT r.var) AS vars" def _taint_walk( self, diff --git a/tests/analysis/commons/test_lifted_helpers.py b/tests/analysis/commons/test_lifted_helpers.py index 345e292b..556aa7dc 100644 --- a/tests/analysis/commons/test_lifted_helpers.py +++ b/tests/analysis/commons/test_lifted_helpers.py @@ -403,3 +403,52 @@ def test_the_taint_query_still_formats(): def test_the_generated_taint_statement_has_not_drifted(P): backend = dict(_path_backends())[P] assert hashlib.sha256(backend._TAINT.encode()).hexdigest()[:16] == TAINT_DIGESTS[P], backend._TAINT + + +@pytest.mark.parametrize("P", ["PY", "J", "TS"]) +def test_the_sanitizer_acceptance_domain_is_delimited_on_every_graph_backend(P): + """``_EDGE_VARS`` is what Ruling A checks a variable sanitizer against, and it must be the *same* + domain ``_TAINT``'s cut can match -- the three disjuncts of + :func:`~cldk.analysis.commons.graphs.under_callable`, not a bare prefix. + + A bare ``n.id STARTS WITH $callable_prefix`` accepts a variable that only occurs in a sibling + callable whose name starts with this one (Ruling K; on TypeScript ``create`` reaches every id of + ``createGuest``), and the delimited cut then severs nothing. That direction over-reports rather + than refutes, so it cannot put a live pair into ``exhausted`` -- but it hands the caller a + sanitizer they believe is in force, and it makes ``SelectorNotInGraph`` silent on a selector that + can never do anything. Three backends spelled this three ways before this assertion existed; the + local halves are delimited in the same commit. + + Not covered by :data:`TAINT_DIGESTS`, which hashes ``_TAINT`` alone. + """ + backend = dict(_path_backends())[P] + q = backend._EDGE_VARS + assert "$callable_prefix + '@'" in q, "the '@' body-node joiner is not delimited" + assert "$callable_prefix + '/'" in q, "the '/' nested-callable joiner is not delimited" + assert ".id = $callable_prefix" in q, "the callable's own id is no longer in its domain" + + +@pytest.mark.parametrize( + "module,cls", + [ + ("cldk.analysis.python.codeanalyzer.codeanalyzer", "PyCodeanalyzer"), + ("cldk.analysis.java.codeanalyzer.codeanalyzer", "JCodeanalyzer"), + ("cldk.analysis.typescript.codeanalyzer.codeanalyzer", "TSCodeanalyzer"), + ], +) +def test_the_local_acceptance_domain_is_delimited_too(module, cls): + """The local half of the same domain, asserted on source text because on Python and Java it has + no observable witness: both id grammars end in ``)``, so a bare prefix is self-delimiting by + accident there and only TypeScript can show the collision. A tripwire is the honest guard for a + correctness property whose counterexample the corpora cannot produce -- the alternative is a test + that passes under the mutation it names. + + Pairs with + :func:`test_the_sanitizer_acceptance_domain_is_delimited_on_every_graph_backend`: six spellings, + one predicate. + """ + import inspect + + src = inspect.getsource(getattr(importlib.import_module(module), cls)._edge_vars_in) + assert "under_callable(" in src, "the acceptance domain stopped delimiting; a sibling callable's vars leak in" + assert ".startswith(" not in src, "a bare prefix accepts a variable the delimited cut cannot sever" From 74624c65e096733b3d43b8c0883a28e023a38a90 Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Thu, 10 Sep 2026 13:54:49 -0400 Subject: [PATCH 53/65] docs(taint): correct the level-gate account and pin the facade delegation The gate in `taint()` is unreachable through that method today: the two `resolve_value` calls below it address `formal_in` ports that only level 4 emits, so a shallow caller is refused by name with `SelectorNotInGraph` before the gate can speak. Measured by a new level-parametrized test rather than assumed, and documented as a backstop for the day `resolve_value` grows a fallback that answers below level 4 -- Java already has one. Also: `PythonAnalysis.taint` gets its own pin, because it is the origin the other two facades mirror and a drift there moves all three at once; the live and dataflow modules say why they call the backend rather than the facade; and two stale cross-references now name the modules that exist. --- cldk/analysis/python/backend.py | 11 ++++++++ .../python/codeanalyzer/codeanalyzer.py | 8 ++++-- cldk/analysis/typescript/backend.py | 5 +++- .../typescript/codeanalyzer/codeanalyzer.py | 9 ++++-- tests/analysis/java/test_java_taint.py | 23 +++++++-------- tests/analysis/python/test_dataflow.py | 28 +++++++++++++++++++ tests/analysis/python/test_python_taint.py | 24 ++++++++++++++++ .../analysis/python/test_python_taint_live.py | 10 +++++-- .../typescript/test_typescript_dataflow.py | 5 ++-- .../typescript/test_typescript_taint.py | 17 +++++------ 10 files changed, 110 insertions(+), 30 deletions(-) diff --git a/cldk/analysis/python/backend.py b/cldk/analysis/python/backend.py index a94c5fb9..e014b5cb 100644 --- a/cldk/analysis/python/backend.py +++ b/cldk/analysis/python/backend.py @@ -1093,6 +1093,17 @@ def taint( :meth:`_taint_walk` opens with it rather than this body asking every backend a question two of them cannot answer. + What that gate does **not** do is diagnose a shallow analysis for this method, and the reason + is the two ``resolve_value`` lines below it: the ports this surface addresses are emitted only + at level 4, so a level-2 or level-3 caller is refused *there*, by name, with + ``SelectorNotInGraph`` -- measured, in + ``test_a_shallow_analysis_is_refused_by_resolution_before_the_walks_gate``. So the gate is + unreachable through this method today, and its threshold (level 3) is one level below what a + walk over ports actually needs. Both are load-bearing the day ``resolve_value`` gains a + fallback that answers below level 4 -- Java has one, which is why Java also measures the port + lattice with ``_require_connected_ports`` -- because a level-3 batch that got past resolution + would walk a port-less SDG and certify **every** pair as ``exhausted``. + Args: sources: The values taint enters at, each ``(name, within)`` -- the addressing :meth:`resolve_value` and :meth:`paths_between` already use. diff --git a/cldk/analysis/python/codeanalyzer/codeanalyzer.py b/cldk/analysis/python/codeanalyzer/codeanalyzer.py index e199b831..984d4154 100644 --- a/cldk/analysis/python/codeanalyzer/codeanalyzer.py +++ b/cldk/analysis/python/codeanalyzer/codeanalyzer.py @@ -1452,8 +1452,12 @@ def _taint_walk( """The sanitized shortest walks, in process (see :meth:`PythonAnalysisBackend._taint_walk`). ``self._require_dataflow()`` first, per :meth:`PythonAnalysisBackend.taint`'s own note: the - level gate is a local backend's to ask, and asking it after resolution would mean a level-2 - analysis hearing "no such value" from ``resolve_value`` rather than "rebuild at level 4". + level gate is a local backend's to ask, because the graph backends have no level to measure. + It is first so that a *direct* call to this hook is diagnosed by level rather than by an empty + walk. Through ``taint()`` it never fires -- resolution runs before the walk and the + ``formal_in`` ports it addresses exist only at level 4, so a shallow caller hears + ``SelectorNotInGraph`` naming their value instead. The gate is the backstop for the day that + stops being true; see that method's docstring for why the backstop matters. One :func:`~cldk.analysis.commons.graphs.shortest_walks` call **per pair**, which is what makes ``max_paths + 1`` a per-pair cap here the way ``collect(p)[0..$cap]`` is one over diff --git a/cldk/analysis/typescript/backend.py b/cldk/analysis/typescript/backend.py index 43fd2386..59836331 100644 --- a/cldk/analysis/typescript/backend.py +++ b/cldk/analysis/typescript/backend.py @@ -967,7 +967,10 @@ def taint( **The level gate belongs to the walk, not here.** ``_require_dataflow`` is a *local* backend's method -- a graph backend has no shallow mode to guard against -- so each :meth:`_taint_walk` opens with it rather than this body asking every backend a question two - of them cannot answer. + of them cannot answer. It also never fires through this method: resolution runs first and the + ports it addresses exist only at level 4, so a shallow caller hears ``SelectorNotInGraph`` + naming their value. :meth:`~cldk.analysis.python.backend.PythonAnalysisBackend.taint` carries + the full argument, including why an unreachable backstop is still worth having. Args: sources: The values taint enters at, each ``(name, within)`` -- the addressing diff --git a/cldk/analysis/typescript/codeanalyzer/codeanalyzer.py b/cldk/analysis/typescript/codeanalyzer/codeanalyzer.py index f3812469..27fc099d 100644 --- a/cldk/analysis/typescript/codeanalyzer/codeanalyzer.py +++ b/cldk/analysis/typescript/codeanalyzer/codeanalyzer.py @@ -1352,9 +1352,12 @@ def _taint_walk( """The sanitized shortest walks, in process (see :meth:`TSAnalysisBackend._taint_walk`). ``self._require_dataflow()`` first, per Ruling F and :meth:`TSAnalysisBackend.taint`'s own - note: the level gate is a local backend's to ask, and asking it after resolution would mean - a level-2 analysis hearing "no such value" from ``resolve_value`` rather than "rebuild at - level 4". + note: the level gate is a local backend's to ask, because the graph backends have no level to + measure. It is first so that a *direct* call to this hook is diagnosed by level rather than by + an empty walk. Through ``taint()`` it never fires -- resolution runs before the walk and the + ports it addresses exist only at level 4, so a shallow caller hears ``SelectorNotInGraph`` + naming their value instead. Python's :meth:`~cldk.analysis.python.backend.PythonAnalysisBackend.taint` + docstring carries the whole argument, including why the unreachable gate is still worth having. One :func:`~cldk.analysis.commons.graphs.shortest_walks` call **per pair**, which is what makes ``max_paths + 1`` a per-pair cap here the way ``collect(p)[0..$cap]`` is one over diff --git a/tests/analysis/java/test_java_taint.py b/tests/analysis/java/test_java_taint.py index ce2c217f..bb7e465d 100644 --- a/tests/analysis/java/test_java_taint.py +++ b/tests/analysis/java/test_java_taint.py @@ -27,13 +27,14 @@ with a typo hears about their typo and not about a gap in the analysis. The last group is the **graph** backend's half of the walk, over a fake driver rather than a server, -and it is worth saying plainly what that can and cannot prove. There is no live Java graph in this -repo's verification set, so what is pinned is the statement text ``sdg_taint_query`` built, the -parameters bound to it (``cap = max_paths + 1``, the application scope prefix, the two cut lists) and -the translation of canned rows into witnesses through the same ``_body_slice_node`` the slice uses. -It proves nothing about what Cypher *does* with that statement -- that the cut inlines into -``ShortestPath``, that ``allShortestPaths`` returns what the design assumes. Only -``tests/analysis/python/test_python_taint_live.py`` proves that, and only for Python. +and it is worth saying plainly what that can and cannot prove. What is pinned here is the statement +text ``sdg_taint_query`` built, the parameters bound to it (``cap = max_paths + 1``, the application +scope prefix, the two cut lists) and the translation of canned rows into witnesses through the same +``_body_slice_node`` the slice uses. It proves nothing about what Cypher *does* with that statement +-- that the cut inlines into ``ShortestPath``, that ``allShortestPaths`` returns what the design +assumes. ``tests/analysis/java/test_java_taint_live.py`` proves that, on daytrader8, and it skips +whenever no server at ``CLDK_TEST_JTAINT_NEO4J_URI`` holds that application -- which is why this +module exists rather than being folded into it: this one always runs. """ import pytest @@ -73,10 +74,10 @@ class _Recording(JavaAnalysisBackend): ``__abstractmethods__`` is cleared below rather than the other fifty-odd methods being stubbed: what is under test is one concrete body, and anything else this backend could answer would only - be a way for these tests to fail for an unrelated reason. ``_ports_carry_dependence`` and - ``_ports_carry_dependence`` stays a property because the real one is (a plain attribute would - not shadow a data descriptor at all), and ``_application_name`` is a bare string because the real - property reads an application view this fake does not have. + be a way for these tests to fail for an unrelated reason. ``_ports_carry_dependence`` stays a + property because the real one is -- a plain attribute would not shadow a data descriptor at all, + so a fake that used one would read the real property and hit the missing application view -- + while ``_application_name`` is a bare string precisely because the real property reads that view. """ _application_name = "acme" diff --git a/tests/analysis/python/test_dataflow.py b/tests/analysis/python/test_dataflow.py index 2dbe7e8c..51916b9c 100644 --- a/tests/analysis/python/test_dataflow.py +++ b/tests/analysis/python/test_dataflow.py @@ -1345,6 +1345,34 @@ def taint_l4(two_route_project, tmp_path_factory) -> PyCodeanalyzer: TAINT_PAIR = ([("raw", "scrub")], [("cleaned", "run_query")]) +@pytest.fixture(scope="module", params=[AnalysisLevel.call_graph, AnalysisLevel.program_dependency_graph], ids=["l2", "l3"]) +def taint_shallow(request, two_route_project, tmp_path_factory) -> PyCodeanalyzer: + """The taint project at each level below the one its ports need.""" + return _backend(two_route_project, tmp_path_factory.mktemp(f"cache-shallow-{request.param.name}"), request.param) + + +def test_a_shallow_analysis_is_refused_by_resolution_before_the_walks_gate(taint_shallow): + """What a below-level-4 caller of ``taint()`` actually hears, measured rather than assumed. + + ``_taint_walk`` opens with ``_require_dataflow()``, and + :func:`test_the_local_taint_walk_opens_with_the_same_level_gate` shows it firing -- but only + because it calls the hook directly. Through ``taint()`` the two ``resolve_value`` calls come + first, and the ``formal_in`` ports they address are emitted only at level 4, so both levels below + it are refused by *name* instead. Level 3 is included deliberately: it satisfies the gate and + still cannot resolve, which is the whole reason the gate's threshold is not what protects this + surface. + + So the gate is unreachable through the public method today. It stays because resolution's + strictness is not a contract -- Java's ``resolve_value`` already has a parameter-list fallback + that answers below level 4 -- and a level-3 batch that got past resolution would walk a port-less + SDG and put every pair in ``exhausted``. + """ + with pytest.raises(SelectorNotInGraph) as e: + taint_shallow.taint(TAINT_SOURCES, TAINT_SINKS) + assert "raw" in str(e.value), "refused by name, not by level" + assert "program_dependency_graph" not in str(e.value), "a level diagnosis here would mean the gate had fired" + + def test_the_local_walk_finds_the_measured_witnesses_and_refutes_nothing(taint_l4): """The anchor the rest of this group narrows: 2 + 2 + 1 + 1 witnesses over four pairs, each crossing two call boundaries in both directions. A walk that stopped at a call boundary would diff --git a/tests/analysis/python/test_python_taint.py b/tests/analysis/python/test_python_taint.py index 46c84011..00f9280c 100644 --- a/tests/analysis/python/test_python_taint.py +++ b/tests/analysis/python/test_python_taint.py @@ -24,10 +24,13 @@ conditions of ``exhausted``. """ +import inspect + import pytest from cldk.analysis.commons.results import Diagnostic, FlowPath, PathHop, SliceNode from cldk.analysis.python.backend import PythonAnalysisBackend +from cldk.analysis.python.python_analysis import PythonAnalysis from cldk.utils.exceptions.exceptions import SelectorNotInGraph @@ -289,3 +292,24 @@ def test_the_two_walk_hooks_are_abstract_methods(): with pytest.raises(TypeError) as err: type("_NoWalk", (PythonAnalysisBackend,), {})() assert "_taint_walk" in str(err.value) and "_edge_vars_in" in str(err.value) + + +def test_the_facade_passes_the_call_through_unchanged(): + """``PythonAnalysis.taint`` is one delegating line, and Python is the facade the other two mirror + signature-for-signature (``test_typescript_public_surface`` asserts that mirror against *this* + class). So a drift here moves all three at once and the mirror test stays green -- which is why + the origin needs its own pin, and why this asserts the arguments *arrive*, not just the signature. + + A bare object for the backend, not a ``_Recording``: what is under test is the delegation, and a + real backend would only add a way for it to fail for another reason. + """ + seen = {} + facade = PythonAnalysis.__new__(PythonAnalysis) + facade.backend = type("_B", (), {"taint": lambda _s, *a, **k: seen.update(args=a, kwargs=k) or "verdict"})() + assert facade.taint([("x", "f")], [("y", "g")], ["scrub"], depth=3, max_paths=2) == "verdict" + assert seen == {"args": ([("x", "f")], [("y", "g")], ["scrub"]), "kwargs": {"depth": 3, "max_paths": 2}} + assert str(inspect.signature(PythonAnalysis.taint)) == ( + "(self, sources: 'Sequence[Tuple[str, str]]', sinks: 'Sequence[Tuple[str, str]]', " + "sanitizers: 'Sequence[Tuple[str, str] | str]' = (), *, depth: 'int | None' = None, " + "max_paths: 'int' = 10) -> 'TaintResult'" + ) diff --git a/tests/analysis/python/test_python_taint_live.py b/tests/analysis/python/test_python_taint_live.py index e1d16e2d..97017358 100644 --- a/tests/analysis/python/test_python_taint_live.py +++ b/tests/analysis/python/test_python_taint_live.py @@ -121,9 +121,13 @@ def _fixture_graph_present() -> bool: CAP_PAIR = ([("raw", "scrub")], [("cleaned", "run_query")]) -# ``.backend`` and not the facade: ``taint()`` is a backend method until the facade method lands -# (leg 4b Task 8 -- ``PythonAnalysis`` delegates one accessor at a time, and this is the last one), -# and a suite that waited for the delegation would leave the two walks untested in between. +# ``.backend`` and not the facade, now that ``PythonAnalysis.taint`` exists (it landed in leg 4b's +# Task 8): the claim these fixtures exist to support is that the two *walks* agree, and +# :func:`test_both_backends_agree_on_the_witnesses_and_on_the_refutations` can only make it by +# calling the same method on both. A facade wraps one backend, so the pair has to be two backends; +# putting the facade on one side would compare a delegation against a direct call. What the facade +# adds over what is called here is one ``self.backend.taint(...)`` line, pinned by +# ``test_the_facade_passes_the_call_through_unchanged`` in ``tests/analysis/python/test_python_taint.py``. @pytest.fixture(scope="module") def graph(): """The fixture graph, attached. Module-scoped: attaching runs three probes and a module load.""" diff --git a/tests/analysis/typescript/test_typescript_dataflow.py b/tests/analysis/typescript/test_typescript_dataflow.py index 703912a8..c11ec5c4 100644 --- a/tests/analysis/typescript/test_typescript_dataflow.py +++ b/tests/analysis/typescript/test_typescript_dataflow.py @@ -312,8 +312,9 @@ def test_every_predicate_and_path_accessor_type_checks_depth(ts): # alert on a live flow. Every assertion below is therefore two-sided: the cut severed what it named # *and* left alone what it did not. # -# No graph and no container; only the a4 fixture the rest of this file already uses. ``taint`` is -# not on the facade yet, so the backend is called directly. Every number was derived by walking +# No graph and no container; only the a4 fixture the rest of this file already uses. The backend is +# called directly even though ``TypeScriptAnalysis.taint`` now exists, because that is what the rest +# of this file does and the walk is the subject. Every number was derived by walking # ``analysis.json``'s ``ddg``/``cdg``/``summary`` lists and the application's ``param_in``/ # ``param_out`` overlays through ``shortest_walks`` -- never by running ``_taint_walk`` and copying # what it printed. diff --git a/tests/analysis/typescript/test_typescript_taint.py b/tests/analysis/typescript/test_typescript_taint.py index 7f96ba43..f3b73547 100644 --- a/tests/analysis/typescript/test_typescript_taint.py +++ b/tests/analysis/typescript/test_typescript_taint.py @@ -23,14 +23,15 @@ rather than raised; a bounded ``depth`` yields no ``exhausted`` pair), and the three membership conditions of ``exhausted``. -The last group is the **graph** backend's half of the walk, over a fake driver rather than a server. -There is no live TypeScript graph in this repo's verification set, so what a fake driver can prove is -bounded and worth saying plainly: the statement text is the one ``sdg_taint_query`` built, the -parameters are bound as the contract says (``cap = max_paths + 1``, the application scope, the two -cut lists), and a row is translated into a witness the way ``paths_between``'s rows are. It proves -nothing about what Cypher *does* with that statement -- that the cut inlines into ``ShortestPath``, -that ``allShortestPaths`` returns what the design assumes. Only -``tests/analysis/python/test_python_taint_live.py`` proves that, and only for Python. +The last group is the **graph** backend's half of the walk, over a fake driver rather than a server, +so what it can prove is bounded and worth saying plainly: the statement text is the one +``sdg_taint_query`` built, the parameters are bound as the contract says (``cap = max_paths + 1``, +the application scope, the two cut lists), and a row is translated into a witness the way +``paths_between``'s rows are. It proves nothing about what Cypher *does* with that statement -- that +the cut inlines into ``ShortestPath``, that ``allShortestPaths`` returns what the design assumes. +``tests/analysis/typescript/test_typescript_taint_live.py`` proves that, on superset-frontend, and it +skips whenever no server at ``CLDK_TEST_TSTAINT_NEO4J_URI`` holds that application -- which is why +this module exists rather than being folded into it: this one always runs. """ import pytest From 0d119d30ef78346752f3f18025c9d26a5c3a27d0 Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Thu, 10 Sep 2026 19:13:39 -0400 Subject: [PATCH 54/65] test(java): measure the param-edge var label instead of asserting it The 3.1.2 param-edge assertion constructed a JParamEdge and read its own attributes back, so it proved that Pydantic keeps a field it was handed and never reached codeanalyzer.py:519, the line that writes the var into the adjacency label. Replace it with a payload built from the a4 fixture by addition -- a var on each of its 355 param_in/param_out edges, named after the formal position its own endpoint already spells -- the mirror of how _without_port_crossings is built by subtraction, and for the same reason: the shape under test comes from the real payload, so a regeneration that changes what is added fails the count. That drives the real _sdg() traversal and pins both consumers: the exact label Counter per formal name, _edge_vars_in's delta over sell's crossings, and an end-to-end scoped cut that takes the taint answer from 9 paths to 3 while the same name under another scope cuts nothing. --- tests/analysis/java/test_java_dataflow.py | 95 ++++++++++++++++++++--- 1 file changed, 83 insertions(+), 12 deletions(-) diff --git a/tests/analysis/java/test_java_dataflow.py b/tests/analysis/java/test_java_dataflow.py index 6b9f0db8..3a6fd3e0 100644 --- a/tests/analysis/java/test_java_dataflow.py +++ b/tests/analysis/java/test_java_dataflow.py @@ -36,6 +36,7 @@ import inspect import json +import re from collections import Counter import pytest @@ -46,7 +47,7 @@ from cldk.analysis.java.java_analysis import JavaAnalysis from cldk.analysis.python.backend import PythonAnalysisBackend from cldk.analysis.python.python_analysis import PythonAnalysis -from cldk.models.java.models import JCdgEdge, JCfgEdge, JDdgEdge, JParamEdge +from cldk.models.java.models import JCdgEdge, JCfgEdge, JDdgEdge from cldk.utils.exceptions import AmbiguousName, SelectorNotInGraph from cldk.utils.exceptions.exceptions import CodeanalyzerExecutionException, CodeanalyzerUsageException @@ -686,7 +687,40 @@ def test_taint_refuses_while_the_port_lattice_carries_no_dependence_edge(disconn disconnected.taint(TAINT_SOURCES, TAINT_SINKS) -def test_a_java_param_edge_carries_the_variable_the_analyzer_put_on_it(ref): +def _with_param_vars(payload: str) -> str: + """The same fixture with a ``var`` on every ``param_in``/``param_out`` edge -- the shape + codeanalyzer-java 3.1.2 (codeanalyzer-java#250) emits and this fixture, at 3.1.0, does not. + + Built by **addition** the way :func:`_without_port_crossings` is built by subtraction, and for + the same reason: the shape under test has to come from the real payload rather than from a + hand-written one, so a regeneration that changes what is being added fails the count below. + + The name written on each edge is the formal position its own endpoint already spells -- + ``@formal_in:0`` becomes ``p0``, ``@formal_out`` becomes ``ret`` -- so a label that reached the + adjacency off the *wrong* edge shows up as the wrong name rather than as a name that is merely + present. All 355 endpoints spell one, asserted rather than assumed. + """ + payload_json = json.loads(payload) + application = payload_json["application"] + named = 0 + for edge in application["param_in"]: + edge["var"] = "p" + re.search(r"@formal_in:(\d+)$", edge["dst"]).group(1) + named += 1 + for edge in application["param_out"]: + assert edge["src"].endswith("@formal_out"), f"a param_out edge starting somewhere other than a formal_out: {edge['src']}" + edge["var"] = "ret" + named += 1 + assert named == 355, f"the 3.1.2 shape names all 355 param edges, not {named}" + return json.dumps(payload_json) + + +@pytest.fixture(scope="module") +def param_vars(analysis_json_a4): + """The local backend over a payload shaped like codeanalyzer-java 3.1.2's.""" + return _local(_with_param_vars(analysis_json_a4)) + + +def test_a_java_param_edge_carries_the_variable_the_analyzer_put_on_it(ref, param_vars): """``J_PARAM_IN``/``J_PARAM_OUT`` must reach the adjacency with whatever ``var`` the payload put on them. This backend hardcoded ``None``, which was true until codeanalyzer-java 3.1.2 (codeanalyzer-java#250) added the property, and became a lie that cost two things: @@ -695,17 +729,54 @@ def test_a_java_param_edge_carries_the_variable_the_analyzer_put_on_it(ref): ``var == c["var"]`` could never match a param edge, so a scoped variable cut was structurally incapable of cutting at a call boundary. - **This fixture cannot witness the fix.** It was emitted by 3.1.0, whose 258 ``param_in`` and 97 - ``param_out`` edges carry no ``var`` key at all, so the assertion is what the *plumbing* does - with an edge that has one -- built here rather than measured, and honest about which. Whether the - pinned analyzer writes ``var`` in practice is unverified in this repo: there is no jar and no JVM. + **This fixture cannot witness the fix**, so the fix is witnessed on a payload built from it: + a4 was emitted by 3.1.0, whose 258 ``param_in`` and 97 ``param_out`` edges carry no ``var`` key + at all, and :func:`_with_param_vars` writes the ones 3.1.2 would. What runs is the real + ``getattr(e, "var", None)`` at ``JCodeanalyzer._sdg``, not a constructed label: the count and the + name of every param edge in the adjacency come back out of the traversal, and the consumer the + hardcoded ``None`` blinded -- ``_edge_vars_in`` -- gains exactly the two crossing names ``sell`` + scopes and nothing else. Whether the pinned analyzer writes ``var`` in practice is unverified in + this repo: there is no jar and no JVM. """ - adjacency = ref._sdg()[0]["forward"] - params = [(rel, var) for outs in adjacency.values() for labels in outs.values() for rel, var, _prov in labels if rel.startswith("J_PARAM")] - assert len(params) == 355, "a4 was emitted by 3.1.0: 258 param_in + 97 param_out, none carrying a var" - assert all(var is None for _rel, var in params), "this fixture's param edges carry no var; the next assertion is the plumbing, not the data" - edge = JParamEdge(src="a", dst="b", var="conn") - assert (getattr(edge, "var", None), tuple(getattr(edge, "prov", None) or ())) == ("conn", ()), "the label the adjacency stores for a 3.1.2 param edge" + old_labels = [(rel, var) for outs in ref._sdg()[0]["forward"].values() for labels in outs.values() for rel, var, _prov in labels if rel.startswith("J_PARAM")] + assert len(old_labels) == 355, "a4 was emitted by 3.1.0: 258 param_in + 97 param_out, none carrying a var" + assert all(var is None for _rel, var in old_labels), "this fixture's param edges carry no var; what follows is asserted on the 3.1.2 shape" + + new_labels = [(rel, var) for outs in param_vars._sdg()[0]["forward"].values() for labels in outs.values() for rel, var, _prov in labels if rel.startswith("J_PARAM")] + assert Counter(new_labels) == { + ("J_PARAM_IN", "p0"): 157, + ("J_PARAM_IN", "p1"): 83, + ("J_PARAM_IN", "p2"): 8, + ("J_PARAM_IN", "p3"): 6, + ("J_PARAM_IN", "p4"): 4, + ("J_PARAM_OUT", "ret"): 97, + }, "every param edge reaches the adjacency under its own formal's name" + + scope = ref.resolve_callable(SELL).ref + assert param_vars._edge_vars_in(scope) - ref._edge_vars_in(scope) == {"p0", "p1"}, "sell's 12 crossings bind two distinct formals, and a sanitizer can now name either" + + +def test_a_java_variable_cut_severs_a_call_boundary_and_only_the_scope_that_named_it(ref, param_vars): + """The payoff, end to end: the scoped variable cut over a hop that *is* a call boundary. On the + 3.1.0 shape ``("p0", BUY)`` is refused as nonexistent -- ``_edge_vars_in`` cannot see a name that + reached the adjacency as ``None`` -- which is Ruling A refusing a real dataflow variable, the + exact failure the hardcoded ``None`` caused. On the 3.1.2 shape the same cut severs ``buy``'s + ``J_PARAM_IN`` crossings and takes both of its pairs from 6 witnesses to ``exhausted``. + + ``completeOrder``'s 3 witnesses are untouched, and cutting ``p0`` *under* ``completeOrder`` + changes nothing at all: ``allow_edge`` reads the hop's start node, so a cut severs only the + callable the caller scoped it to. That is what separates a scoped cut from a cut on every param + hop in the application -- and over-cutting is the one error this instrument must not make.""" + with pytest.raises(SelectorNotInGraph, match="'p0'"): + ref.taint(TAINT_SOURCES, TAINT_SINKS, sanitizers=[("p0", BUY)], max_paths=10) + + assert len(param_vars.taint(TAINT_SOURCES, TAINT_SINKS, max_paths=10).paths) == 9, "naming the param vars changes no unsanitized answer" + cut = param_vars.taint(TAINT_SOURCES, TAINT_SINKS, sanitizers=[("p0", BUY)], max_paths=10) + assert len(cut.paths) == 3 and {p.hops[0].frm.callable for p in cut.paths} == {COMPLETE_ORDER} + assert cut.exhausted == [("orderProcessingMode", "inGlobalTxn"), ("orderProcessingMode", "conn")] and cut.complete is True + + elsewhere = param_vars.taint(TAINT_SOURCES, TAINT_SINKS, sanitizers=[("p0", COMPLETE_ORDER)], max_paths=10) + assert len(elsewhere.paths) == 9 and elsewhere.exhausted == [], "the same name under another scope cuts nothing" def test_the_local_java_walk_finds_the_measured_witnesses_and_refutes_nothing(ref): From 4061dca6f8fbe2b9a8f66536cc6e4cff8ba8b6cb Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Thu, 10 Sep 2026 19:13:39 -0400 Subject: [PATCH 55/65] docs(taint): name sanitizer examples after callables the application declares Every bare-str sanitizer example named a library function -- html.escape, StringEscapeUtils.escapeHtml4, DOMPurify.sanitize, encodeURIComponent. The bare shape is resolved with resolve_callable, whose candidate domain is the callables the analyzer emitted for the application under analysis, so a reader following the example verbatim learns the shape from a name that cannot select anything in it. The examples now name a callable the application itself declares, and the prose says where the library function belongs: it is what the application's own wrapper calls, not the selector. --- cldk/analysis/commons/resolve.py | 6 ++++-- cldk/analysis/java/backend.py | 8 +++++--- cldk/analysis/java/java_analysis.py | 5 +++-- cldk/analysis/python/backend.py | 6 ++++-- cldk/analysis/python/python_analysis.py | 14 ++++++++------ cldk/analysis/typescript/backend.py | 6 ++++-- cldk/analysis/typescript/typescript_analysis.py | 11 ++++++----- docs/agent-api-reference.md | 13 +++++++------ 8 files changed, 41 insertions(+), 28 deletions(-) diff --git a/cldk/analysis/commons/resolve.py b/cldk/analysis/commons/resolve.py index 9c1960b0..fef21e2f 100644 --- a/cldk/analysis/commons/resolve.py +++ b/cldk/analysis/commons/resolve.py @@ -424,8 +424,10 @@ def resolve_sanitizers( it is off-limits), a ``(name, within)`` pair cuts a **variable**, scoped to the callable ``within`` names. They are different mechanisms for a reason: a validating guard (``if not re.match(...): abort``) never sits on the data path, so only a variable cut severs - it; a transforming sanitizer (``html.escape(x)``) does sit on the path and is naturally named - as the function it is. + it; a transforming sanitizer (the application's own ``escape_html(x)`` wrapper) does sit on the + path and is naturally named as the callable it is -- and since the bare shape goes through + :func:`resolve_callable`, the name it needs is the wrapper's, not that of the library function + the wrapper delegates to. **The shape decides which resolver runs, and neither is a fallback for the other.** A bare name that :func:`resolve_callable` cannot resolve raises -- it is never retried as a variable diff --git a/cldk/analysis/java/backend.py b/cldk/analysis/java/backend.py index db192997..3fb33afb 100644 --- a/cldk/analysis/java/backend.py +++ b/cldk/analysis/java/backend.py @@ -2020,9 +2020,11 @@ def taint( that rots, and this accessor is the mechanism. **A sanitizer is two mechanisms wearing one word**, told apart by shape. A bare ``str`` cuts - a *callable* on the path -- what a transforming sanitizer (``StringEscapeUtils.escapeHtml4``, - a parameterised ``PreparedStatement`` bind) is, since it sits on the data path and is - naturally named as the function it is. A + a *callable* on the path -- what a transforming sanitizer is, since it sits on the data path + and is naturally named as the method it is. The name is resolved with + :meth:`resolve_callable`, so it is the method *this application* declares around + ``StringEscapeUtils.escapeHtml4`` or a parameterised ``PreparedStatement`` bind, not the + library call that method delegates to. A ``(name, within)`` pair cuts a *variable* inside that callable, which is the only thing that severs a *validating* guard, because a guard never appears on the data path at all. Both cuts are applied **inside** the search rather than to the rows it returns, so what comes back diff --git a/cldk/analysis/java/java_analysis.py b/cldk/analysis/java/java_analysis.py index 69fb83a8..75e3d587 100644 --- a/cldk/analysis/java/java_analysis.py +++ b/cldk/analysis/java/java_analysis.py @@ -1667,7 +1667,7 @@ def taint( r = java.taint( sources=[("userID", "TradeAppServlet.doPost")], sinks=[("sql", "TradeDirect.getOrders")], - sanitizers=["StringEscapeUtils.escapeHtml4", ("checked", "TradeAppServlet.doPost")], + sanitizers=["TradeAppServlet.escapeUserID", ("checked", "TradeAppServlet.doPost")], ) for path in r.paths: print(" -> ".join(h.to.name for h in path.hops)) @@ -1685,7 +1685,8 @@ def taint( stands on any pair in the result: one blocked pair voids the whole batch's ``exhausted``. **Sources, sinks and sanitizers are the caller's to supply** — no framework catalogue ships - here. A bare ``str`` cuts a *callable* on the path (a transforming sanitizer); a + here. A bare ``str`` cuts a *callable* on the path (a transforming sanitizer, resolved with + :meth:`resolve_callable` and so named as a callable *this application* declares); a ``(name, within)`` pair cuts a *variable* inside that callable, which is the only thing that severs a *validating* guard, since a guard never sits on the data path. Both cuts are applied inside the search, so the result is the shortest **unsanitized** route. diff --git a/cldk/analysis/python/backend.py b/cldk/analysis/python/backend.py index e014b5cb..085590e0 100644 --- a/cldk/analysis/python/backend.py +++ b/cldk/analysis/python/backend.py @@ -1066,8 +1066,10 @@ def taint( that rots, and this accessor is the mechanism. **A sanitizer is two mechanisms wearing one word**, told apart by shape. A bare ``str`` cuts - a *callable* on the path -- what a transforming sanitizer (``html.escape``, ``shlex.quote``) - is, since it sits on the data path and is naturally named as the function it is. A + a *callable* on the path -- what a transforming sanitizer is, since it sits on the data path + and is naturally named as the function it is. The name is resolved with + :meth:`resolve_callable`, so it is the wrapper *this application* owns around ``html.escape`` + or ``shlex.quote``, not the library function that wrapper delegates to. A ``(name, within)`` pair cuts a *variable* inside that callable, which is the only thing that severs a *validating* guard, because a guard never appears on the data path at all. Both cuts are applied **inside** the search rather than to the rows it returns, so what comes back diff --git a/cldk/analysis/python/python_analysis.py b/cldk/analysis/python/python_analysis.py index 3f846ee1..834a09db 100644 --- a/cldk/analysis/python/python_analysis.py +++ b/cldk/analysis/python/python_analysis.py @@ -1410,7 +1410,7 @@ def taint( r = py.taint( sources=[("invoice_id", "PaymentPortal.invoice_transaction")], sinks=[("query", "AccountMove._execute")], - sanitizers=["html.escape", ("checked_id", "PaymentPortal.invoice_transaction")], + sanitizers=["PaymentPortal._sanitize_id", ("checked_id", "PaymentPortal.invoice_transaction")], ) for path in r.paths: # the witnesses print(" -> ".join(h.to.name for h in path.hops)) @@ -1436,11 +1436,13 @@ def taint( **Sources, sinks and sanitizers are yours to supply.** This SDK ships no framework catalogue and derives no default set: a per-language vocabulary of taint sources is policy that rots, and this is the mechanism. A sanitizer is two things wearing one word, told - apart by shape — a bare ``str`` cuts a *callable* on the path (a transforming sanitizer, - ``html.escape``), and a ``(name, within)`` pair cuts a *variable* inside that callable, - which is the only thing that severs a *validating* guard, because a guard never sits on the - data path at all. Both cuts are applied inside the search, so what comes back is the - shortest **unsanitized** route rather than a filtered list of sanitized ones. + apart by shape — a bare ``str`` cuts a *callable* on the path: a transforming sanitizer, + named as the wrapper *in this application* that calls ``html.escape``, because the bare + shape is resolved with :meth:`resolve_callable`. A ``(name, within)`` pair cuts a *variable* + inside that callable, which is the only thing that severs a *validating* guard, because a + guard never sits on the data path at all. Both cuts are applied inside the search, so what + comes back is the shortest **unsanitized** route rather than a filtered list of sanitized + ones. Args: sources: The values taint enters at, each ``(name, within)`` — the addressing diff --git a/cldk/analysis/typescript/backend.py b/cldk/analysis/typescript/backend.py index 59836331..9a5d2df1 100644 --- a/cldk/analysis/typescript/backend.py +++ b/cldk/analysis/typescript/backend.py @@ -937,8 +937,10 @@ def taint( that rots, and this accessor is the mechanism. **A sanitizer is two mechanisms wearing one word**, told apart by shape. A bare ``str`` cuts - a *callable* on the path -- what a transforming sanitizer (``encodeURIComponent``, ``DOMPurify.sanitize``) - is, since it sits on the data path and is naturally named as the function it is. A + a *callable* on the path -- what a transforming sanitizer is, since it sits on the data path + and is naturally named as the function it is. The name is resolved with + :meth:`resolve_callable`, so it is the wrapper *this application* owns around + ``encodeURIComponent`` or ``DOMPurify.sanitize``, not the library function it delegates to. A ``(name, within)`` pair cuts a *variable* inside that callable, which is the only thing that severs a *validating* guard, because a guard never appears on the data path at all. Both cuts are applied **inside** the search rather than to the rows it returns, so what comes back diff --git a/cldk/analysis/typescript/typescript_analysis.py b/cldk/analysis/typescript/typescript_analysis.py index e21fbfa7..96c16c12 100644 --- a/cldk/analysis/typescript/typescript_analysis.py +++ b/cldk/analysis/typescript/typescript_analysis.py @@ -758,7 +758,7 @@ def taint( r = ts.taint( sources=[("userInput", "SearchBar.onChange")], sinks=[("html", "ResultList.render")], - sanitizers=["DOMPurify.sanitize", ("validated", "SearchBar.onChange")], + sanitizers=["SearchBar.sanitizeQuery", ("validated", "SearchBar.onChange")], ) for path in r.paths: print(" -> ".join(h.to.name for h in path.hops)) @@ -776,10 +776,11 @@ def taint( stands on any pair in the result: one blocked pair voids the whole batch's ``exhausted``. **Sources, sinks and sanitizers are the caller's to supply** — no framework catalogue ships - here. A bare ``str`` cuts a *callable* on the path (a transforming sanitizer, - ``encodeURIComponent``); a ``(name, within)`` pair cuts a *variable* inside that callable, - which is the only thing that severs a *validating* guard, since a guard never sits on the - data path. Both cuts are applied inside the search, so the result is the shortest + here. A bare ``str`` cuts a *callable* on the path: a transforming sanitizer, named as the + wrapper *in this application* that calls ``encodeURIComponent``, because the bare shape is + resolved with :meth:`resolve_callable`. A ``(name, within)`` pair cuts a *variable* inside + that callable, which is the only thing that severs a *validating* guard, since a guard never + sits on the data path. Both cuts are applied inside the search, so the result is the shortest **unsanitized** route. Every hop's provenance is ``reaching-defs``, as on :meth:`paths_between`, so a TypeScript diff --git a/docs/agent-api-reference.md b/docs/agent-api-reference.md index 40afa0e9..460deb89 100644 --- a/docs/agent-api-reference.md +++ b/docs/agent-api-reference.md @@ -759,7 +759,7 @@ Names in, names out. No `can://` URIs, no ordinals — you say `"invoice_id"`, n | `paths_between(src, dst, src_within=, dst_within=, depth=None, max_paths=10)` | how one reaches the other | `py.paths_between("invoice_id", "invoice_ids", src_within="PaymentPortal.invoice_transaction", dst_within="PaymentPortal._process_transaction")` | | `flows_to_call(src, callee, within=, depth=None)` | reaches **any call to** X | `py.flows_to_call("invoice_id", "_process_transaction", within="PaymentPortal.invoice_transaction")` | | `flows_to_argument(src, callee, arg, within=, depth=None)` | reaches X's **named argument** | `py.flows_to_argument("invoice_id", "_process_transaction", arg="invoice_ids", within="…invoice_transaction")` | -| `taint(sources, sinks, sanitizers=(), depth=None, max_paths=10)` | which of m sources reach which of n sinks, and which pairs are **refuted** | `py.taint([("invoice_id", "PaymentPortal.invoice_transaction")], [("query", "AccountMove._execute")], sanitizers=["html.escape"])` | +| `taint(sources, sinks, sanitizers=(), depth=None, max_paths=10)` | which of m sources reach which of n sinks, and which pairs are **refuted** | `py.taint([("invoice_id", "PaymentPortal.invoice_transaction")], [("query", "AccountMove._execute")], sanitizers=["PaymentPortal._sanitize_id"])` | | `reaches(src, dst, depth=None)` | is there a call path | `py.reaches("invoice_transaction", "AccountMove.write")` | | `call_paths_between(src, dst, depth=None, max_paths=10)` | show the call chains | `py.call_paths_between("PaymentPortal.invoice_transaction", "AccountMove.write")` | | `resolve_callable(name, in_class=, in_module=)` | what a name means, before asking | `py.resolve_callable("write", in_class="AccountMove").callable` | @@ -793,11 +793,12 @@ one traversal and reports each pair three ways: a witness in `paths`, a **refuta **Sources, sinks and sanitizers are yours to supply.** The SDK ships no framework catalogue and derives no default set — a per-language vocabulary of taint sources is policy that rots, and this is the mechanism. A sanitizer is two things wearing one word, told apart by **shape**: a bare `str` cuts -a *callable* on the path (a transforming sanitizer, `html.escape`), and a `(name, within)` pair cuts a -*variable* inside that callable, which is the only thing that severs a *validating* guard — a guard -never sits on the data path at all, it reads the value and throws. Both cuts are applied inside the -search, so what comes back is the shortest **unsanitized** route rather than a filtered list of -sanitized ones. Measured on superset-frontend: `sanitizeHtmlIfNeeded`'s `htmlString` reaches two +a *callable* on the path — a transforming sanitizer, named as the wrapper *in this application* that +calls `html.escape`, because the bare shape is resolved with `resolve_callable` — and a +`(name, within)` pair cuts a *variable* inside that callable, which is the only thing that severs a +*validating* guard — a guard never sits on the data path at all, it reads the value and throws. Both +cuts are applied inside the search, so what comes back is the shortest **unsanitized** route rather +than a filtered list of sanitized ones. Measured on superset-frontend: `sanitizeHtmlIfNeeded`'s `htmlString` reaches two sinks; cutting one callable refutes that pair and leaves the sibling witnessed in the same result. **A slice is a set; a path is a sequence.** `slice_backward` answers "what is in scope"; a 10k-node From 119f344a09fec892a82533f6cf2d24cc829e2d2e Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Thu, 10 Sep 2026 19:19:57 -0400 Subject: [PATCH 56/65] test(java): correct the param-edge var shape against a real 3.1.2 run The helper claimed its fabricated names were the shape 3.1.2 emits, and the test said whether the pinned analyzer writes var at all was unverifiable here. Both were wrong: the pinned wheel carries the jar and jdk4py the JVM, so the pin is runnable in this repo, and running it at -a 4 over the same daytrader8 sources names every one of 1,932 param_in and 909 param_out edges. What it names them is not what the helper wrote. A param_in var is the actual argument's name (tSIA, volume), which a 3.1.0 payload cannot yield because it records the argument's position only -- so those names stay fabricated from the position and are now labelled as such. A param_out var is $ret, so the fixture writes that instead of ret, and the label assertion follows. --- tests/analysis/java/test_java_dataflow.py | 24 +++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/tests/analysis/java/test_java_dataflow.py b/tests/analysis/java/test_java_dataflow.py index 3a6fd3e0..52f9da45 100644 --- a/tests/analysis/java/test_java_dataflow.py +++ b/tests/analysis/java/test_java_dataflow.py @@ -695,8 +695,15 @@ def _with_param_vars(payload: str) -> str: the same reason: the shape under test has to come from the real payload rather than from a hand-written one, so a regeneration that changes what is being added fails the count below. - The name written on each edge is the formal position its own endpoint already spells -- - ``@formal_in:0`` becomes ``p0``, ``@formal_out`` becomes ``ret`` -- so a label that reached the + A ``param_in`` name here is **fabricated from the formal position** its own endpoint spells -- + ``@formal_in:0`` becomes ``p0`` -- because a 3.1.0 payload records the argument's *position* and + never its name, so the name 3.1.2 writes there cannot be recovered from this fixture. Measured on + the pinned 3.1.2 (a local ``-a 4`` run over the same daytrader8 sources): all 1,932 ``param_in`` + edges carry a ``var``, and it is the **actual argument's** name (``tSIA``, ``volume``). The + ``param_out`` name is not fabricated: all 909 of that run's carry ``$ret``, which is what is + written here. + + Fabricated or not, each name is tied to the edge it belongs on, so a label that reached the adjacency off the *wrong* edge shows up as the wrong name rather than as a name that is merely present. All 355 endpoints spell one, asserted rather than assumed. """ @@ -708,7 +715,7 @@ def _with_param_vars(payload: str) -> str: named += 1 for edge in application["param_out"]: assert edge["src"].endswith("@formal_out"), f"a param_out edge starting somewhere other than a formal_out: {edge['src']}" - edge["var"] = "ret" + edge["var"] = "$ret" named += 1 assert named == 355, f"the 3.1.2 shape names all 355 param edges, not {named}" return json.dumps(payload_json) @@ -735,8 +742,13 @@ def test_a_java_param_edge_carries_the_variable_the_analyzer_put_on_it(ref, para ``getattr(e, "var", None)`` at ``JCodeanalyzer._sdg``, not a constructed label: the count and the name of every param edge in the adjacency come back out of the traversal, and the consumer the hardcoded ``None`` blinded -- ``_edge_vars_in`` -- gains exactly the two crossing names ``sell`` - scopes and nothing else. Whether the pinned analyzer writes ``var`` in practice is unverified in - this repo: there is no jar and no JVM. + scopes and nothing else. + + That the pinned analyzer writes ``var`` at all is measured rather than assumed: the 3.1.2 wheel's + jar, run at ``-a 4`` over the same daytrader8 sources this fixture was cut from, names every one + of its 1,932 ``param_in`` and 909 ``param_out`` edges. What that run cannot stand in for is *this* + payload, which is why the fix is witnessed on a4 plus :func:`_with_param_vars` rather than on a + second application. """ old_labels = [(rel, var) for outs in ref._sdg()[0]["forward"].values() for labels in outs.values() for rel, var, _prov in labels if rel.startswith("J_PARAM")] assert len(old_labels) == 355, "a4 was emitted by 3.1.0: 258 param_in + 97 param_out, none carrying a var" @@ -749,7 +761,7 @@ def test_a_java_param_edge_carries_the_variable_the_analyzer_put_on_it(ref, para ("J_PARAM_IN", "p2"): 8, ("J_PARAM_IN", "p3"): 6, ("J_PARAM_IN", "p4"): 4, - ("J_PARAM_OUT", "ret"): 97, + ("J_PARAM_OUT", "$ret"): 97, }, "every param edge reaches the adjacency under its own formal's name" scope = ref.resolve_callable(SELL).ref From 959b0418da2725fd3ed3721b173d4c5a2a96147d Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Fri, 11 Sep 2026 12:13:23 -0400 Subject: [PATCH 57/65] =?UTF-8?q?feat(java):=20view=20dispatches=20?= =?UTF-8?q?=E2=80=94=20JViewDispatch=20models,=20get=5Fview=5Fdispatches?= =?UTF-8?q?=20/=20get=5Fview=5Fdispatchers=20/=20get=5Funresolved=5Fview?= =?UTF-8?q?=5Fdispatches,=20Neo4j=20J=5FDISPATCHES=5FTO;=20pin=20codeanaly?= =?UTF-8?q?zer-java=203.3.3=20(#404)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three accessors on JavaAnalysis over both backends, one implementation on the contract; the Neo4j backend reads J_DISPATCHES_TO back and refuses the JSON-only unresolved record. The layer is the one overlay gated on the analyzer generation, because 3.3.x added no always-present key to probe. Pin 3.2.0 -> 3.3.3; release/2.0 absorbs main at rc.6. --- CHANGELOG.md | 58 ++++++ cldk/analysis/java/backend.py | 121 +++++++++++++ .../java/codeanalyzer/codeanalyzer.py | 12 ++ cldk/analysis/java/java_analysis.py | 43 +++++ cldk/analysis/java/neo4j/neo4j_backend.py | 31 ++++ cldk/models/java/__init__.py | 4 + cldk/models/java/models.py | 47 ++++- docs/agent-api-reference.md | 18 +- pyproject.toml | 4 +- tests/analysis/java/test_java_e2e.py | 21 +++ .../analysis/java/test_java_neo4j_backend.py | 7 + .../analysis/java/test_java_public_surface.py | 3 + .../java/test_java_view_dispatches.py | 167 ++++++++++++++++++ uv.lock | 8 +- 14 files changed, 535 insertions(+), 9 deletions(-) create mode 100644 tests/analysis/java/test_java_view_dispatches.py diff --git a/CHANGELOG.md b/CHANGELOG.md index f07df1c4..4382bcd7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,64 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [v2.0.0-rc.7] - 2026-09-11 + +The Java facade learns the view layer codeanalyzer-java 3.3.0–3.3.2 added, and the 2.0 line takes up +everything rc.6 shipped from `main` (the 3.2.0 text model and the sibling-analyzer pins) — this is +the first release cut from `release/2.0` that contains rc.6. + +### Added + +**Java view dispatches** (`python-sdk#404`, spec `codellm-devkit/.github` +`docs/design/specs/2026-09-11-java-view-templates-and-dispatch.md`). JSP, Facelets and Thymeleaf +templates are artifacts with `roles: ["view-template"]`, and the analyzer now says which code reaches +one. Three accessors on `JavaAnalysis`, both backends: + +- `get_view_dispatches(view=None) -> List[JViewDispatch]` — every resolved dispatch: the body node + that hands the request over (a `forward` / `include` / `sendRedirect` call, a `ModelAndView` + construction or `setViewName`, or a Spring controller's `return`) and the artifact it reaches, + with `via` naming the mechanism and `prov` the tier. `literal` and `dataflow` mean exactly one + target; `table` (3.3.1) is a may-dispatch over a static string table, one edge per entry, from + the same site. `view` filters by a segment-aligned suffix of the artifact's path. +- `get_view_dispatchers(path) -> List[JCallableOverview]` — the same edges resolved to the callables + that own the dispatching sites. +- `get_unresolved_view_dispatches() -> List[JViewDispatchUnresolved]` — what closed on nothing (a + variable target, a servlet URL, a view name matching two templates), with its reason and the tiers + attempted. JSON-only: the graph has no node for a target that resolved to nothing, so the Neo4j + backend refuses this one rather than answering `[]`. + +Two models, `JViewDispatch` and `JViewDispatchUnresolved`, on `JApplication.view_dispatches` / +`.view_dispatches_unresolved`; the Neo4j backend reads `J_DISPATCHES_TO` back into the former. + +One rule stated because it is the only one of its kind: **this layer is gated on the analyzer +generation.** The 3.1.0 config trio is probed by an always-present sibling key; 3.3.x added none — +both lists are written only when non-empty and the edge type is declared only once an edge exists — +so a pre-3.3.0 analysis is refused from `analyzer.version` (wire) / `analyzer_version` (graph) instead, +because `[]` off a 3.2.0 analysis would say "reaches no view" where the truth is that nothing looked. + +Measured on daytrader8 with 3.3.3: 37 edges at level 1–2 (3 `literal`, 34 `table`), 54 at level 4, +19 of 23 JSPs reached; the `TradeConfig.webUI` table's 17 on-disk pages all reached. + +### Changed + +- `codeanalyzer-java` pin `3.2.0` → **`3.3.3`** (the `java` extra and `[tool.backend-versions]`). + 3.3.0 adds the view-template role, `J_DISPATCHES_TO`, and `argument_expr` on `return` body nodes; + 3.3.1 the table tier; 3.3.2 narrows the Jakarta entrypoint finder to lifecycle methods, so + helpers taking servlet-typed parameters are no longer entrypoints and the interprocedural + config-use and view-dispatch tiers bind their parameters (daytrader8 entrypoint marks + 135 → 196: 21 helpers dropped, 82 `init`/`destroy`/`doFilter`/`onMessage` gained); 3.3.3 fixes + the `NullPointerException` 3.3.0–3.3.2 threw on any unbuilt project with a dispatch call, which + this SDK's `test_java_degradation.py` was the first to hit (codeanalyzer-java#265). +- `release/2.0` absorbed `main` at rc.6. + +### Verification + +The Java suite offline on both backends over a1 with the layer injected (edges, suffix filter, +dispatcher resolution, the JSON-only refusal on the graph, the pre-3.3.0 refusal, the empty answer +on a 3.3.x payload that reaches nothing, and the answered-once check across backends); the public +surface pins the three signatures; e2e against the 3.3.3 wheel on daytrader8 at level 1 and 2 +asserts the 37 edges, the 19 views, the three unresolved URLs and the 38 view-template artifacts. + ## [v2.0.0-rc.6] - 2026-09-10 The Java Neo4j backend stops being the lossy one about source text. Until now a graph-backed Java diff --git a/cldk/analysis/java/backend.py b/cldk/analysis/java/backend.py index 9613f9c2..2cace7be 100644 --- a/cldk/analysis/java/backend.py +++ b/cldk/analysis/java/backend.py @@ -109,6 +109,8 @@ JLocalVariable, JMethodDetail, JType, + JViewDispatch, + JViewDispatchUnresolved, ) from cldk.models.java.projections import JCallableOverview, JClassOverview from cldk.utils.exceptions.exceptions import CodeanalyzerExecutionException, CodeanalyzerUsageException, SelectorNotInGraph @@ -154,6 +156,29 @@ "codeanalyzer-java 3.1.0 or newer. get_config_keys() is unaffected: the keys a config artifact declares are read at every generation." ) +#: Why the three view-dispatch accessors refuse on an analysis that predates the pass. +#: codeanalyzer-java 3.3.0 (spec 2026-09-11, codeanalyzer-java#259) added the view-dispatch layer; +#: 3.3.1 (#261) its table tier. Unlike the 3.1.0 trio there is **no unconditional witness** for it: +#: both lists are written only when non-empty, ``J_DISPATCHES_TO`` is declared only once an edge +#: exists, and the release added no always-present key. So this is the one overlay decided from the +#: analyzer generation — ``analyzer.version`` on the wire, ``analyzer_version`` on the ``:JApplication`` +#: anchor — because the alternative, answering ``[]`` off a 3.2.0 analysis, would say "this +#: application reaches no view" where the truth is that nothing looked (D7). +VIEW_DISPATCH_UNAVAILABLE = ( + "the view-dispatch layer cannot be answered for application {app!r}: this analysis was produced by codeanalyzer-java {version}, and the " + "layer (view_dispatches, view_dispatches_unresolved, J_DISPATCHES_TO) exists only from 3.3.0. An empty answer here would say 'this " + "application reaches no view' where the truth is that nothing looked. Re-analyse, or re-emit your Neo4j graph, with codeanalyzer-java " + "3.3.0 or newer. get_artifacts() is unaffected." +) + +#: What the graph backend raises from ``get_unresolved_view_dispatches``: the projection carries no +#: node for a target that resolved to nothing, so the record is reachable from ``analysis.json`` only. +VIEW_DISPATCH_UNRESOLVED_JSON_ONLY = ( + "unresolved view dispatches for application {app!r} are not in the graph: codeanalyzer-java writes view_dispatches_unresolved into " + "analysis.json only (a target that resolved to no artifact has no node to project an edge to), so this backend cannot tell 'every " + "dispatch resolved' from 'the record is elsewhere'. Read them off the local codeanalyzer backend." +) + #: What ``get_external_symbols`` raises on a payload whose run never homed out-of-project call #: targets -- which is not the same fact as a project that calls nothing outside itself (D7). EXTERNAL_SYMBOLS_UNAVAILABLE = ( @@ -1238,6 +1263,102 @@ def get_config_readers(self, key: str) -> List[JCallableOverview]: found[row.key] = row return [JCallableOverview.of(r.key, r.type, r.callable, path=r.path) for r in found.values()] + # -----[ the view-dispatch layer (codeanalyzer-java 3.3.0, python-sdk#404) ]----- + @abstractmethod + def _analyzer_generation(self) -> Tuple[int, int, int] | None: + """The ``(major, minor, patch)`` of the codeanalyzer-java that produced this analysis — + ``analyzer.version`` off the wire, ``analyzer_version`` off the ``:JApplication`` anchor — + or ``None`` when it cannot be read. The one version-shaped probe in this contract, and + :data:`VIEW_DISPATCH_UNAVAILABLE` says why it has to be one.""" + + def _view_overlay(self) -> Tuple[List[JViewDispatch], List[JViewDispatchUnresolved]]: + """This analysis's view-dispatch lists, or raise if the analyzer predates the pass. + + Raises: + CodeanalyzerExecutionException: The analysis was produced by codeanalyzer-java older + than 3.3.0, or by one whose version cannot be read (:data:`VIEW_DISPATCH_UNAVAILABLE`). + """ + generation = self._analyzer_generation() + if generation is None or generation < (3, 3, 0): + version = "unknown" if generation is None else ".".join(map(str, generation)) + raise CodeanalyzerExecutionException(VIEW_DISPATCH_UNAVAILABLE.format(app=self._application_name, version=version)) + app = self.get_application_view() + return list(app.view_dispatches or []), list(app.view_dispatches_unresolved or []) + + def _view_artifact_paths(self) -> Dict[str, str]: + """``artifact id -> repo-relative path`` for every inventoried artifact.""" + return {a.id: path for path, a in self.get_application_view().artifacts.items()} + + @staticmethod + def _view_matches(path: str, view: str) -> bool: + """Segment-aligned suffix match: ``quoteDataPrimitive.jsp`` and + ``src/main/webapp/quoteDataPrimitive.jsp`` both name the same artifact; ``DataPrimitive.jsp`` + names nothing. Never a substring (E8).""" + return path == view or path.endswith("/" + view) + + def get_view_dispatches(self, view: str | None = None) -> List[JViewDispatch]: + """Every view dispatch the analyzer resolved: a body node that hands the request to a view + template, and the artifact it reaches. + + Args: + view: Restrict to one view, matched as a segment-aligned suffix of the artifact's + repo-relative path (``home.jsp``, ``WEB-INF/jsp/home.jsp``, or the whole path). + ``None`` returns every edge. + + Returns: + :class:`~cldk.models.java.models.JViewDispatch`, sorted by ``(src, dst)``. ``prov`` is + the tier that closed the target — see the model's note on ``["table"]``, the one + many-per-site kind. Empty means the pass ran and resolved nothing. + + Raises: + CodeanalyzerExecutionException: See :meth:`_view_overlay`. + """ + dispatches, _ = self._view_overlay() + if view is None: + return dispatches + paths = self._view_artifact_paths() + return [d for d in dispatches if self._view_matches(paths.get(d.dst, ""), view)] + + def get_unresolved_view_dispatches(self) -> List[JViewDispatchUnresolved]: + """Every detected dispatch that closed on no artifact — a variable target, a servlet URL, + a view name matching two templates — kept first class so a page nobody can trace stays as + visible as one that resolves. + + Returns: + :class:`~cldk.models.java.models.JViewDispatchUnresolved`, sorted by + ``(site, reason, target)``; ``prov`` lists every tier attempted. + + Raises: + CodeanalyzerExecutionException: See :meth:`_view_overlay`; and on the Neo4j backend + always (:data:`VIEW_DISPATCH_UNRESOLVED_JSON_ONLY`), because the projection carries + no node for a target that resolved to nothing. + """ + _, unresolved = self._view_overlay() + return unresolved + + def get_view_dispatchers(self, path: str) -> List[JCallableOverview]: + """Overviews of every callable that dispatches to one view — :meth:`get_view_dispatches` + resolved from body nodes back to the callables that own them. + + Args: + path: The view's repo-relative path or any segment-aligned suffix of it, matched as in + :meth:`get_view_dispatches`. + + Returns: + One overview per distinct callable, in the addressing index's order; a callable that + reaches the view from several sites (or through a table's many entries) appears once. + Empty means nothing dispatches to it. + + Raises: + CodeanalyzerExecutionException: See :meth:`_view_overlay`. + """ + found: Dict[str, _Addressed] = {} + for edge in self.get_view_dispatches(path): + row = self._addressing.by_id.get(edge.src.rpartition("@")[0]) + if row is not None: + found[row.key] = row + return [JCallableOverview.of(r.key, r.type, r.callable, path=r.path) for r in found.values()] + # -----[ the type-kind leaf accessors (J-7) ]----- def get_interfaces(self) -> Dict[str, JType]: """Every interface, keyed by qualified name — the subset of :meth:`get_all_classes` whose diff --git a/cldk/analysis/java/codeanalyzer/codeanalyzer.py b/cldk/analysis/java/codeanalyzer/codeanalyzer.py index 71496d6f..c31e78ea 100644 --- a/cldk/analysis/java/codeanalyzer/codeanalyzer.py +++ b/cldk/analysis/java/codeanalyzer/codeanalyzer.py @@ -161,6 +161,14 @@ def _recorded_verdict(verdict_file: Path, analysis_json_file: Path) -> List[Diag return None +def _semver(raw: object) -> Tuple[int, int, int] | None: + """``"3.3.2"`` → ``(3, 3, 2)``; anything that is not three integers is ``None``.""" + parts = str(raw or "").split("-", 1)[0].split(".") + if len(parts) != 3 or not all(p.isdigit() for p in parts): + return None + return int(parts[0]), int(parts[1]), int(parts[2]) + + class JCodeanalyzer(JavaAnalysisBackend): """Build and query the application view of a Java project by invoking codeanalyzer-java. @@ -187,6 +195,10 @@ class JCodeanalyzer(JavaAnalysisBackend): payload occupies the same pipe the log would. """ + def _analyzer_generation(self) -> Tuple[int, int, int] | None: + """``analyzer.version`` off the wire, as a comparable tuple; ``None`` if it is not a semver.""" + return _semver(self.analysis.analyzer.version) + def __init__( self, project_dir: Union[str, Path, None], diff --git a/cldk/analysis/java/java_analysis.py b/cldk/analysis/java/java_analysis.py index 75e3d587..614d5864 100644 --- a/cldk/analysis/java/java_analysis.py +++ b/cldk/analysis/java/java_analysis.py @@ -72,6 +72,8 @@ JField, JMethodDetail, JType, + JViewDispatch, + JViewDispatchUnresolved, ) from cldk.models.java.projections import JCallableOverview, JClassOverview from cldk.models.python import PyArtifact, PyConfigKey, PyConfigRead, PyConfigUseEdge, PyDependency @@ -1949,6 +1951,47 @@ def get_config_readers(self, key: str) -> List[JCallableOverview]: """ return self.backend.get_config_readers(key) + # -----[ the view-dispatch layer (codeanalyzer-java 3.3.0) ]----- + def get_view_dispatches(self, view: str | None = None) -> List[JViewDispatch]: + """Return every resolved view dispatch: which body node hands the request to which view + template (JSP, Facelet, Thymeleaf) — a ``forward`` / ``include`` / ``sendRedirect`` call, a + ``ModelAndView`` construction or ``setViewName``, or a Spring controller's ``return``. + + Args: + view: When given, only edges whose view's repo-relative path ends with it, + segment-aligned (``home.jsp`` or ``WEB-INF/jsp/home.jsp``). + + Raises: + CodeanalyzerExecutionException: The analysis predates codeanalyzer-java 3.3.0, which is + where the pass exists; an older analysis is refused rather than answered ``[]``. + + See Also: + :meth:`get_view_dispatchers`: The same edges, resolved to their dispatching callables. + :meth:`get_unresolved_view_dispatches`: The dispatches this cannot show. + """ + return self.backend.get_view_dispatches(view) + + def get_unresolved_view_dispatches(self) -> List[JViewDispatchUnresolved]: + """Return every detected dispatch that closed on no view — a variable target, a servlet URL, + a view name matching two templates — with its reason and the tiers attempted. + + Raises: + CodeanalyzerExecutionException: The analysis predates codeanalyzer-java 3.3.0; or, on the + Neo4j backend, always — the record lives in ``analysis.json`` only. + """ + return self.backend.get_unresolved_view_dispatches() + + def get_view_dispatchers(self, path: str) -> List[JCallableOverview]: + """Return overviews of every callable that dispatches to the view at ``path``. + + Args: + path: The view's repo-relative path or any segment-aligned suffix of it. + + Raises: + CodeanalyzerExecutionException: As :meth:`get_view_dispatches`. + """ + return self.backend.get_view_dispatchers(path) + # -----[ the type-kind leaf accessors (J-7) ]----- def get_interfaces(self) -> Dict[str, JType]: """Return every interface in the project, keyed by qualified name. diff --git a/cldk/analysis/java/neo4j/neo4j_backend.py b/cldk/analysis/java/neo4j/neo4j_backend.py index c585adf5..103b2cc8 100644 --- a/cldk/analysis/java/neo4j/neo4j_backend.py +++ b/cldk/analysis/java/neo4j/neo4j_backend.py @@ -134,6 +134,7 @@ JavaAnalysisBackend, duplicate_type_name, unhomed_endpoint, + VIEW_DISPATCH_UNRESOLVED_JSON_ONLY, ) from cldk.analysis.java.neo4j import reconstruct as R from cldk.models.java import JGraphEdges @@ -150,6 +151,8 @@ JCompilationUnit, JConfigRead, JConfigUse, + JViewDispatch, + JViewDispatchUnresolved, JDdgEdge, JDecorator, JEntrypointReport, @@ -594,8 +597,36 @@ def _reconstruct(self) -> JApplication: # ``None`` and ``[]`` are different answers here: see :meth:`_overlay_rows`. config_uses=None if report is None else uses, config_reads_unresolved=None if report is None else reads, + # From 3.3.0 the edge is read whatever the application reaches (``[]`` is a real answer); + # before it the key stays ``None`` and the accessors refuse on the generation. The + # unresolved record is JSON-only and never rebuilt here -- see + # :meth:`get_unresolved_view_dispatches`. + view_dispatches=self._view_dispatch_rows() if (self._analyzer_version or (0, 0, 0)) >= (3, 3, 0) else None, ) + def _view_dispatch_rows(self) -> List[JViewDispatch]: + """``J_DISPATCHES_TO`` (codeanalyzer-java 3.3.0): the source is a body node under the id + prefix, the target an artifact reached from the application anchor — the two id spaces meet + on this edge exactly as they do on ``J_USES_CONFIG``.""" + rows = self._run( + "MATCH (:JApplication {id: $app_id})-[:HAS_ARTIFACT]->(a:Artifact)<-[d:J_DISPATCHES_TO]-(src) " + f"WHERE {_scoped('src')} RETURN src.id AS src, a.id AS dst, d.via AS via, d.prov AS prov ORDER BY src.id, a.id", + app_id=self._application_id, + prefix=self._scope_prefix, + ) + return [JViewDispatch(src=r["src"], dst=r["dst"], via=r["via"], prov=list(r["prov"] or [])) for r in rows] + + def _analyzer_generation(self) -> Tuple[int, int, int] | None: + """``analyzer_version`` off the ``:JApplication`` anchor, read once at attach by + :meth:`_probe_schema`.""" + return self._analyzer_version + + def get_unresolved_view_dispatches(self) -> List[JViewDispatchUnresolved]: + """Always raises: the record is JSON-only (:data:`VIEW_DISPATCH_UNRESOLVED_JSON_ONLY`). The + generation check runs first, so a pre-3.3.0 graph gets the more useful of the two reasons.""" + self._view_overlay() + raise CodeanalyzerExecutionException(VIEW_DISPATCH_UNRESOLVED_JSON_ONLY.format(app=self._application_name)) + # ===================================================================================== # The reconstructed view and its index (both built on first use) # ===================================================================================== diff --git a/cldk/models/java/__init__.py b/cldk/models/java/__init__.py index 586be981..9eab3354 100644 --- a/cldk/models/java/__init__.py +++ b/cldk/models/java/__init__.py @@ -29,6 +29,8 @@ JGraphEdges, JImport, JType, + JViewDispatch, + JViewDispatchUnresolved, ) from .projections import JCallableOverview, JClassOverview @@ -46,6 +48,8 @@ "JGraphEdges", "JImport", "JType", + "JViewDispatch", + "JViewDispatchUnresolved", "JCallableOverview", "JClassOverview", "CRUDOperationType", diff --git a/cldk/models/java/models.py b/cldk/models/java/models.py index 9a25a166..35f27b12 100644 --- a/cldk/models/java/models.py +++ b/cldk/models/java/models.py @@ -19,7 +19,8 @@ The wire is one containment tree: ``JAnalysis{analyzer, application}`` → ``JApplication{symbol_table{path → JCompilationUnit}, call_graph, param_in, param_out, artifacts, -dependencies, config_uses, config_reads_unresolved, entrypoint_report}`` → +dependencies, config_uses, config_reads_unresolved, view_dispatches, view_dispatches_unresolved, +entrypoint_report}`` → ``JCompilationUnit{types{name → JType}}`` → ``JType{fields{}, callables{signature → JCallable}, types{}}`` → ``JCallable{body{}, cfg, cdg, ddg, summary, types{}}``. Every node carries a ``can://`` ``id`` and a ``kind``; a unit carries its full ``source`` once and every node's text is a @@ -862,6 +863,44 @@ class JConfigRead(_Base): prov: List[str] = [] +class JViewDispatch(_Base): + """One resolved view dispatch (codeanalyzer-java 3.3.0, spec 2026-09-11 § 4): the body node that + hands the request to a view template and the :class:`JArtifact` it reaches. + + ``src`` is the dispatching body node's id (``@:``) — a + ``RequestDispatcher.forward`` / ``include`` or ``sendRedirect`` call, a ``ModelAndView`` + construction or ``setViewName``, or a Spring controller's ``return``; ``dst`` is the artifact's + ``can:///artifact/`` id. ``via`` names the mechanism (``forward | include | redirect | + view-name | navigation``) and ``prov`` the tier that closed the target: ``["literal"]`` is a string + at the site, ``["dataflow"]`` a local or parameter traced over the L3/L4 substrate — both mean + exactly one target — and ``["table"]`` (3.3.1, § 4.5) a lookup into a static string table, which + is a **may-dispatch**: one edge per entry of the table, from the same site. Kept ``str`` rather + than ``Literal`` on purpose, as :class:`JConfigRead.reason` is: ``navigation`` is reserved and + unemitted today, and a closed type here would break on the analyzer release that fills it.""" + + src: str + dst: str + via: str + prov: List[str] = [] + + +class JViewDispatchUnresolved(_Base): + """A detected dispatch that closed on no artifact — first class, so a page nobody can trace stays + as visible as one that resolves. ``target`` is the decoded literal when there was one and + ``None`` for ``reason="non-literal"``; ``reason`` is ``non-literal`` / ``no-such-artifact`` (a + servlet URL, a ``*.faces`` route, a file outside the repository) / ``ambiguous`` (a view name + matching two templates); ``prov`` lists every tier attempted. ``callee`` is the dispatching + callee's ``@external`` id for a call site and the enclosing callable's id for a ``return`` site. + **JSON-only**: the projection has no node for a target that resolved to nothing.""" + + site: str + callee: str + target: Optional[str] = None + via: str + reason: str + prov: List[str] = [] + + class JApplication(_Base): """The application root. ``call_graph``/``param_in``/``param_out`` are absent below the level that computes them — empty here, never ``None``. @@ -889,6 +928,12 @@ class JApplication(_Base): config_uses: Optional[List[JConfigUse]] = None config_reads_unresolved: Optional[List[JConfigRead]] = None entrypoint_report: Optional[JEntrypointReport] = None + #: The 3.3.0 view-dispatch layer. ``None`` here means only "the key is absent": 3.3.x writes both + #: lists only when non-empty, so — unlike the 3.1.0 trio — no key on the wire is unconditional and + #: the backends decide "predates the pass" from the analyzer generation instead + #: (:data:`~cldk.analysis.java.backend.VIEW_DISPATCH_UNAVAILABLE`). + view_dispatches: Optional[List[JViewDispatch]] = None + view_dispatches_unresolved: Optional[List[JViewDispatchUnresolved]] = None def model_post_init(self, __context: Any) -> None: for path, unit in self.symbol_table.items(): diff --git a/docs/agent-api-reference.md b/docs/agent-api-reference.md index 9fb38c94..e10d132f 100644 --- a/docs/agent-api-reference.md +++ b/docs/agent-api-reference.md @@ -239,8 +239,9 @@ Java-specific facts: accessors** — `get_callables_overview`, `get_method_bodies`, `get_decorated_callables`, `get_callsites_for`, `get_external_symbols`, `get_entrypoints` / `get_entrypoint_classes` / `get_entrypoint_coverage`, `get_artifacts` / `get_dependencies` / `get_config_keys` / -`get_config_uses` / `get_unresolved_config_reads` / `get_config_readers`, and `get_interfaces` / -`get_enums` / `get_enum_members` / `get_records`. Five rules: +`get_config_uses` / `get_unresolved_config_reads` / `get_config_readers`, `get_view_dispatches` / +`get_unresolved_view_dispatches` / `get_view_dispatchers`, and `get_interfaces` / `get_enums` / +`get_enum_members` / `get_records`. Six rules: - **`get_entrypoint_coverage` reads the report from codeanalyzer-java 3.1.0 on.** 3.1.0 (codeanalyzer-java#235) emits the entrypoint pass's own coverage record — `entrypoint_report` on @@ -254,6 +255,19 @@ accessors** — `get_callables_overview`, `get_method_bodies`, `get_decorated_ca count of syntactically-marked callables is not a coverage record. The marks themselves are real and unambiguous — 133 callables and 66 types of daytrader8's 1,216 and 149, 1,501 callables and 904 types of ThingsBoard's — and `get_entrypoints` / `get_entrypoint_classes` return those. +- **`get_view_dispatches` reads codeanalyzer-java 3.3.0's view layer, and is the one accessor + gated on the analyzer generation.** JSP, Facelets and Thymeleaf templates are `:Artifact` nodes + with `roles: ["view-template"]`, and a `J_DISPATCHES_TO` edge (`view_dispatches[]` on the wire) + runs from the body node that hands the request over — a `forward` / `include` / `sendRedirect` + call, a `ModelAndView` construction or `setViewName`, or a Spring controller's `return` — to the + artifact it reaches, with `via` naming the mechanism and `prov` the tier: `literal` and + `dataflow` mean exactly one target, `table` (3.3.1) is a may-dispatch over a static string table, + one edge per entry. `get_view_dispatchers(path)` resolves the sites to their callables; + `get_unresolved_view_dispatches()` is the JSON-only record of what closed on nothing, so the + Neo4j backend refuses it rather than answering `[]`. Unlike the 3.1.0 trio there is no + always-present key to probe, so a pre-3.3.0 analysis is refused from `analyzer.version` / + `analyzer_version`. On daytrader8 with 3.3.3: 37 edges at level 1–2 (3 literal, 34 table), 54 at + level 4, 19 of 23 JSPs reached. - **A marker matches an annotation by simple name.** `get_decorated_callables(["Test"])`, `["@Test"]` and `["org.junit.Test"]` are the same query: the Java wire carries an annotation's simple name, so both sides are compared on the segment after the last `.` with a leading `@` diff --git a/pyproject.toml b/pyproject.toml index 4ef0e17a..c0890b72 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,7 +43,7 @@ dependencies = [ # The Neo4j-backed analysis backends (read-only; no analyzer is invoked). neo4j = ["neo4j>=5.14,<7"] # The local Java backend: the analyzer wheel carries the jar and a bundled JVM (jdk4py). -java = ["codeanalyzer-java==3.2.0"] +java = ["codeanalyzer-java==3.3.3"] # Everything. `codeanalyzer-python` and `codeanalyzer-typescript` are still hard # dependencies; #340 moves them here, and this list grows when it does. all = ["cldk[java]", "cldk[neo4j]"] @@ -86,7 +86,7 @@ include = [ ] [tool.backend-versions] -codeanalyzer-java = "3.2.0" +codeanalyzer-java = "3.3.3" codeanalyzer-python = "1.5.2" codeanalyzer-typescript = "1.6.0" diff --git a/tests/analysis/java/test_java_e2e.py b/tests/analysis/java/test_java_e2e.py index 5a75958a..a7048f51 100644 --- a/tests/analysis/java/test_java_e2e.py +++ b/tests/analysis/java/test_java_e2e.py @@ -94,3 +94,24 @@ def test_call_graph_nodes_are_fqn_dot_signature_strings(analysis): # J-8/#8: absolute file lines, sorted — not offsets into ``JCallable.code``, which is a # different string on each backend. assert data["calling_lines"] == sorted(data["calling_lines"]) and all(n > 0 for n in data["calling_lines"]) + + +def test_view_dispatches_reach_daytrader8s_jsps(analysis): + """codeanalyzer-java 3.3.x (python-sdk#404): at level 1 and 2 the literal tier closes 3 sites + and the table tier (3.3.1) 34 more -- every page in ``TradeConfig.webUI`` that exists on disk, + from ``TradeConfigServlet``'s two sites -- so 37 edges reach 19 of daytrader8's 23 JSPs. The + call-graph tier that binds ``TradeServletAction.requestDispatch`` runs at level 4 only, which + these two levels do not reach. Measured off the 3.3.3 jar, not off the SDK.""" + edges = analysis.get_view_dispatches() + assert len(edges) == 37 + assert {(e.via, tuple(e.prov)) for e in edges} == {("forward", ("literal",)), ("include", ("literal",)), ("include", ("table",))} + assert len({e.dst for e in edges}) == 19 + assert [d.key for d in analysis.get_view_dispatchers("PingServlet2Jsp.jsp")] == [ + "com.ibm.websphere.samples.daytrader.web.prims.PingServlet2Jsp.doGet(javax.servlet.http.HttpServletRequest, javax.servlet.http.HttpServletResponse)" + ] + unresolved = analysis.get_unresolved_view_dispatches() + assert {u.reason for u in unresolved} == {"no-such-artifact", "non-literal"} + assert {u.target for u in unresolved if u.reason == "no-such-artifact"} == {"welcome.faces", "/servlet/PingServlet2IncludeRcv", "/servlet/PingServlet2ServletRcv"} + views = {p for p, a in analysis.get_artifacts().items() if "view-template" in a.roles} + assert len(views) == 38 and all(p.endswith((".jsp", ".xhtml")) for p in views) + diff --git a/tests/analysis/java/test_java_neo4j_backend.py b/tests/analysis/java/test_java_neo4j_backend.py index 46af282e..e364564b 100644 --- a/tests/analysis/java/test_java_neo4j_backend.py +++ b/tests/analysis/java/test_java_neo4j_backend.py @@ -79,6 +79,8 @@ import pytest +from cldk.utils.exceptions.exceptions import CodeanalyzerExecutionException + logging.getLogger("neo4j").setLevel(logging.ERROR) NEO4J_URI = os.environ.get("CLDK_TEST_NEO4J_URI", "bolt://localhost:7687") @@ -408,6 +410,11 @@ def test_artifact_layer_parity(backends): # ``test_java_entrypoints_live.py`` states that lossiness and pins the counts. assert sorted((u.src, u.dst, tuple(u.prov)) for u in neo.get_config_uses()) == sorted((u.src, u.dst, tuple(u.prov)) for u in ref.get_config_uses()) assert {(r.callee, r.key, r.reason) for r in neo.get_unresolved_config_reads()} == {(r.callee, r.key, r.reason) for r in ref.get_unresolved_config_reads()} + # The 3.3.0 view-dispatch layer: identical resolved edges; the unresolved record is JSON-only, + # which the graph backend states by refusing rather than by an empty list. + assert [(d.src, d.dst, d.via, tuple(d.prov)) for d in neo.get_view_dispatches()] == [(d.src, d.dst, d.via, tuple(d.prov)) for d in ref.get_view_dispatches()] + with pytest.raises(CodeanalyzerExecutionException): + neo.get_unresolved_view_dispatches() # The Java wire's own artifact models carry two fields the shared Py* ones have no home for. assert sorted(neo.application.artifacts) == sorted(ref.application.artifacts) assert {p: (a.text_truncated, a.sha256) for p, a in neo.application.artifacts.items()} == {p: (a.text_truncated, a.sha256) for p, a in ref.application.artifacts.items()} diff --git a/tests/analysis/java/test_java_public_surface.py b/tests/analysis/java/test_java_public_surface.py index 6bfaef2a..ec80182e 100644 --- a/tests/analysis/java/test_java_public_surface.py +++ b/tests/analysis/java/test_java_public_surface.py @@ -105,6 +105,9 @@ "get_config_uses": "(self, key: 'str | None' = None) -> 'List[PyConfigUseEdge]'", "get_unresolved_config_reads": "(self) -> 'List[PyConfigRead]'", "get_config_readers": "(self, key: 'str') -> 'List[JCallableOverview]'", + "get_view_dispatches": "(self, view: 'str | None' = None) -> 'List[JViewDispatch]'", + "get_unresolved_view_dispatches": "(self) -> 'List[JViewDispatchUnresolved]'", + "get_view_dispatchers": "(self, path: 'str') -> 'List[JCallableOverview]'", "get_interfaces": "(self) -> 'Dict[str, JType]'", "get_enums": "(self) -> 'Dict[str, JType]'", "get_enum_members": "(self, qualified_enum_name: 'str') -> 'List[JEnumConstant]'", diff --git a/tests/analysis/java/test_java_view_dispatches.py b/tests/analysis/java/test_java_view_dispatches.py new file mode 100644 index 00000000..43e80b50 --- /dev/null +++ b/tests/analysis/java/test_java_view_dispatches.py @@ -0,0 +1,167 @@ +################################################################################ +# Copyright IBM Corporation 2026 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +################################################################################ +"""The view-dispatch layer (codeanalyzer-java 3.3.0, python-sdk#404) on **both** backends, offline. + +The committed fixtures are 3.1.0 payloads and carry no ``view_dispatches``, so the layer is exercised +over **a1 with the layer injected**: three resolved edges and one unresolved record shaped exactly +as codeanalyzer-java 3.3.3 writes them on daytrader8 (``PingServlet2Jsp.doGet`` forwards to +``PingServlet2Jsp.jsp``; ``PingJDBCRead2JSP.doGet`` includes ``quoteDataPrimitive.jsp``; a forward to +``/servlet/PingServlet2ServletRcv`` is a URL, not a file), re-addressed onto the fixture's own +callable and artifact ids. The second include comes from ``PingServlet2Session2Entity2JSP.doGet``, +the fixture's other ``include`` site — a1 is a 3.1.0 run over 138 units and has no +``PingJDBCWrite2JSP``. The a1 payload as committed is the +*refusal* case: an analysis that predates the pass must not answer ``[]``. +""" +import json +from typing import Dict + +import pytest + +from cldk.analysis.java.backend import JavaAnalysisBackend +from cldk.analysis.java.codeanalyzer.codeanalyzer import JCodeanalyzer +from cldk.analysis.java.neo4j import JNeo4jBackend +from cldk.models.java.models import JViewDispatch, JViewDispatchUnresolved +from cldk.models.java.projections import JCallableOverview +from cldk.utils.exceptions.exceptions import CodeanalyzerExecutionException + +from .test_java_addressing import _graph, _local + +DO_GET = "doGet(javax.servlet.http.HttpServletRequest, javax.servlet.http.HttpServletResponse)" + + +def _site(app: dict, unit: str, type_name: str, method_name: str) -> str: + """The ordinal id of the first ``call`` body node invoking ``method_name`` in ``doGet``. The + unit is found by basename: the two include sites live in different ``web/prims`` packages.""" + path = next(k for k in app["symbol_table"] if k.endswith(f"/{unit}.java")) + callable_ = app["symbol_table"][path]["types"][type_name]["callables"][DO_GET] + for local_id, node in callable_["body"].items(): + if node.get("method_name") == method_name: + return f"{callable_['id']}@{local_id}" + raise AssertionError(f"no {method_name} call in {type_name}.doGet") + + +def _with_layer(payload: str) -> str: + doc = json.loads(payload) + app = doc["application"] + doc["analyzer"]["version"] = "3.3.3" + art = {p: a["id"] for p, a in app["artifacts"].items()} + app["view_dispatches"] = sorted( + [ + {"src": _site(app, "PingServlet2Jsp", "PingServlet2Jsp", "forward"), "dst": art["src/main/webapp/PingServlet2Jsp.jsp"], "via": "forward", "prov": ["literal"]}, + {"src": _site(app, "PingJDBCRead2JSP", "PingJDBCRead2JSP", "include"), "dst": art["src/main/webapp/quoteDataPrimitive.jsp"], "via": "include", "prov": ["literal"]}, + {"src": _site(app, "PingServlet2Session2Entity2JSP", "PingServlet2Session2Entity2JSP", "include"), "dst": art["src/main/webapp/quoteDataPrimitive.jsp"], "via": "include", "prov": ["literal"]}, + ], + key=lambda d: (d["src"], d["dst"]), + ) + app["view_dispatches_unresolved"] = [ + { + "site": _site(app, "PingServlet2Servlet", "PingServlet2Servlet", "forward"), + "callee": "can://java/daytrader8/@external/javax.servlet.RequestDispatcher/forward(javax.servlet.ServletRequest, javax.servlet.ServletResponse)", + "target": "/servlet/PingServlet2ServletRcv", + "via": "forward", + "reason": "no-such-artifact", + "prov": ["literal"], + } + ] + return json.dumps(doc) + + +def _seed(kind: str, payload: str) -> JavaAnalysisBackend: + backend = (_local if kind == "local" else _graph)(payload) + if kind == "graph": + backend._analyzer_version = (3, 3, 3) + return backend + + +@pytest.fixture(scope="module", params=["local", "graph"]) +def both(request, analysis_json): + return _seed(request.param, _with_layer(analysis_json)) + + +@pytest.fixture(scope="module", params=["local", "graph"]) +def both_before(request, analysis_json): + """a1 as committed: codeanalyzer-java 3.1.0, no view-dispatch pass.""" + backend = (_local if request.param == "local" else _graph)(analysis_json) + if request.param == "graph": + backend._analyzer_version = (3, 1, 0) + return backend + + +def test_get_view_dispatches_carries_the_edges_and_their_mechanism(both): + edges = both.get_view_dispatches() + assert len(edges) == 3 and all(isinstance(e, JViewDispatch) for e in edges) + assert [(e.via, tuple(e.prov)) for e in edges] == [("include", ("literal",)), ("include", ("literal",)), ("forward", ("literal",))] or sorted((e.via, tuple(e.prov)) for e in edges) == [("forward", ("literal",)), ("include", ("literal",)), ("include", ("literal",))] + assert all(e.src.startswith(e.src.rpartition("@")[0] + "@") for e in edges), "src is a body-node id" + assert {e.dst.rsplit("/", 1)[1] for e in edges} == {"PingServlet2Jsp.jsp", "quoteDataPrimitive.jsp"} + assert [(e.src, e.dst) for e in edges] == sorted((e.src, e.dst) for e in edges), "sorted by (src, dst)" + + +def test_get_view_dispatches_filters_by_the_views_path_suffix(both): + two = both.get_view_dispatches("quoteDataPrimitive.jsp") + assert len(two) == 2 and all(e.dst.endswith("/quoteDataPrimitive.jsp") for e in two) + assert len(both.get_view_dispatches("src/main/webapp/quoteDataPrimitive.jsp")) == 2, "a longer suffix still matches" + assert both.get_view_dispatches("DataPrimitive.jsp") == [], "segment-aligned, never a substring" + assert both.get_view_dispatches("no/such.jsp") == [] + + +def test_get_view_dispatchers_resolves_the_sites_to_their_callables(both): + dispatchers = both.get_view_dispatchers("quoteDataPrimitive.jsp") + assert all(isinstance(d, JCallableOverview) for d in dispatchers) + assert sorted(d.key for d in dispatchers) == [ + f"com.ibm.websphere.samples.daytrader.web.prims.PingJDBCRead2JSP.{DO_GET}", + f"com.ibm.websphere.samples.daytrader.web.prims.ejb3.PingServlet2Session2Entity2JSP.{DO_GET}", + ] + assert [d.key for d in both.get_view_dispatchers("PingServlet2Jsp.jsp")] == [f"com.ibm.websphere.samples.daytrader.web.prims.PingServlet2Jsp.{DO_GET}"] + assert both.get_view_dispatchers("no/such.jsp") == [] + + +def test_get_unresolved_view_dispatches_keeps_the_url_visible_on_the_local_backend(both): + if isinstance(both, JNeo4jBackend): + # JSON-only: the projection has no node for a target that resolved to nothing, so the + # graph backend refuses rather than answering the ambiguous empty. + with pytest.raises(CodeanalyzerExecutionException, match="analysis.json"): + both.get_unresolved_view_dispatches() + return + unresolved = both.get_unresolved_view_dispatches() + assert len(unresolved) == 1 and isinstance(unresolved[0], JViewDispatchUnresolved) + u = unresolved[0] + assert (u.via, u.reason, u.target, u.prov) == ("forward", "no-such-artifact", "/servlet/PingServlet2ServletRcv", ["literal"]) + assert u.site.startswith(u.site.rpartition("@")[0] + "@") + + +def test_a_payload_without_the_pass_refuses_rather_than_answering_empty(both_before): + for call in (both_before.get_view_dispatches, both_before.get_unresolved_view_dispatches, lambda: both_before.get_view_dispatchers("x.jsp")): + with pytest.raises(CodeanalyzerExecutionException, match="3.3.0"): + call() + + +def test_a_newer_payload_with_no_dispatches_answers_empty(analysis_json): + doc = json.loads(analysis_json) + doc["analyzer"]["version"] = "3.3.3" + local = _local(json.dumps(doc)) + assert local.get_view_dispatches() == [] and local.get_unresolved_view_dispatches() == [] + assert local.get_view_dispatchers("x.jsp") == [] + + +def test_the_layer_is_answered_once_for_both_backends(): + """One implementation on the contract for the two edge accessors; the graph backend overrides + only the unresolved one, to refuse what the projection does not carry.""" + for name in ("get_view_dispatches", "get_view_dispatchers"): + shared = getattr(JavaAnalysisBackend, name) + for backend in (JCodeanalyzer, JNeo4jBackend): + assert getattr(backend, name) is shared, f"{backend.__name__} overrides {name}" + assert JCodeanalyzer.get_unresolved_view_dispatches is JavaAnalysisBackend.get_unresolved_view_dispatches + assert JNeo4jBackend.get_unresolved_view_dispatches is not JavaAnalysisBackend.get_unresolved_view_dispatches diff --git a/uv.lock b/uv.lock index fb3df067..beda8a04 100644 --- a/uv.lock +++ b/uv.lock @@ -300,7 +300,7 @@ wheels = [ [[package]] name = "cldk" -version = "2.0.0rc6" +version = "2.0.0rc7" source = { editable = "." } dependencies = [ { name = "codeanalyzer-python" }, @@ -350,7 +350,7 @@ test = [ requires-dist = [ { name = "cldk", extras = ["java"], marker = "extra == 'all'" }, { name = "cldk", extras = ["neo4j"], marker = "extra == 'all'" }, - { name = "codeanalyzer-java", marker = "extra == 'java'", specifier = "==3.2.0" }, + { name = "codeanalyzer-java", marker = "extra == 'java'", specifier = "==3.3.3" }, { name = "codeanalyzer-python", specifier = "==1.5.2" }, { name = "codeanalyzer-typescript", specifier = "==1.6.0" }, { name = "neo4j", marker = "extra == 'neo4j'", specifier = ">=5.14,<7" }, @@ -398,13 +398,13 @@ wheels = [ [[package]] name = "codeanalyzer-java" -version = "3.2.0" +version = "3.3.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jdk4py" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/c5/06/26d6c653ada36ce11358b08a33bbc24ca8b6f0da8b843c379c6d160691a3/codeanalyzer_java-3.2.0-py3-none-any.whl", hash = "sha256:c4e98c22c719bdb1665e98d8bcee4e2d81cc18ec518f66e467d77bdd6d871251", size = 32840590, upload-time = "2026-09-10T15:14:49.099Z" }, + { url = "https://files.pythonhosted.org/packages/28/34/8364c6d79ee9ad3e3ef40dae0f95de30db0a9e43cc1032b351586c3a5912/codeanalyzer_java-3.3.3-py3-none-any.whl", hash = "sha256:0a499ada8bba00ac4adf45786e4bb578052af17e295796367afc951f20a0d84b", size = 32863677, upload-time = "2026-09-11T16:09:16.097Z" }, ] [[package]] From 4a11011bef5d8ff358f7a0a7bfb1918b34ef8e69 Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Fri, 11 Sep 2026 12:13:24 -0400 Subject: [PATCH 58/65] chore(release): 2.0.0-rc.7 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index c0890b72..d489d08f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "cldk" -version = "2.0.0-rc.6" +version = "2.0.0-rc.7" description = "The official Python SDK for Codellm-Devkit." readme = "README.md" license = { text = "Apache-2.0" } From 70067855ae0d776b775822d7b4c0ce5910f01bbf Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Tue, 15 Sep 2026 23:55:50 -0400 Subject: [PATCH 59/65] chore(deps): bump codeanalyzer-python 1.5.2 -> 1.5.3 (#410) Two analyzer fixes, neither moving the schema: the analyzer no longer ingests its own output when the cache or output directory sits inside the input (codeanalyzer-python#207), and a call site keeps one body node even when two calls share a start position (codeanalyzer-python#215). Both pins move together; body_key_column already parses the suffixed key. Python tier: 482 passed, 168 skipped, same as on 1.5.2. --- CHANGELOG.md | 24 ++++++++++++++++++++++++ pyproject.toml | 4 ++-- uv.lock | 8 ++++---- 3 files changed, 30 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4382bcd7..808a15cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,30 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [v2.0.0-rc.8] - 2026-09-15 + +### Changed + +- `codeanalyzer-python` pin `1.5.2` → **`1.5.3`** (`dependencies` and `[tool.backend-versions]`, + `python-sdk#410`). Two analyzer fixes, neither moving the schema (`schema_version` and the graph + `SCHEMA_VERSION` stay `2.0.0`; `PyNeo4jBackend._ANALYZER_FLOOR` stays `1.5.0`): + - the analyzer no longer ingests its own output (codeanalyzer-python#207). `discover_artifacts` + had no idea where the run writes, so an output or cache directory inside the input made run N + inventory run N-1's `analysis.json` and embed it whole. This SDK's default `cache_dir` is + `/.codeanalyzer`, inside the input, so every local run was exposed. + - one body node per call site when two calls share a start position (codeanalyzer-python#215). + `getattr(o, n)(x)` starts the outer application and the inner `getattr` at the same column, and + the position-keyed `body` kept the inner one. Keys now carry a `/2`, `/3` disambiguator, + outermost first; `body_key_column` already splits on `/` and needed no change. A key that used + to collide now names the **outer** call, and a call whose callee is itself a call carries + `callee_signature: null` with `method_name: ""`. + +### Verification + +The Python tier offline (`tests/analysis/python`, mocked Neo4j and the in-process analyzer): +482 passed, 168 skipped, on 1.5.2 and on 1.5.3 alike — every skip is a live-Neo4j tier. No fixture +expectation moved. + ## [v2.0.0-rc.7] - 2026-09-11 The Java facade learns the view layer codeanalyzer-java 3.3.0–3.3.2 added, and the 2.0 line takes up diff --git a/pyproject.toml b/pyproject.toml index d489d08f..24a57609 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,7 +35,7 @@ dependencies = [ "tree-sitter-java==0.23.5", "tree-sitter-python==0.23.6", "tree-sitter-javascript==0.23.1", - "codeanalyzer-python==1.5.2", + "codeanalyzer-python==1.5.3", "codeanalyzer-typescript==1.6.0", ] @@ -87,7 +87,7 @@ include = [ [tool.backend-versions] codeanalyzer-java = "3.3.3" -codeanalyzer-python = "1.5.2" +codeanalyzer-python = "1.5.3" codeanalyzer-typescript = "1.6.0" ######################################## diff --git a/uv.lock b/uv.lock index beda8a04..9c78cbdb 100644 --- a/uv.lock +++ b/uv.lock @@ -351,7 +351,7 @@ requires-dist = [ { name = "cldk", extras = ["java"], marker = "extra == 'all'" }, { name = "cldk", extras = ["neo4j"], marker = "extra == 'all'" }, { name = "codeanalyzer-java", marker = "extra == 'java'", specifier = "==3.3.3" }, - { name = "codeanalyzer-python", specifier = "==1.5.2" }, + { name = "codeanalyzer-python", specifier = "==1.5.3" }, { name = "codeanalyzer-typescript", specifier = "==1.6.0" }, { name = "neo4j", marker = "extra == 'neo4j'", specifier = ">=5.14,<7" }, { name = "networkx", specifier = ">=3.4.2,<4" }, @@ -409,7 +409,7 @@ wheels = [ [[package]] name = "codeanalyzer-python" -version = "1.5.2" +version = "1.5.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "astor" }, @@ -426,9 +426,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "uv" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/71/c1/c76ad3a531d02f2548a8128c17c33c01c0d5aec240bd8e0dad93411590c3/codeanalyzer_python-1.5.2.tar.gz", hash = "sha256:18b1d81c44f0e0f1ab8dcb36a144345c58a51a55394a1a2b359d92c97c43df84", size = 234282, upload-time = "2026-09-10T21:45:35.272Z" } +sdist = { url = "https://files.pythonhosted.org/packages/59/9f/898ffe729c71c3ff1f1d1d8f13463c3a315c1d448152807bb66ff30d2b5b/codeanalyzer_python-1.5.3.tar.gz", hash = "sha256:d1ee224698c22a7c376b88e8b4431ac96a15949a789474c7c4490f7de958bbf1", size = 236787, upload-time = "2026-09-14T21:02:05.093Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/60/12/61fbed9875b06c061b24fcf4363e96215431db86efd895a2f4bd966c1d4f/codeanalyzer_python-1.5.2-py3-none-any.whl", hash = "sha256:7c0e1ed0b91fde5376f5c0cae70d48f85e83b0ff9e737c923b37c8e3375e2cd2", size = 239571, upload-time = "2026-09-10T21:45:33.895Z" }, + { url = "https://files.pythonhosted.org/packages/8b/22/04258091baf77cebbd51c8a55792dd7383f853f723020a6faf4f5fb99b1f/codeanalyzer_python-1.5.3-py3-none-any.whl", hash = "sha256:6049e8bd8d27ebb73f8ae9dfd5eed6f21bc06f94348f3458c10806e243597328", size = 242306, upload-time = "2026-09-14T21:02:03.694Z" }, ] [[package]] From d3586e1cb5615a921001c5cad75b9136dd66beee Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Wed, 16 Sep 2026 00:03:27 -0400 Subject: [PATCH 60/65] fix(python): locate places a decorator-line position on the decorated callable (#408) PyCallable.start_line is the def line (ast.FunctionDef.lineno) and the AST puts decorators above it, so a position on @http.route(...) sat in no callable span and fell through to module_scope. Both backends now read where the decorator is applied: _find_innermost admits a callable whose decorators[].span starts at or before the line and above its def; _LOCATE_QUERY gains one EXISTS disjunct over PY_DECORATED_BY.start_line, which codeanalyzer-python 1.5.2 emits. An older graph or analysis carries no span there, the comparison is null, and the position stays module scope. Ranking is unchanged (def-based width), so a decorator on a nested callable resolves to the nested one. A class decorator is still module scope, pinned. Fixture gains cached/outer.helper/old/Config; fourteen new tests, parity included. --- .../python/codeanalyzer/codeanalyzer.py | 21 +++- cldk/analysis/python/neo4j/neo4j_backend.py | 13 ++- docs/agent-api-reference.md | 1 + tests/analysis/python/conftest.py | 91 +++++++++++++++++- tests/analysis/python/test_locate.py | 95 +++++++++++++++++++ 5 files changed, 214 insertions(+), 7 deletions(-) diff --git a/cldk/analysis/python/codeanalyzer/codeanalyzer.py b/cldk/analysis/python/codeanalyzer/codeanalyzer.py index 984d4154..776f3c59 100644 --- a/cldk/analysis/python/codeanalyzer/codeanalyzer.py +++ b/cldk/analysis/python/codeanalyzer/codeanalyzer.py @@ -301,12 +301,29 @@ def _resolve_callee( return cs if resolved == cs.callee_signature else cs.model_copy(update={"callee_signature": resolved}) +def _decorator_covers(c: PyCallable, line: int) -> bool: + """Whether ``line`` sits on (or between) the decorators applied to ``c`` -- at or after the first + recorded decorator's line and above ``c.start_line``, the ``def`` line. The same predicate the + Neo4j backend's ``PY_DECORATED_BY`` disjunct evaluates, so the two backends admit the same + callable for a decorator position (#408). + + A decorator whose ``span`` was not recorded (every analysis from codeanalyzer-python 1.5.1 or + earlier) is skipped: nothing is inferred from the decorator's mere presence, so such a position + stays module scope exactly as before. Line-level only, deliberately -- the span starts at the + decorator *expression*, one column past the ``@``, so a column test would miss the ``@`` itself. + """ + return any(d.span is not None and d.span.start[0] <= line < c.start_line for d in c.decorators) + + def _find_innermost(module: PyModule, line: int) -> Tuple[PyCallable, "PyClass | None"] | None: """The callable (and its immediate owning class, if any) whose span most tightly contains ``line`` — innermost first, so a closure nested inside a method wins over the method itself. Only real callable spans count: a blank line, a comment, or a gap between two callables' spans - contains no callable and must never snap to the nearest one (see :meth:`locate`). + contains no callable and must never snap to the nearest one (see :meth:`locate`). The one + widening is a decorator line: ``start_line`` is the ``def`` line and the AST puts decorators + above it, so :func:`_decorator_covers` admits the callable a decorator at that position applies + to (#408). The rank stays the def-based width, which is what the Neo4j backend ranks on too. Equal line widths tie — ``def one(self): return lambda: 2`` nests two callables on one line — and lines are all the Neo4j projection carries, so the tie breaks on the *longer signature*: a nested @@ -322,7 +339,7 @@ def consider(c: PyCallable, owner: "PyClass | None") -> None: nonlocal best, best_rank if c.start_line < 0 or c.end_line < 0: return - if not (c.start_line <= line <= c.end_line): + if not (c.start_line <= line <= c.end_line or _decorator_covers(c, line)): return rank = (c.end_line - c.start_line, -len(c.signature), c.signature) if best_rank is None or rank < best_rank: diff --git a/cldk/analysis/python/neo4j/neo4j_backend.py b/cldk/analysis/python/neo4j/neo4j_backend.py index acfe4279..dffcd4b6 100644 --- a/cldk/analysis/python/neo4j/neo4j_backend.py +++ b/cldk/analysis/python/neo4j/neo4j_backend.py @@ -2037,7 +2037,15 @@ def get_config_readers(self, key: str) -> List[PyCallableOverview]: # the position is the innermost callable, which naturally treats a gap between two callables # (or a module top-level line) the same way: no callable matches, so it falls through to # module_scope rather than snapping to a neighbour. PY_HAS_METHOD is walked reversed for the - # owning class (``type``); + # owning class (``type``). ``start_line`` is the ``def`` line (``ast.FunctionDef.lineno``), and + # Python's AST puts decorators *above* it, so a position on ``@http.route(...)`` sits in no + # callable span; the ``EXISTS`` disjunct admits the callable whose ``PY_DECORATED_BY`` edge is + # applied at or before the position and above its ``def`` (#408). The edge's ``start_line`` + # arrived with codeanalyzer-python 1.5.2; on an older graph the comparison is null, the + # disjunct is false, and the position is module scope exactly as before. A decorated *class* + # is not a callable and is deliberately not admitted. Ranking is unchanged — the def-based + # width — so a decorator on a nested callable, which also lies inside the enclosing + # callable's span, still resolves to the nested (narrower) one; # * the **body node** comes from PY_HAS_BODY_NODE off that same candidate callable, again by # line containment, innermost first. Synthetic vertices (@entry / @exit / @formal_in:N) carry # no span, so the emitter prunes their start_line/end_line away entirely — the @@ -2063,7 +2071,8 @@ def get_config_readers(self, key: str) -> List[PyCallableOverview]: "OPTIONAL MATCH (c:PyCallable:PySymbol) " "WHERE c.id STARTS WITH pos.module_prefix " "AND c.start_line IS NOT NULL AND c.end_line IS NOT NULL " - "AND c.start_line <= pos.line AND pos.line <= c.end_line " + "AND ((c.start_line <= pos.line AND pos.line <= c.end_line) " + "OR EXISTS { (c)-[r:PY_DECORATED_BY]->() WHERE r.start_line <= pos.line AND pos.line < c.start_line }) " "WITH pos, m, c " "OPTIONAL MATCH (cls:PyClass)-[:PY_HAS_METHOD]->(c) " "WITH pos, m, c, cls " diff --git a/docs/agent-api-reference.md b/docs/agent-api-reference.md index e10d132f..966d70b4 100644 --- a/docs/agent-api-reference.md +++ b/docs/agent-api-reference.md @@ -525,6 +525,7 @@ class LocateResult: | inside a callable | `callable` is set, no diagnostic | | module top level | `callable is None`, diagnostic `module_scope` — a **real position**, not an absence | | between two callables | same as module scope; it never snaps to the nearest callable | +| on a decorator line (Python) | `callable` is the decorated callable, no diagnostic. `start_line` is the `def` line, so the decorators sit above every span; both backends read where the decorator is *applied* (`decorators[].span` locally, `PY_DECORATED_BY.start_line` on a graph emitted by codeanalyzer-python ≥ 1.5.2; older graphs keep reporting `module_scope`). A **class** decorator is still module scope | | file not analysed | diagnostic `file_not_in_graph` — distinct from a file that doesn't exist | **Gotcha:** on **Python and TypeScript** over Neo4j, module-scope `source` is empty and carries diff --git a/tests/analysis/python/conftest.py b/tests/analysis/python/conftest.py index c14b390e..7cdfc75c 100644 --- a/tests/analysis/python/conftest.py +++ b/tests/analysis/python/conftest.py @@ -34,7 +34,7 @@ from cldk.analysis.python.codeanalyzer.codeanalyzer import PyCodeanalyzer from cldk.analysis.python.neo4j import PyNeo4jBackend -from cldk.models.python import BodyNode, PyApplication, PyCallable, PyClass, PyModule, Span +from cldk.models.python import BodyNode, PyApplication, PyCallable, PyClass, PyDecorator, PyModule, Span # The full vocabulary codeanalyzer-python 1.4.0 emits (see cldk/analysis/python/neo4j/neo4j_backend # .py's module docstring / the leg-1 brief) — a reasonable "healthy v2 graph" default so fixtures @@ -256,6 +256,26 @@ def query_counter(fake_driver: FakeDriver) -> QueryCounter: "", # 27 " def two(self, x):", # 28 " if x: return x", # 29 — two body nodes tie on line width + "", # 30 + " @property", # 31 — first decorator: ast puts it *above* FunctionDef.lineno (#408) + " @functools.lru_cache(maxsize=8)", # 32 — second decorator + " def cached(self):", # 33 — start_line + " return 3", # 34 + "", # 35 + " def outer(self):", # 36 + " @staticmethod", # 37 — a decorator on a *nested* callable, inside outer's span + " def helper():", # 38 + " return 4", # 39 + " return helper", # 40 + "", # 41 + " @legacy", # 42 — a decorator whose span the analyzer did not record (pre-1.5.2 shape) + " def old(self):", # 43 + " return 5", # 44 + "", # 45 + "", # 46 + "@dataclass", # 47 — a *class* decorator: no callable applies, so still module scope + "class Config:", # 48 + " x: int = 0", # 49 ] _LOCATE_MODULE_SOURCE = "".join(line + "\n" for line in _LOCATE_SOURCE_LINES) @@ -294,6 +314,9 @@ def _locate_code(start_line: int, end_line: int) -> str: # analysis vertices (@entry/@exit/@formal_in:N) that carry no span and can never contain a position. # ``has_span`` False is a callable the analyzer emitted with no span at all — an abstract method or a # protocol stub — whose source is unrecoverable, and which must degrade rather than raise. +# ``decorators`` lists (name, start_line, end_line) where each decorator is *applied*; ``None`` lines +# are a decorator the analyzer recorded without a span (every graph from 1.5.1 or earlier, and the +# local ``PyDecorator.span`` default), which must leave ``locate`` exactly where it was (#408). _LOCATE_CALLABLE_SPECS = [ { "signature": "src.app.Store.Meta.tag", @@ -374,6 +397,45 @@ def _locate_code(start_line: int, end_line: int) -> str: "has_span": False, "body": {}, }, + { + "signature": "src.app.Store.cached", + "name": "cached", + "start_line": 33, # the ``def`` line; the decorators sit on 31-32, above it + "end_line": 34, + "class_signature": "src.app.Store", + "has_span": True, + "body": {"34:8": ("return", 34, 34)}, + "decorators": [("property", 31, 31), ("functools.lru_cache", 32, 32)], + }, + { + "signature": "src.app.Store.outer", + "name": "outer", + "start_line": 36, + "end_line": 40, + "class_signature": "src.app.Store", + "has_span": True, + "body": {"40:8": ("return", 40, 40)}, + }, + { + "signature": "src.app.Store.outer..helper", + "name": "helper", + "start_line": 38, + "end_line": 39, + "class_signature": None, + "has_span": True, + "body": {"39:12": ("return", 39, 39)}, + "decorators": [("staticmethod", 37, 37)], # line 37 is also inside outer's 36-40 + }, + { + "signature": "src.app.Store.old", + "name": "old", + "start_line": 43, + "end_line": 44, + "class_signature": "src.app.Store", + "has_span": True, + "body": {"44:8": ("return", 44, 44)}, + "decorators": [("legacy", None, None)], # recorded, but with no span + }, ] _LOCATE_SPEC = {c["signature"]: c for c in _LOCATE_CALLABLE_SPECS} @@ -396,6 +458,7 @@ def _locate_pycallable(spec: dict, **children: Any) -> PyCallable: start_line=spec["start_line"], end_line=spec["end_line"], body={key: BodyNode(kind=kind, span=_locate_span(s, e) if s is not None else None) for key, (kind, s, e) in spec["body"].items()}, + decorators=[PyDecorator(name=name, expression=f"@{name}", span=_locate_span(s, e) if s is not None else None) for name, s, e in spec.get("decorators", [])], **children, ) @@ -404,6 +467,7 @@ def _locate_application() -> PyApplication: """The fixture module as the in-process analyzer would hand it over.""" inner = _locate_pycallable(_LOCATE_SPEC["src.app.Store.wrap..inner"]) lam = _locate_pycallable(_LOCATE_SPEC["src.app.Store.one.."]) + helper = _locate_pycallable(_LOCATE_SPEC["src.app.Store.outer..helper"]) meta = PyClass( name="Meta", signature="src.app.Store.Meta", @@ -418,13 +482,19 @@ def _locate_application() -> PyApplication: "one": _locate_pycallable(_LOCATE_SPEC["src.app.Store.one"], callables={"": lam}), "two": _locate_pycallable(_LOCATE_SPEC["src.app.Store.two"]), "stub": _locate_pycallable(_LOCATE_SPEC["src.app.Store.stub"]), + "cached": _locate_pycallable(_LOCATE_SPEC["src.app.Store.cached"]), + "outer": _locate_pycallable(_LOCATE_SPEC["src.app.Store.outer"], callables={"helper": helper}), + "old": _locate_pycallable(_LOCATE_SPEC["src.app.Store.old"]), }, types={"src.app.Store.Meta": meta}, ) + # A decorated *class*: the decorator applies to no callable, so a position on it stays module + # scope on both backends (#408 leaves the class case for its own decision). + config = PyClass(name="Config", signature="src.app.Config", decorators=[PyDecorator(name="dataclass", expression="@dataclass", span=_locate_span(47, 47))]) module = PyModule( file_path=_LOCATE_MODULE_PATH, module_name="src.app", - types={"src.app.Store": store}, + types={"src.app.Store": store, "src.app.Config": config}, source=_LOCATE_MODULE_SOURCE, ) return PyApplication(symbol_table={_LOCATE_MODULE_PATH: module}) @@ -482,6 +552,21 @@ def _locate_body_props(spec: dict) -> list[dict]: ] +def _locate_contains(spec: dict, line: int, query: str) -> bool: + """The callable WHERE clause, evaluated the way Cypher would. + + Plain span containment always; the ``PY_DECORATED_BY`` disjunct (#408) only when the query + actually names the relationship, and then only over decorator edges that *carry* a + ``start_line`` -- ``r.start_line <= pos.line`` is null, hence false, on an edge with no span, + exactly as on a graph emitted before codeanalyzer-python 1.5.2. + """ + if spec["start_line"] <= line <= spec["end_line"]: + return True + if "PY_DECORATED_BY" not in query: + return False + return any(s is not None and s <= line < spec["start_line"] for _, s, _ in spec.get("decorators", [])) + + def _locate_row(idx, module_props=None, callable_props=None, class_props=None, body_props=None) -> dict: return {"idx": idx, "module_props": module_props, "callable_props": callable_props, "class_props": class_props, "body_props": body_props} @@ -520,7 +605,7 @@ def _locate_responder(query: str, params: dict) -> list[dict]: rows.append(_locate_row(pos["idx"])) # no :PyModule for this file_key continue # ``OPTIONAL MATCH (c:PyCallable) WHERE c.id STARTS WITH pos.module_prefix AND ...`` - matches = [c for c in _LOCATE_CALLABLE_SPECS if _locate_callable_id(c["signature"]).startswith(pos["module_prefix"]) and c["start_line"] <= pos["line"] <= c["end_line"]] + matches = [c for c in _LOCATE_CALLABLE_SPECS if _locate_callable_id(c["signature"]).startswith(pos["module_prefix"]) and _locate_contains(c, pos["line"], query)] if not matches: rows.append(_locate_row(pos["idx"], _LOCATE_MODULE_PROPS)) continue diff --git a/tests/analysis/python/test_locate.py b/tests/analysis/python/test_locate.py index 607e0fa4..ab1fa241 100644 --- a/tests/analysis/python/test_locate.py +++ b/tests/analysis/python/test_locate.py @@ -355,3 +355,98 @@ def test_locate_body_key_column_is_parsed_not_string_compared(): assert body_key_column("@entry") == -1 # synthetic vertices carry no column assert "29:10" < "29:4" # ...which is why the string comparison had to go assert body_key_column("29:10") > body_key_column("29:4") + + +# ================================================================================================ +# Decorator lines (#408). ``PyCallable.start_line`` is the ``def`` line -- codeanalyzer-python sets +# it from ``ast.FunctionDef.lineno``, and Python's AST puts decorators *above* that line -- so a +# position on a decorator sits inside no callable span and used to fall through to ``module_scope``. +# A routed controller's ``@http.route(...)`` line is not module scope; it is the method's. Both +# backends read the applied position the analyzer already records: ``PyCallable.decorators[].span`` +# locally, ``PY_DECORATED_BY.start_line`` on the graph (codeanalyzer-python 1.5.2). +# ================================================================================================ +def test_locate_decorator_line_resolves_to_the_decorated_method(py_either): + """Line 31 is ``@property``, two lines above ``def cached``.""" + r = py_either.locate("src/app.py", 31) + assert r.callable.signature == "src.app.Store.cached" + assert r.type.signature == "src.app.Store" + assert r.callable.class_signature == "src.app.Store" + assert r.diagnostics == [] + assert r.body is None # no body node starts above the def line + + +def test_locate_every_line_from_the_first_decorator_to_end_line_is_the_method(py_either): + """A position between two decorators of one callable (line 32) resolves to it as well.""" + for line in (31, 32, 33, 34): + r = py_either.locate("src/app.py", line) + assert r.callable is not None, line + assert r.callable.signature == "src.app.Store.cached", line + assert r.diagnostics == [], line + + +def test_locate_decorator_on_a_nested_callable_resolves_to_the_nested_one(py_either): + """Line 37 (``@staticmethod``) lies inside ``outer``'s span *and* above ``helper``'s def. + ``helper`` wins on width, as the existing innermost rule says -- asserted, not assumed.""" + r = py_either.locate("src/app.py", 37) + assert r.callable.signature == "src.app.Store.outer..helper" + assert r.callable.class_signature is None + assert r.type is None + assert r.diagnostics == [] + # ...and one line up, the enclosing method still owns the position. + assert py_either.locate("src/app.py", 36).callable.signature == "src.app.Store.outer" + + +def test_locate_blank_line_between_callables_is_still_module_scope(py_either): + """The widening must not turn into a nearest-callable fallback: a blank line above a decorated + callable (30) and one below it (35) contain no callable and no decorator.""" + for line in (30, 35, 41): + r = py_either.locate("src/app.py", line) + assert r.callable is None, line + assert r.type is None, line + assert "module_scope" in [d.code for d in r.diagnostics], line + + +def test_locate_class_decorator_is_still_module_scope(py_either): + """``@dataclass`` on line 47 applies to a *class*, not a callable. A result with ``type`` set and + ``callable`` unset would be a new shape; until that is decided, the position keeps today's + behaviour -- pinned so the out-of-scope decision is visible rather than assumed.""" + r = py_either.locate("src/app.py", 47) + assert r.callable is None + assert r.type is None + assert "module_scope" in [d.code for d in r.diagnostics] + + +def test_locate_decorator_without_a_recorded_span_keeps_todays_behaviour(py_either): + """``@legacy`` on line 42 is recorded with no span -- the shape of every analysis and graph from + codeanalyzer-python 1.5.1 or earlier. Nothing raises, and the position is module scope exactly + as before; the ``def`` line itself (43) still resolves.""" + r = py_either.locate("src/app.py", 42) + assert r.callable is None + assert "module_scope" in [d.code for d in r.diagnostics] + assert py_either.locate("src/app.py", 43).callable.signature == "src.app.Store.old" + + +def test_locate_parity_decorator_positions_agree(py, py_local): + """Both backends, position by position, over every decorator-adjacent line the fixture has.""" + + def probe(backend, line): + r = backend.locate("src/app.py", line) + return ( + r.callable.signature if r.callable else None, + r.type.signature if r.type else None, + r.body is None, + [d.code for d in r.diagnostics if d.code != "module_source_unavailable"], + ) + + lines = [30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 47] + assert [probe(py, line) for line in lines] == [probe(py_local, line) for line in lines] + + +def test_locate_query_reads_the_decorator_edge_and_keeps_the_pysymbol_seek(py, fake_driver): + """The widening is one ``EXISTS`` disjunct over ``PY_DECORATED_BY``, and it must not cost the + per-module index seek ``test_locate_and_resolve_seek_the_pysymbol_index`` pins.""" + py.locate("src/app.py", 31) + statement = next(s for s in fake_driver.statements if "UNWIND $positions AS pos" in s) + assert "OPTIONAL MATCH (c:PyCallable:PySymbol) " in statement + assert "[r:PY_DECORATED_BY]" in statement + assert "r.start_line <= pos.line AND pos.line < c.start_line" in statement From d80a1d7d45651663870ab8038140100dd4d58d2d Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Wed, 16 Sep 2026 00:44:19 -0400 Subject: [PATCH 61/65] docs(python): record the #408 locate benchmark above _LOCATE_QUERY odoo-slim-19 re-emitted by codeanalyzer-python 1.5.3, 40 positions, median of 5, same graph both ways: 71.0 ms placing 20/40 before the PY_DECORATED_BY disjunct, 70.2 ms placing 40/40 with it; the plan still seeks pysymbol_id. --- cldk/analysis/python/neo4j/neo4j_backend.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/cldk/analysis/python/neo4j/neo4j_backend.py b/cldk/analysis/python/neo4j/neo4j_backend.py index dffcd4b6..75ac0fd6 100644 --- a/cldk/analysis/python/neo4j/neo4j_backend.py +++ b/cldk/analysis/python/neo4j/neo4j_backend.py @@ -2064,6 +2064,13 @@ def get_config_readers(self, key: str) -> List[PyCallableOverview]: # 1.4.1's own ``:PyCanNode(id)`` range index was measured and rejected: it spans all 955,961 # application nodes, so seeking it walked a 40x larger range (locate 54 ms, but # ``_RESOLVE_CALLABLE_QUERY`` 19 -> 210 ms), and 1.4.0 graphs have no such label at all. + # + # The ``PY_DECORATED_BY`` disjunct (#408) adds one relationship expansion per candidate + # callable. Measured on odoo-slim-19 re-emitted by codeanalyzer-python 1.5.3 at level 1 (1,626 + # modules, 15,549 callables, 5,615 decorator edges), 40 positions (20 decorator lines, 20 body + # lines), median of 5, same graph both ways: 71.0 ms without the disjunct placing 20/40, 70.2 ms + # with it placing 40/40. The plan still seeks ``pysymbol_id`` (``NodeUniqueIndexSeekByRange``); + # the disjunct runs as a ``SelectOrSemiApply`` over the seek's rows, never as a scan. _LOCATE_QUERY = ( "UNWIND $positions AS pos " "OPTIONAL MATCH (:PyApplication {id: $app_id})-[:PY_HAS_MODULE]->(m:PyModule {file_key: pos.path}) " From d48a74d78fa7fd0f306a29d21e8dd700c080cd4d Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Wed, 16 Sep 2026 00:47:01 -0400 Subject: [PATCH 62/65] docs(changelog): #408 locate on decorator lines --- CHANGELOG.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 808a15cb..ee887a36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [v2.0.0-rc.8] - 2026-09-15 +### Fixed + +- **`locate` places a position on a decorator line on the decorated callable, on both Python + backends** (`python-sdk#408`). `PyCallable.start_line` is the `def` line (`ast.FunctionDef.lineno`) + and Python's AST puts decorators above it, so `@http.route(...)` sat inside no callable span and + came back as `module_scope`. Both backends now read where the decorator is *applied*: + `_find_innermost` admits the callable whose `decorators[].span` starts at or before the line and + above its `def`; `_LOCATE_QUERY` gains one `EXISTS` disjunct over `PY_DECORATED_BY.start_line`, + which codeanalyzer-python 1.5.2 emits. Ranking is unchanged (the def-based width), so a decorator + on a nested callable resolves to the nested one. Three things deliberately stay as they were: a + position between two callables with no decorator over it is still `module_scope` (this is not a + nearest-callable fallback); a **class** decorator is still `module_scope` (a result with `type` + set and `callable` unset is a new shape and needs its own decision); and an analysis or graph from + codeanalyzer-python 1.5.1 or earlier, which records no decorator span, answers exactly as before. + Measured on odoo-slim-19 re-emitted by 1.5.3, 40 positions, median of 5, same graph both ways: + 71.0 ms placing 20/40 → 70.2 ms placing 40/40; the plan still seeks `pysymbol_id`. + ### Changed - `codeanalyzer-python` pin `1.5.2` → **`1.5.3`** (`dependencies` and `[tool.backend-versions]`, From ce459467b7d875a86cf710a2c58f5890cff001b7 Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Wed, 16 Sep 2026 04:21:28 -0400 Subject: [PATCH 63/65] chore(release): 2.0.0-rc.8 --- CHANGELOG.md | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ee887a36..cb4d470f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [v2.0.0-rc.8] - 2026-09-15 +## [v2.0.0-rc.8] - 2026-09-16 ### Fixed diff --git a/pyproject.toml b/pyproject.toml index 24a57609..177da5dd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "cldk" -version = "2.0.0-rc.7" +version = "2.0.0-rc.8" description = "The official Python SDK for Codellm-Devkit." readme = "README.md" license = { text = "Apache-2.0" } From 32cda35b9be5b32c0e43feba9a7e454203737314 Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Wed, 16 Sep 2026 07:01:13 -0400 Subject: [PATCH 64/65] docs: split the README into a user page, an architecture page and a citations page (#416) The README keeps what a user needs: install, quick start, the query surface by family, backend selection, and where to read next. The contributor module map moves to docs/architecture.md (linked from CONTRIBUTING) and the Cited By list to docs/citations.md, both verbatim. The agent guide gains two operational notes: project_path and the analysis cache, and the JDK a Java call graph needs from level 2 up. --- CONTRIBUTING.md | 3 + README.md | 220 +++++++++++++++--------------------- docs/agent-api-reference.md | 10 ++ docs/architecture.md | 30 +++++ docs/citations.md | 22 ++++ 5 files changed, 157 insertions(+), 128 deletions(-) create mode 100644 docs/architecture.md create mode 100644 docs/citations.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 21c4bd59..27ca168e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -21,6 +21,9 @@ We welcome PRs across all our repositories! When submitting: ## Development Guidelines +### Repository layout +- See [docs/architecture.md](docs/architecture.md) for how the SDK's models, backends, and shared result types fit together + ### Code Quality - Follow the repository's established patterns - Include appropriate documentation diff --git a/README.md b/README.md index 44f672c3..37e952bd 100644 --- a/README.md +++ b/README.md @@ -26,83 +26,98 @@ # Codellm-Devkit (CLDK) -**A unified, multilingual program-analysis SDK for Code LLMs.** CLDK turns raw source code into structured, LLM-ready program facts — symbol tables, call graphs, type hierarchies, and more — behind a single Python API, so you can build analysis-augmented LLM pipelines without wrangling a different static-analysis tool for every language. +CLDK is a Python SDK that runs static program analysis on Java, Python, and TypeScript code. AI agents and LLM applications query code in all three languages through one API. Support for other languages is in development. [Krishna et al. (2025)](https://doi.org/10.1145/3696630.3728555) describe the design and how it supplies program-analysis context to LLMs that work on code. -Under the hood, CLDK orchestrates mature analysis engines (WALA, Tree-sitter, Jedi, PyCG, ts-morph) and normalizes their output into consistent, typed [Pydantic](https://docs.pydantic.dev/) models. You get the same ergonomic interface whether you are analyzing Java, Python, or TypeScript. +CLDK unifies language-specific static-analysis tools, formats, and program models so agents need not reconcile them. Each analyzer emits one shared schema. The SDK maps that schema onto typed [Pydantic](https://docs.pydantic.dev/) models. The agent queries those models through the API. The API answers the questions an agent asks while it reads, tests, or changes code: -CLDK is: +- Which callable contains this line? +- What calls it, and what does it call? +- How does a value move through the program? +- Which callables are entry points? +- Which packages does the project depend on? +- Which code reads a configuration key? -- **Unified** — one framework and one mental model across languages and analysis backends. -- **Extensible** — designed to take on new languages, engines, and graph backends (e.g. Neo4j). -- **Streamlined** — raw code in, structured LLM-ready facts out, with the tooling complexity hidden. +Two backends answer every query. The local backend runs the analyzer. The Neo4j backend reads a graph the analyzer wrote earlier and never runs the analyzer. Both give the same answer. Where a backend cannot answer, it refuses and says why. It never returns an empty value that reads like a real answer. -> Developed at IBM Research. CLDK is an actively evolving project — issues and contributions are welcome. - -## Table of Contents - -- [Cited By](#cited-by) -- [Installation](#installation) -- [Quick Start](#quick-start) -- [Supported Languages & Backends](#supported-languages--backends) -- [Architecture](#architecture) -- [Documentation](#documentation) -- [Contributing](#contributing) -- [Citation](#citation) -- [Maintainers](#maintainers) +CLDK is developed at IBM Software Innovation Labs. Issues and contributions are welcome. ## Installation +CLDK requires Python 3.11 or newer. + ```bash -pip install cldk +pip install cldk # Python and TypeScript analysis +pip install "cldk[java]" # adds the Java analyzer: the jar and a bundled JVM +pip install "cldk[neo4j]" # adds the read-only Neo4j backend +pip install "cldk[all]" # everything above ``` -Optional extras: +**Java call graphs need a JDK.** The bundled JVM is enough for the symbol table. From the call-graph level up, the analyzer compiles the project with its Maven or Gradle wrapper. `JAVA_HOME` must then point to a JDK with `javac` (Java 11 or newer). Without one, the analyzer still exits 0 but emits a call graph with declared edges only. CLDK logs the degradation at `WARNING`. ```bash -pip install "cldk[neo4j]" # read-only Neo4j graph backend (Java / Python / TypeScript) +export JAVA_HOME=/path/to/jdk # must contain bin/javac ``` +**Upgrading from 1.x.** The old entry point `CLDK(language="java").analysis(...)` still works. It emits a `DeprecationWarning`. Use the factory methods. + ## Quick Start -Create a language-specific analysis facade with the per-language factory methods, then query it: +The three steps below take a Python project from disk to a question about its code. Java and TypeScript facades answer the same calls, so the same steps apply with a different factory. -```python -from cldk import CLDK +1. Create a facade. Each factory returns a typed analysis facade for its language. The call-graph level covers all three steps. -# Pick a language — each returns a typed analysis facade. -analysis = CLDK.java(project_path="/path/to/java/project") -# analysis = CLDK.python(project_path="/path/to/python/project") -# analysis = CLDK.typescript(project_path="/path/to/ts/project") -``` + ```python + from cldk import CLDK + from cldk.analysis import AnalysisLevel -Walk the symbol table and pull method bodies: + analysis = CLDK.python( + project_path="/path/to/python/project", + analysis_level=AnalysisLevel.call_graph, + ) + # CLDK.java(...) and CLDK.typescript(...) take the same arguments. + ``` -```python -from cldk import CLDK +2. Find the callable at a line, and read its source. -analysis = CLDK.java(project_path="/path/to/java/project") + ```python + hit = analysis.locate("src/app/handlers.py", line=42) + print(hit.callable.signature if hit.callable else "module scope") + print(hit.source) # the enclosing callable's text + ``` -for file_path, class_file in analysis.get_symbol_table().items(): - for type_name, type_declaration in class_file.type_declarations.items(): - for method in type_declaration.callable_declarations.values(): - body = analysis.get_method_body(method.declaration) - print(f"{type_name}.{method.declaration}\n{body}\n") -``` +3. If the line was inside a callable, ask who calls it. -Build a call graph by raising the analysis level: + ```python + for caller in analysis.callers_of(hit.callable.signature): + print(caller.callable, caller.file, caller.line) + ``` -```python -from cldk import CLDK -from cldk.analysis import AnalysisLevel +[Query Surface](#query-surface) lists what else a facade answers. [Backends](#backends) shows how to read a graph from Neo4j instead of running the analyzer. -analysis = CLDK.python( - project_path="/path/to/python/project", - analysis_level=AnalysisLevel.call_graph, -) -call_graph = analysis.get_call_graph() # a networkx.DiGraph -``` +## Query Surface + +Every family below is on all three facades, except where the table says otherwise. The [agent API reference](./docs/agent-api-reference.md) names each accessor with its return type, its cost, and what will mislead you. + +| Family | Question it answers | +| --- | --- | +| Addressing | Which callable owns this line? What does this name resolve to? | +| Symbol table | What does the project declare? | +| Declarations by kind | Which interfaces, enums, records, type aliases, exports, or functions exist? (Java and TypeScript, with a different set on each) | +| Call graph | What calls this, and what does it call? | +| Per-callable graphs | How do control and data flow inside one callable? | +| Slices and paths | What does this value depend on, and where does it flow? | +| Taint | Do these sources reach these sinks? You supply the names. | +| Entry points | Where does execution enter the application? | +| Bulk projections | What are the callables, their bodies, decorators, and call sites, in one pass? | +| Repository artifacts | What does the project depend on, and which code reads which configuration key? | +| View dispatches | Which code renders which template? (Java only) | +| Comments | What do the comments and docstrings say? (Java only) | + +Deeper questions need a higher analysis level. The default level is the symbol table. Pass `analysis_level=` to raise it, as in Quick Start step 1. Where a backend or a language cannot answer, the accessor refuses rather than returning an empty result. The reference lists every such case. + +## Backends -Select a backend by passing a typed config. For example, query a pre-populated graph **read-only** over Neo4j (no source or analyzer run needed): +The type of the `backend=` configuration selects the backend. `CodeAnalyzerConfig` and its subclasses run the analyzer locally. This is the default. `Neo4jConnectionConfig` reads a graph the analyzer emitted earlier with `--emit neo4j`, so no source checkout is needed: ```python from cldk import CLDK @@ -111,107 +126,56 @@ from cldk.analysis.commons.backend_config import Neo4jConnectionConfig analysis = CLDK.python( backend=Neo4jConnectionConfig( uri="bolt://localhost:7687", - application_name="my-app", # the graph is populated out of band + username="neo4j", + password="...", + application_name="my-app", # the --app-name the graph was loaded with ), ) -classes = analysis.get_all_classes() ``` -> **`project_path` with the Neo4j backend:** it's **optional** — the graph is read over Bolt, so you can omit it as shown above. CLDK validates `project_path` only when you actually pass one (it must exist and be a directory, on every backend); passing `None` skips that check. Supply a real path only if you also need on-disk source access (e.g. file content/snippets) alongside the graph. +| Language | Analyzer | Built on | Runs as | +| --- | --- | --- | --- | +| Java | [`codeanalyzer-java`](https://github.com/codellm-devkit/codeanalyzer-java) | JavaParser and WALA | a subprocess on the bundled JVM | +| Python | [`codeanalyzer-python`](https://github.com/codellm-devkit/codeanalyzer-python) | Jedi, the standard-library `ast`, and a vendored Scalpel alias oracle | in-process | +| TypeScript / JavaScript | [`codeanalyzer-typescript`](https://github.com/codellm-devkit/codeanalyzer-typescript) | the TypeScript compiler via ts-morph, Rapid Type Analysis, and a def-use linker | a self-contained binary | -> **Deprecation:** the old `CLDK(language="java").analysis(...)` entry point still works as a thin compatibility shim (it emits a `DeprecationWarning`). Prefer the `CLDK.java()` / `CLDK.python()` / `CLDK.typescript()` factory methods. +The reference's [Attach](./docs/agent-api-reference.md#attach) section and the per-language sections after it list the graph versions each backend accepts. They also show how to migrate an older graph. -## Supported Languages & Backends +## Read Next -Each language is analyzed by a dedicated `codeanalyzer-*` engine; CLDK normalizes the result into typed models exposed through the same API. All three also support an optional **read-only Neo4j backend** — pass a `Neo4jConnectionConfig` and the SDK answers the same queries with Cypher over a graph the analyzer populates out of band (`--emit neo4j`). - -| Language | Analysis engine | What it provides | -| --- | --- | --- | -| **Java** | [`codeanalyzer-java`](https://github.com/codellm-devkit/codeanalyzer-java) | WALA + JavaParser. Bytecode-level call graphs, type hierarchies, symbol resolution, CRUD-operation and entry-point detection. Optional read-only **Neo4j** graph backend. | -| **Python** | [`codeanalyzer-python`](https://github.com/codellm-devkit/codeanalyzer-python) | Jedi with PyCG-based call graphs. Symbol tables, call graphs, and class/method resolution. Optional read-only **Neo4j** graph backend. | -| **TypeScript / JavaScript** | [`codeanalyzer-typescript`](https://github.com/codellm-devkit/codeanalyzer-typescript) | ts-morph with Jelly-based call graphs. Symbols, call graph, types, decorators, and call sites. Optional read-only **Neo4j** graph backend. | - -The backend is selected by the **type** of the `backend=` config you pass to a factory: the in-process analyzer (default) or a `Neo4jConnectionConfig` for the read-only graph backend. - -> **Analysis cache (Python):** caching is owned by `codeanalyzer-python` — the backend virtualenv and analysis cache live under `cache_dir` (default `/.codeanalyzer`). The first run is slower (it provisions the backend virtualenv) and later runs reuse a checksum-validated cache. Add the cache directory to your `.gitignore`. - -## Architecture - -The user interacts only with the top-level `CLDK` interface (`core.py`), which configures the session, initializes the language-specific pipeline, and exposes a high-level, language-agnostic API. Each language module is built from two pieces: **data models** and an **analysis backend**. - -```mermaid -graph TD - User <--> CLDK - CLDK --> M[cldk.models
typed Pydantic schemas] - CLDK --> A[cldk.analysis] - - A --> J[cldk.analysis.java] - A --> P[cldk.analysis.python] - A --> T[cldk.analysis.typescript] - - J --> EJ[codeanalyzer-java
WALA · JavaParser] - P --> EP[codeanalyzer-python
Jedi · PyCG] - T --> ET[codeanalyzer-typescript
ts-morph · Jelly] - - J -. read-only .-> N[(Neo4j)] - P -. read-only .-> N - T -. read-only .-> N -``` - -**Data models** — each language has its own set of Pydantic models under `cldk.models` (`cldk.models.java`, `cldk.models.python`, `cldk.models.typescript`). They give you structured, typed, dot-accessible representations of classes, methods, fields, and statements, with JSON serialization and shared conventions across languages. - -**Analysis backends** — each language has a backend under `cldk.analysis.` that coordinates its engine (see the table above) and maps the result onto the data models. The read-only Neo4j backends (`cldk.analysis..neo4j`) reconstruct the *same* models from a Cypher graph, so they are drop-in interchangeable with the in-process analyzers. Backends are orchestrated internally; you only call high-level methods such as `get_symbol_table()`, `get_method_body(...)`, and `get_call_graph(...)`, and CLDK handles tool coordination, parsing, and marshalling under the hood. - -## Documentation - -Full documentation lives at **[codellm-devkit.info](https://codellm-devkit.info)**. +- [docs/agent-api-reference.md](./docs/agent-api-reference.md) is the full reference. It opens with the four moves most questions decompose into, then lists every accessor with its cost. +- [docs/skills/using-cldk/SKILL.md](./docs/skills/using-cldk/SKILL.md) is a Claude Code skill. It states the invariants an agent must hold. Ids are opaque, levels gate accessors, bounds are never silent, and a refusal is not an empty answer. To install it, copy the directory into your skills folder. +- [docs/architecture.md](./docs/architecture.md) shows how the SDK is laid out, for contributors. +- [codellm-devkit.info](https://codellm-devkit.info) holds the full documentation. ## Contributing -We welcome contributors of all experience levels — see the [CONTRIBUTING](./CONTRIBUTING.md) guide to get started. +We welcome contributors of all experience levels. See the [CONTRIBUTING](./CONTRIBUTING.md) guide to get started. ## Citation If you use CLDK in your research, please cite: ```bibtex -@article{krishna2024codellm, - title = {Codellm-Devkit: A Framework for Contextualizing Code LLMs with Program Analysis Insights}, - author = {Krishna, Rahul and Pan, Rangeet and Pavuluri, Raju and Tamilselvam, Srikanth and Vukovic, Maja and Sinha, Saurabh}, - journal = {arXiv preprint arXiv:2410.13007}, - year = {2024} +@inproceedings{krishna2025codellm, + title = {Codellm-Devkit: A Framework for Contextualizing Code LLMs with Program Analysis Insights}, + author = {Krishna, Rahul and Pan, Rangeet and Pavuluri, Raju and Tamilselvam, Srikanth and Vukovic, Maja and Sinha, Saurabh}, + booktitle = {Proceedings of the 33rd ACM International Conference on the Foundations of Software Engineering (FSE Companion '25)}, + pages = {308--318}, + year = {2025}, + publisher = {ACM}, + doi = {10.1145/3696630.3728555} } ``` -## Cited By - -CLDK ([Krishna et al., 2024](https://arxiv.org/abs/2410.13007)) is used and cited in a growing body of research on program analysis and code LLMs: - -- **SAINT: Service-Level Integration Test Generation with Program Analysis and LLM-Based Agents** — Pan, Pavuluri, Huang, Krishna et al. (2026). *ICSE*. [arXiv:2511.13305](https://arxiv.org/abs/2511.13305) -- **RECON: An LLM-Enhanced Backward Constraint Analysis Framework** — Bappah et al. (2026). [arXiv:2606.10264](https://arxiv.org/abs/2606.10264) -- **Architecting Open, Accountable, and Trustworthy AI-IDEs** — Contreras, Guerra & de Lara (2026). *Automated Software Engineering*. [doi:10.1007/s10515-026-00608-x](https://doi.org/10.1007/s10515-026-00608-x) -- **Resolving Java Code Repository Issues with iSWE Agent** — Ganhotra et al. (2026). [arXiv:2603.11356](https://arxiv.org/abs/2603.11356) -- **HookLens: Visual Analytics for Understanding React Hooks Structures** — Hwang et al. (2026). *IEEE PacificVis*. [arXiv:2602.17891](https://arxiv.org/abs/2602.17891) -- **ASTER: Natural and Multi-Language Unit Test Generation with LLMs** — Pan, Kim, Krishna, Pavuluri & Sinha (2025). *ICSE-SEIP*. [arXiv:2409.03093](https://arxiv.org/abs/2409.03093) -- **PRAXIS: Integrating Program Analysis with Observability for Root-Cause Analysis** — Cui, Krishna & Jha et al. (2025). [arXiv:2512.22113](https://arxiv.org/abs/2512.22113) -- **Examining Software Developers' Needs for Privacy Enforcing Techniques: A Survey** — Theophilou & Kapitsaki (2025). *ACM SAC*. [arXiv:2512.14756](https://arxiv.org/abs/2512.14756) -- **LLM as an Execution Estimator: Recovering Missing Dependency for Practical Time-Travelling Debugging** — Pei, Wang & Zhang et al. (2025). [arXiv:2508.18721](https://arxiv.org/abs/2508.18721) -- **Agentic Multi-Modal LLMs for Software Comprehension: Structuring Code Summarization with Business Process Awareness** — Tamilselvam & Saxena (2025). *IEEE SSE*. [doi:10.1109/SSE67621.2025.00024](https://doi.org/10.1109/SSE67621.2025.00024) -- **Phaedrus: Predicting Dynamic Application Behavior with Lightweight Generative Models and LLMs** — Chatterjee, Jadhav & Pande (2024). *PACMPL (OOPSLA)*. [arXiv:2412.06994](https://arxiv.org/abs/2412.06994) - -List compiled from Semantic Scholar / OpenAlex citation data; please open a PR to add a missing paper. - -Related publications: - -1. Pan, Rangeet, Myeongsoo Kim, Rahul Krishna, Raju Pavuluri, and Saurabh Sinha. "[Multi-language Unit Test Generation using LLMs.](https://arxiv.org/abs/2409.03093)" arXiv preprint arXiv:2409.03093 (2024). -2. Pan, Rangeet, Rahul Krishna, Raju Pavuluri, Saurabh Sinha, and Maja Vukovic. "[Simplify your Code LLM solutions using CodeLLM Dev Kit (CLDK).](https://www.linkedin.com/pulse/simplify-your-code-llm-solutions-using-codellm-dev-kit-rangeet-pan-vnnpe/)" Blog. +Research that uses or cites CLDK is listed in [docs/citations.md](./docs/citations.md). ## Maintainers | Name | Email | | --- | --- | -| Rahul Krishna | [i.m.ralk@gmail.com](mailto:imralk+oss@gmail.com) | -| Rangeet Pan | [rangeet.pan@ibm.com](mailto:rangeet.pan@gmail.com) | +| Rahul Krishna | [i.m.ralk@gmail.com](mailto:i.m.ralk@gmail.com) | +| Rangeet Pan | [rangeet.pan@ibm.com](mailto:rangeet.pan@ibm.com) | | Saurabh Sinha | [sinhas@us.ibm.com](mailto:sinhas@us.ibm.com) | Licensed under the [Apache License 2.0](./LICENSE). diff --git a/docs/agent-api-reference.md b/docs/agent-api-reference.md index 966d70b4..56394269 100644 --- a/docs/agent-api-reference.md +++ b/docs/agent-api-reference.md @@ -26,6 +26,10 @@ py = CLDK.python(project_path=".", backend=Neo4jConnectionConfig( py = CLDK.python(project_path="/path/to/project", backend=PyCodeAnalyzerConfig()) ``` +`project_path` is optional with `Neo4jConnectionConfig`. Pass it when you also want file contents read from disk. Any `project_path` you pass must exist and be a directory, on every backend. + +**Analysis cache.** Local runs write under `cache_dir`. The default is `/.codeanalyzer`, with one subdirectory per language. The Python analyzer also builds a virtualenv for the project there. The first Python run is therefore slower than later runs. Add the directory to your `.gitignore`. + Attaching raises `GraphSchemaMismatch` if the graph was built by a different analyzer generation. That is deliberate: the alternative is every query silently returning zero rows. If you see it, the graph needs re-ingesting — it is not a bug in your query. @@ -322,6 +326,12 @@ java = CLDK.java(backend=Neo4jConnectionConfig( application_name="daytrader8")) ``` +**Java call graphs need a JDK.** The bundled JVM is enough for level 1. From level 2 the analyzer compiles the project with its Maven or Gradle wrapper. `JAVA_HOME` must then point to a JDK with `javac` (Java 11 or newer). Without one, the run still exits 0 but degrades to declared call edges. [Diagnostics](#diagnostics) describes what the SDK records. + +```bash +export JAVA_HOME=/path/to/jdk # must contain bin/javac +``` + **Analyzer floor: codeanalyzer-java 3.2.0** (the pin is `[tool.backend-versions]` in `pyproject.toml` and is equal to the floor; `--emit neo4j` always runs at level 4 and forces external calls). Attaching to a graph refuses rather than answering empty, and the message diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 00000000..2e623571 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,30 @@ +# Architecture of the Python SDK + +This page is for contributors who change the SDK. Users start at the [README](../README.md). + +The `CLDK` factories in `core.py` are the entry point. A factory validates its arguments. It selects a backend by the type of the `backend=` configuration. It then returns the analysis facade for the language. Each language package has two parts: **data models** and an **analysis backend**. + +```mermaid +graph TD + Agent <--> CLDK + CLDK --> M[cldk.models
typed Pydantic schemas] + CLDK --> A[cldk.analysis] + + A --> J[cldk.analysis.java] + A --> P[cldk.analysis.python] + A --> T[cldk.analysis.typescript] + + J --> EJ[codeanalyzer-java
JavaParser · WALA] + P --> EP[codeanalyzer-python
Jedi · ast · Scalpel] + T --> ET[codeanalyzer-typescript
TypeScript compiler · RTA · def-use linker] + + J -. read-only .-> N[(Neo4j)] + P -. read-only .-> N + T -. read-only .-> N +``` + +**Data models** live under `cldk.models.`. They mirror canonical schema v2 as Pydantic models for modules, types, callables, fields, statements, artifacts, and dependencies. The projections (`*CallableOverview`, `*ClassOverview`) are the lightweight forms the bulk accessors return. + +**Analysis backends** live under `cldk.analysis.`. A shared `AnalysisBackend` ABC in `cldk.analysis.commons` declares the core accessors and the artifact, dependency, and configuration accessors. Each language subclasses it once for the local analyzer and once for Neo4j. The local backend runs the analyzer and maps its output onto the models. The Neo4j backend reconstructs the same models from the graph. Both backends therefore answer the same accessors. + +**Shared result types** live in `cldk.analysis.commons.results` and `cldk.analysis.commons.bounds`: `LocateResult`, `EdgePage`, `Slice`, `FlowPaths`, `TaintResult`, `EntrypointCoverage`, `Diagnostic`, and the default bounds. diff --git a/docs/citations.md b/docs/citations.md new file mode 100644 index 00000000..9f3783e8 --- /dev/null +++ b/docs/citations.md @@ -0,0 +1,22 @@ +# Citations + +The following research on program analysis and code LLMs uses or cites CLDK ([Krishna et al., 2025](https://doi.org/10.1145/3696630.3728555)): + +- **SAINT: Service-Level Integration Test Generation with Program Analysis and LLM-Based Agents** — Pan, Pavuluri, Huang, Krishna et al. (2026). *ICSE*. [arXiv:2511.13305](https://arxiv.org/abs/2511.13305) +- **RECON: An LLM-Enhanced Backward Constraint Analysis Framework** — Bappah et al. (2026). [arXiv:2606.10264](https://arxiv.org/abs/2606.10264) +- **Architecting Open, Accountable, and Trustworthy AI-IDEs** — Contreras, Guerra & de Lara (2026). *Automated Software Engineering*. [doi:10.1007/s10515-026-00608-x](https://doi.org/10.1007/s10515-026-00608-x) +- **Resolving Java Code Repository Issues with iSWE Agent** — Ganhotra et al. (2026). [arXiv:2603.11356](https://arxiv.org/abs/2603.11356) +- **HookLens: Visual Analytics for Understanding React Hooks Structures** — Hwang et al. (2026). *IEEE PacificVis*. [arXiv:2602.17891](https://arxiv.org/abs/2602.17891) +- **ASTER: Natural and Multi-Language Unit Test Generation with LLMs** — Pan, Kim, Krishna, Pavuluri & Sinha (2025). *ICSE-SEIP*. [arXiv:2409.03093](https://arxiv.org/abs/2409.03093) +- **PRAXIS: Integrating Program Analysis with Observability for Root-Cause Analysis** — Cui, Krishna & Jha et al. (2025). [arXiv:2512.22113](https://arxiv.org/abs/2512.22113) +- **Examining Software Developers' Needs for Privacy Enforcing Techniques: A Survey** — Theophilou & Kapitsaki (2025). *ACM SAC*. [arXiv:2512.14756](https://arxiv.org/abs/2512.14756) +- **LLM as an Execution Estimator: Recovering Missing Dependency for Practical Time-Travelling Debugging** — Pei, Wang & Zhang et al. (2025). [arXiv:2508.18721](https://arxiv.org/abs/2508.18721) +- **Agentic Multi-Modal LLMs for Software Comprehension: Structuring Code Summarization with Business Process Awareness** — Tamilselvam & Saxena (2025). *IEEE SSE*. [doi:10.1109/SSE67621.2025.00024](https://doi.org/10.1109/SSE67621.2025.00024) +- **Phaedrus: Predicting Dynamic Application Behavior with Lightweight Generative Models and LLMs** — Chatterjee, Jadhav & Pande (2024). *PACMPL (OOPSLA)*. [arXiv:2412.06994](https://arxiv.org/abs/2412.06994) + +List compiled from Semantic Scholar and OpenAlex citation data. Open a PR to add a missing paper. + +Related publications: + +1. Pan, Rangeet, Myeongsoo Kim, Rahul Krishna, Raju Pavuluri, and Saurabh Sinha. "[Multi-language Unit Test Generation using LLMs.](https://arxiv.org/abs/2409.03093)" arXiv preprint arXiv:2409.03093 (2024). +2. Pan, Rangeet, Rahul Krishna, Raju Pavuluri, Saurabh Sinha, and Maja Vukovic. "[Simplify your Code LLM solutions using CodeLLM Dev Kit (CLDK).](https://www.linkedin.com/pulse/simplify-your-code-llm-solutions-using-codellm-dev-kit-rangeet-pan-vnnpe/)" Blog. From 854c6b22c3ce9d261325ad7213cda4d51ddd05fd Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Thu, 15 Oct 2026 14:45:50 -0400 Subject: [PATCH 65/65] chore(release): 2.0.0 (#430) --- CHANGELOG.md | 38 ++++++++++++++++++++++++++++++++++++++ pyproject.toml | 2 +- uv.lock | 2 +- 3 files changed, 40 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cb4d470f..1ecae67a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,44 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [v2.0.0] - 2026-10-15 + +CLDK 2.0 gives Python, Java and TypeScript one agent-facing query surface — addressing, per-callable +control and data flow, slices, call-graph and value-flow predicates, entrypoints and the +repository-artifact layer — answered identically from an `analysis.json` or an attached Neo4j graph. +No code changes since `v2.0.0-rc.8` except the README split into a user page, `docs/architecture.md` +and `docs/citations.md` (#416). The rc sections below are the full record; this section lists what +breaks for code written against **1.5.0**, each with its migration step. + +### Breaking (since 1.5.0) + +- **C support is removed** (`CLDK.c()`, `cldk.analysis.c`, `cldk.models.c`; rc.2). There is no + replacement; pin `cldk<2` if you need it. +- **Java needs the `java` extra.** The analyzer ships as the `codeanalyzer-java` wheel with its own + JVM, not a jar in this repo (rc.3). Install with `pip install "cldk[java]"`. +- **Every Neo4j graph must be re-emitted, into a wiped database.** The `can://` id grammar now puts the + application outermost (`can:////...`, rc.4), and each backend refuses a graph below its + floor at attach with `GraphSchemaMismatch`, rather than answering with zero rows. The floors are + `codeanalyzer-python` 1.5.0, `codeanalyzer-typescript` 1.5.2 and `codeanalyzer-java` 3.2.0 (rc.6). + Re-emit with the pinned analyzer (`--emit neo4j`); there is no in-place upgrade. +- **Java and TypeScript models mirror schema v2** (rc.3). A 1.x `analysis.json` is refused rather than + parsed: re-run the analysis with the pinned analyzer. For Java, `get_call_graph()` nodes are + `"."` strings rather than `(signature, klass)` tuples — read the class from + `cg.nodes[key]["method_detail"].klass`. Local and anonymous class names carry their declaring callable + — take class keys from `get_all_classes()` rather than composing them. `JGraphEdges` is + `{src, dst, prov, weight}`. For TypeScript, `TSCallable` and `TSModule` lost their per-file fields (v2 + keys modules by path). +- **Java's single-file `source_code` mode is gone** (rc.3). Analyze the project directory. +- **`PyCallableOverview.path` is repo-relative**, not an absolute path (rc.2). Stop stripping a + project-root prefix off it. +- **Accessors that only ever raised `NotImplementedError` are removed**: fifteen on `PythonAnalysis` + (rc.2) and `get_entry_point_methods` / `get_service_entry_point_methods` on `TypeScriptAnalysis` + (rc.3). Use `get_entrypoints()` / `get_entrypoint_classes()`. + +### Analyzer pins + +`codeanalyzer-python==1.5.3`, `codeanalyzer-typescript==1.6.0`, `codeanalyzer-java==3.3.3` (extra). + ## [v2.0.0-rc.8] - 2026-09-16 ### Fixed diff --git a/pyproject.toml b/pyproject.toml index 177da5dd..45b2a114 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "cldk" -version = "2.0.0-rc.8" +version = "2.0.0" description = "The official Python SDK for Codellm-Devkit." readme = "README.md" license = { text = "Apache-2.0" } diff --git a/uv.lock b/uv.lock index 9c78cbdb..51f54cfc 100644 --- a/uv.lock +++ b/uv.lock @@ -300,7 +300,7 @@ wheels = [ [[package]] name = "cldk" -version = "2.0.0rc7" +version = "2.0.0" source = { editable = "." } dependencies = [ { name = "codeanalyzer-python" },