From fb626d7a9e6496824ace5dea522f3944869a470e Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Wed, 9 Sep 2026 06:40:21 -0400 Subject: [PATCH 01/50] 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 76295ae..c61939e 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 6974540..cc25975 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 a5ee1acbadb39ef86eace0c33a35ae58052b6355 Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Wed, 9 Sep 2026 06:55:02 -0400 Subject: [PATCH 02/50] 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 c61939e..0f91278 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 cc25975..f1573c2 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 3417c31778e5b8262e79581c0bd4d2eeb1e14d81 Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Wed, 9 Sep 2026 07:56:52 -0400 Subject: [PATCH 03/50] 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 2655d07..1171220 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 da227e3..0c21752 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 70bfc3f..bc57ed8 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 04/50] 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 f1573c2..ac5f25d 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 05/50] 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 ac5f25d..dab0ae7 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 06/50] 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 0f91278..47de828 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 07/50] 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 1171220..03556d6 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 bc57ed8..957fc92 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 08/50] 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 dab0ae7..68b82a0 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 09/50] 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 e1babf0..ee1c4c0 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 0000000..4e022cc --- /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 10/50] =?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 ee1c4c0..ad8f45b 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 11/50] 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 47de828..8d8a621 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 4e022cc..396e000 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 12/50] 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 396e000..159633b 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 13/50] 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 8d8a621..f73fffb 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 68b82a0..235479c 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 14/50] 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 f73fffb..fff8c82 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 6c180d8..9c1960b 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 159633b..7cabd21 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 15/50] 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 1d253a2..61e805f 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 0000000..77e650a --- /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 16/50] 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 a69a58e..fe5f85a 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 0000000..3483eda --- /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 17/50] 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 8b41975..2ef9923 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 0000000..512220e --- /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 18/50] 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 2ef9923..8e14138 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 61e805f..3f92148 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 fe5f85a..27a4d6d 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 512220e..ca020ce 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 77e650a..a70f94c 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 3483eda..0a9dbcb 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 19/50] 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 8e14138..c427c3d 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 3f92148..123066b 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 27a4d6d..6096f04 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 ca020ce..180ae9f 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 a70f94c..68b95f4 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 0a9dbcb..260ea95 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 20/50] 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 c427c3d..30d1028 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 180ae9f..163a51e 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 21/50] 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 fff8c82..61ebd78 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 30d1028..f4f3ec8 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 123066b..ab40699 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 6096f04..7f73524 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 22/50] 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 61ebd78..6b710b6 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 ad8f45b..c60166f 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 2ccfac3..578f69f 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 163a51e..598cb38 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 68b95f4..0ce7fea 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 260ea95..cbff5fc 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 23/50] 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 c60166f..ac6bbcd 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 f4f3ec8..199ca04 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 ab40699..5f82a9e 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 7f73524..b82a5c3 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 598cb38..0fba4ec 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 0ce7fea..8e09986 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 cbff5fc..fc1b898 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 24/50] 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 199ca04..7191ffe 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 03556d6..0f32cd7 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 0fba4ec..185ed66 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 8e09986..27914d8 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 fc1b898..cb55f11 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 25/50] 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 0c21752..3b1aad4 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 9f71d1e..3ae675c 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 26/50] 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 6b710b6..ff542bf 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 ebe8898..69d18c8 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 7cabd21..89ee44b 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 d35f006..ba21b29 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 0000000..74c960c --- /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 27/50] 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 ff542bf..5c437eb 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 69d18c8..6e3a669 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 235479c..7194524 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 89ee44b..bd03744 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 28/50] 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 6e3a669..ec3da99 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 ba21b29..2dbe7e8 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 27914d8..f3333e4 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 29/50] 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 ec3da99..818c218 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 3b1aad4..7f63b9d 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 30/50] 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 74c960c..871889a 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 31/50] 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 7f63b9d..d012550 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 3ae675c..e975e40 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 32/50] 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 5c437eb..3e7c315 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 bd03744..a77d9d6 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 33/50] 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 3ef9069..a0899f4 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 701e097..703912a 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 34/50] 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 957fc92..fc4cf13 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 7bdaad7..05fcf6a 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 cb55f11..e22e219 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 35/50] 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 81459d8..031acc0 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 70127d0..6b9f0db 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 36/50] 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 0f32cd7..00d638c 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 9adb643..1a629bb 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 185ed66..a178864 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 37/50] 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 7191ffe..db19299 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 031acc0..cd59f1f 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 00d638c..e25d3d3 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 5f82a9e..a94c5fb 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 818c218..7c20ff5 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 d012550..fade7db 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 b82a5c3..43fd238 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 a0899f4..f381246 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 fc4cf13..21c3b1e 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 a178864..ce2c217 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 f3333e4..46c8401 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 e22e219..7f96ba4 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 38/50] 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 7194524..6630c08 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 39/50] 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 3e7c315..7805e03 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 6630c08..d7dfba3 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 40/50] 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 b464779..69fb83a 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 85c08ab..3f846ee 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 ba4e052..e21fbfa 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 e77e538..6bfaef2 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 c4d797a..8cd761c 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 41/50] 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 871889a..28a3a24 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 42/50] 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 0000000..3def25b --- /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 0000000..c4a0486 --- /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 43/50] 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 93f412c..b6c1e3e 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 578f69f..40afa0e 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 af3cc9c..de98e1b 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 44/50] 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 28a3a24..e1d16e2 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 45/50] 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 d7dfba3..345e292 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 46/50] 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 cd59f1f..71496d6 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 e25d3d3..9a475d6 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 7c20ff5..e199b83 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 fade7db..86a63f2 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 21c3b1e..ca5765d 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 345e292..556aa7d 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 47/50] 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 a94c5fb..e014b5c 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 e199b83..984d415 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 43fd238..5983633 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 f381246..27fc099 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 ce2c217..bb7e465 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 2dbe7e8..51916b9 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 46c8401..00f9280 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 e1d16e2..9701735 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 703912a..c11ec5c 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 7f96ba4..f3b7354 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 48/50] 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 6b9f0db..3a6fd3e 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 49/50] 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 9c1960b..fef21e2 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 db19299..3fb33af 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 69fb83a..75e3d58 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 e014b5c..085590e 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 3f846ee..834a09d 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 5983633..9a5d2df 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 e21fbfa..96c16c1 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 40afa0e..460deb8 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 50/50] 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 3a6fd3e..52f9da4 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