adl-toolkit 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/INSTALL_TOOLKIT.md +163 -0
  2. package/README.md +269 -2
  3. package/adl_drift/__init__.py +6 -0
  4. package/adl_drift/cli/__init__.py +1 -0
  5. package/adl_drift/cli/commands.py +993 -0
  6. package/adl_drift/cli/main.py +744 -0
  7. package/adl_drift/cortex/__init__.py +1 -0
  8. package/adl_drift/cortex/embeddings.py +96 -0
  9. package/adl_drift/cortex/memory.py +1749 -0
  10. package/adl_drift/cortex/patterns.py +89 -0
  11. package/adl_drift/cortex/telemetry.py +192 -0
  12. package/adl_drift/detectors/__init__.py +1 -0
  13. package/adl_drift/detectors/async_patterns.py +115 -0
  14. package/adl_drift/detectors/base.py +121 -0
  15. package/adl_drift/detectors/config.py +122 -0
  16. package/adl_drift/detectors/data_access.py +128 -0
  17. package/adl_drift/detectors/decorators.py +122 -0
  18. package/adl_drift/detectors/dynamic_patterns.py +260 -0
  19. package/adl_drift/detectors/errors.py +134 -0
  20. package/adl_drift/detectors/imports.py +132 -0
  21. package/adl_drift/detectors/logging_patterns.py +108 -0
  22. package/adl_drift/detectors/routes.py +257 -0
  23. package/adl_drift/detectors/security.py +236 -0
  24. package/adl_drift/detectors/taint.py +406 -0
  25. package/adl_drift/detectors/tests.py +95 -0
  26. package/adl_drift/detectors/type_conventions.py +81 -0
  27. package/adl_drift/graph/__init__.py +1 -0
  28. package/adl_drift/graph/builder.py +2670 -0
  29. package/adl_drift/graph/cfg.py +461 -0
  30. package/adl_drift/graph/cluster.py +40 -0
  31. package/adl_drift/graph/edges.py +86 -0
  32. package/adl_drift/graph/node.py +90 -0
  33. package/adl_drift/harness/__init__.py +10 -0
  34. package/adl_drift/harness/setup.py +832 -0
  35. package/adl_drift/harness/skills/drift-antigravity.md +19 -0
  36. package/adl_drift/harness/skills/drift-generic.md +19 -0
  37. package/adl_drift/harness/skills/drift.md +25 -0
  38. package/adl_drift/harness/watch.py +84 -0
  39. package/adl_drift/lang/__init__.py +8 -0
  40. package/adl_drift/lang/base.py +464 -0
  41. package/adl_drift/lang/java/__init__.py +6 -0
  42. package/adl_drift/lang/java/backend.py +97 -0
  43. package/adl_drift/lang/java/cfg.py +1424 -0
  44. package/adl_drift/lang/java/config.py +707 -0
  45. package/adl_drift/lang/java/detectors/__init__.py +29 -0
  46. package/adl_drift/lang/java/detectors/additional.py +445 -0
  47. package/adl_drift/lang/java/detectors/base.py +154 -0
  48. package/adl_drift/lang/java/detectors/data_access.py +148 -0
  49. package/adl_drift/lang/java/detectors/imports.py +53 -0
  50. package/adl_drift/lang/java/detectors/routes.py +159 -0
  51. package/adl_drift/lang/java/detectors/security.py +386 -0
  52. package/adl_drift/lang/java/detectors/tests.py +74 -0
  53. package/adl_drift/lang/java/extractor.py +582 -0
  54. package/adl_drift/lang/java/ids.py +99 -0
  55. package/adl_drift/lang/java/parser.py +49 -0
  56. package/adl_drift/lang/java/resolution/__init__.py +1 -0
  57. package/adl_drift/lang/java/resolution/constants.py +1619 -0
  58. package/adl_drift/lang/java/resolution/constructors.py +278 -0
  59. package/adl_drift/lang/java/resolution/context.py +305 -0
  60. package/adl_drift/lang/java/resolution/dispatch.py +540 -0
  61. package/adl_drift/lang/java/resolution/functional.py +1318 -0
  62. package/adl_drift/lang/java/resolution/generated.py +95 -0
  63. package/adl_drift/lang/java/resolution/model.py +153 -0
  64. package/adl_drift/lang/java/resolution/orchestration.py +866 -0
  65. package/adl_drift/lang/java/resolution/overloads.py +1275 -0
  66. package/adl_drift/lang/java/resolution/scopes.py +1111 -0
  67. package/adl_drift/lang/java/resolution/types.py +2947 -0
  68. package/adl_drift/lang/java/resolver.py +1401 -0
  69. package/adl_drift/lang/java/scopes.py +69 -0
  70. package/adl_drift/lang/java/taint.py +500 -0
  71. package/adl_drift/lang/java/type_provenance.py +342 -0
  72. package/adl_drift/lang/python/__init__.py +8 -0
  73. package/adl_drift/lang/python/backend.py +94 -0
  74. package/adl_drift/lang/python/cfg.py +537 -0
  75. package/adl_drift/lang/python/extractor.py +454 -0
  76. package/adl_drift/lang/python/parser.py +41 -0
  77. package/adl_drift/lang/python/resolver.py +1789 -0
  78. package/adl_drift/lang/python/taint.py +365 -0
  79. package/adl_drift/lang/registry.py +117 -0
  80. package/adl_drift/parser/__init__.py +1 -0
  81. package/adl_drift/parser/cache.py +38 -0
  82. package/adl_drift/parser/core.py +33 -0
  83. package/adl_drift/parser/extractor.py +17 -0
  84. package/adl_drift/parser/literal_eval.py +326 -0
  85. package/adl_drift/scanner/__init__.py +1 -0
  86. package/adl_drift/scanner/discovery.py +338 -0
  87. package/adl_drift/scanner/incremental.py +261 -0
  88. package/adl_drift/scanner/parallel.py +224 -0
  89. package/adl_drift/server/__init__.py +1 -0
  90. package/adl_drift/server/handlers.py +2849 -0
  91. package/adl_drift/server/mcp_server.py +127 -0
  92. package/adl_drift/server/mcp_stdio.py +335 -0
  93. package/adl_drift/server/protocol.py +71 -0
  94. package/adl_drift/server/static/index.html +696 -0
  95. package/adl_drift/server/web.py +350 -0
  96. package/adl_drift/utils/__init__.py +1 -0
  97. package/adl_drift/utils/config.py +77 -0
  98. package/adl_drift/utils/fswalk.py +52 -0
  99. package/adl_drift/utils/types.py +345 -0
  100. package/bin/adl-drift.mjs +35 -0
  101. package/bin/adl-toolkit.mjs +488 -0
  102. package/package.json +29 -4
  103. package/pyproject.toml +49 -0
  104. package/scripts/install_toolkit.py +3584 -0
@@ -0,0 +1,69 @@
1
+ """Lexical visibility ranges for Java type declarations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Dict, Iterator, List, Tuple
6
+
7
+ from tree_sitter import Node
8
+
9
+ TYPE_DECLARATIONS = {
10
+ "class_declaration", "interface_declaration", "enum_declaration",
11
+ "record_declaration", "annotation_type_declaration",
12
+ }
13
+ # Body node types tree-sitter-java pairs with each `TYPE_DECLARATIONS` entry —
14
+ # *not* uniformly `class_body`: `interface_declaration` → `interface_body`,
15
+ # `enum_declaration` → `enum_body`, `annotation_type_declaration` →
16
+ # `annotation_type_body` (`class_declaration`/`record_declaration` are the
17
+ # only two that actually use `class_body`). Any CST walk that must stop at a
18
+ # nested type's boundary (a local/anonymous type's own scope, separately
19
+ # analyzed) needs *all four*, not just `class_body` — a caller that checks
20
+ # only `class_body` will walk straight through a local enum/interface/
21
+ # `@interface`'s body and misattribute its statements to the enclosing
22
+ # method (PROBLEMS.md P4.6).
23
+ TYPE_BODIES = {"class_body", "interface_body", "enum_body", "annotation_type_body"}
24
+ TypeScopes = Dict[str, List[Tuple[int, int]]]
25
+
26
+
27
+ def _walk(node: Node) -> Iterator[Node]:
28
+ yield node
29
+ for child in node.children:
30
+ yield from _walk(child)
31
+
32
+
33
+ def declaration_scope(node: Node, root: Node) -> Tuple[int, int]:
34
+ """Lexical visibility range for a type declaration `node`: from its own
35
+ start (a local class isn't visible before its declaration point) to the
36
+ end of whichever encloses it — the nearest `block` for a local class, the
37
+ nearest enclosing type's body for a member class, or `root` for a
38
+ top-level class. Shared by `declared_type_scopes` (below) and
39
+ `type_provenance.local_supertypes_of`, so a class's variable-declaration
40
+ scope and its extends/implements scope always agree (PROBLEMS.md P4.6
41
+ F-02: two local classes of the same simple name in sibling methods each
42
+ get their own scope instead of colliding in a single name-keyed table)."""
43
+ parent = node.parent
44
+ while parent is not None and parent is not root:
45
+ if parent.type == "block":
46
+ return (node.start_byte, parent.end_byte)
47
+ if parent.type in TYPE_DECLARATIONS:
48
+ body = parent.child_by_field_name("body")
49
+ scope = body if body is not None else parent
50
+ return (scope.start_byte, scope.end_byte)
51
+ parent = parent.parent
52
+ return (root.start_byte, root.end_byte)
53
+
54
+
55
+ def declared_type_scopes(root: Node, source: bytes) -> TypeScopes:
56
+ scopes: TypeScopes = {}
57
+ for node in _walk(root):
58
+ if node.type not in TYPE_DECLARATIONS:
59
+ continue
60
+ name_node = node.child_by_field_name("name")
61
+ if name_node is None:
62
+ continue
63
+ name = source[name_node.start_byte:name_node.end_byte].decode("utf-8")
64
+ scopes.setdefault(name, []).append(declaration_scope(node, root))
65
+ return scopes
66
+
67
+
68
+ def type_declared_at(scopes: TypeScopes, name: str, at_byte: int) -> bool:
69
+ return any(start <= at_byte < end for start, end in scopes.get(name, ()))
@@ -0,0 +1,500 @@
1
+ """Java intra-function taint mechanics (P4.6, PROBLEMS.md).
2
+
3
+ `JavaTaintStrategy` is `detectors.taint.TaintStrategy`'s Java implementation
4
+ — the CST-coupled half of P2.19's taint tracking, mirroring `lang/python/
5
+ taint.py::PythonTaintStrategy`'s shape but driven by Java's own source/sink
6
+ tables (`lang/java/cfg.py`) and grammar.
7
+
8
+ The one genuinely Java-specific addition beyond a grammar port: a formal
9
+ parameter carrying a request-binding annotation (`@RequestParam` et al.,
10
+ `CFGConfig.taint_source_param_annotations`) is tainted *unconditionally* at
11
+ function entry — not merely "could be tainted, caller decides" the way an
12
+ ordinary parameter is. This is what makes codegraph-java-deep-dive.md
13
+ §10.5's example work: `UserController.search(@RequestParam String q)` is
14
+ itself the taint's origin, with no caller needed to seed it.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from typing import Dict, List, Optional, Set, Tuple
20
+
21
+ from tree_sitter import Node
22
+
23
+ from adl_drift.detectors.taint import TaintPath, TaintStrategy, _OutCall
24
+ from adl_drift.lang.base import CFGConfig
25
+ from adl_drift.lang.java.cfg import (
26
+ SINK_CONSTRUCTOR_ARGUMENTS,
27
+ SINK_CONSTRUCTOR_OWNERS,
28
+ SINK_METHOD_ARGUMENTS,
29
+ SINK_METHOD_EXTERNAL_SUBTYPE_FALLBACKS,
30
+ SINK_METHOD_OWNERS,
31
+ SinkArgumentPolicy,
32
+ TAINT_SOURCE_PARAM_ANNOTATION_OWNERS,
33
+ _annotation_owner_verdict,
34
+ _annotations_of,
35
+ _collect_uses,
36
+ _constructed_receiver_type,
37
+ _enclosing_type_name,
38
+ _erased_type_name,
39
+ _method_parameters,
40
+ _owner_provenance,
41
+ _receiver_type_candidates,
42
+ _source_match_for,
43
+ _text,
44
+ )
45
+ from adl_drift.lang.java.scopes import TYPE_BODIES
46
+ from adl_drift.lang.java.type_provenance import JavaTypeContext, build_type_context
47
+ from adl_drift.utils.types import SourcePosition
48
+
49
+ __all__ = ["JavaTaintStrategy", "default_java_taint_strategy"]
50
+
51
+ # Node types marking a *different* function/type's own scope — walks over a
52
+ # method body must not descend past one of these (mirrors `lang.java.
53
+ # resolution.constants._NESTED_SCOPE_BOUNDARY`'s intent, but correct for
54
+ # every nested-type body tree-sitter-java produces, not just `class_body`;
55
+ # see `scopes.TYPE_BODIES`'s docstring — PROBLEMS.md P4.6).
56
+ _NESTED_SCOPE_BOUNDARY = (
57
+ "method_declaration", "constructor_declaration",
58
+ "compact_constructor_declaration", "lambda_expression", *TYPE_BODIES,
59
+ )
60
+
61
+
62
+ def _pos(node: Node) -> SourcePosition:
63
+ return SourcePosition(
64
+ line=node.start_point[0] + 1,
65
+ column=node.start_point[1],
66
+ end_line=node.end_point[0] + 1,
67
+ end_column=node.end_point[1],
68
+ )
69
+
70
+
71
+ def _ids_in(node: Optional[Node], source: bytes) -> Set[str]:
72
+ """Identifier *reads* under *node* — reuses `lang.java.cfg`'s use-walker
73
+ (already skips method/field names, type positions, annotations, and
74
+ lambda bodies) rather than duplicating that logic."""
75
+ out: Set[str] = set()
76
+ if node is None:
77
+ return out
78
+ events: List = []
79
+ _collect_uses(node, events)
80
+ return {e.name for e in events}
81
+
82
+
83
+ def _this_field_name(node: Optional[Node], source: bytes) -> str:
84
+ if node is None or node.type != "field_access":
85
+ return ""
86
+ obj = node.child_by_field_name("object")
87
+ field = node.child_by_field_name("field")
88
+ if obj is None or field is None or obj.type != "this":
89
+ return ""
90
+ return _text(field, source)
91
+
92
+
93
+ def _taint_ids_in(node: Optional[Node], source: bytes) -> Set[str]:
94
+ """Binding reads for taint propagation, including `this.field` aliases."""
95
+ out = _ids_in(node, source)
96
+ if node is None:
97
+ return out
98
+ stack = [node]
99
+ while stack:
100
+ cur = stack.pop()
101
+ name = _this_field_name(cur, source)
102
+ if name:
103
+ out.add(name)
104
+ continue
105
+ stack.extend(cur.children)
106
+ return out
107
+
108
+
109
+ def _target_names(node: Optional[Node], source: bytes) -> List[str]:
110
+ if node is None:
111
+ return []
112
+ if node.type == "identifier":
113
+ name = _text(node, source)
114
+ return [name] if name else []
115
+ name = _this_field_name(node, source)
116
+ return [name] if name else []
117
+
118
+
119
+ def _positional_args(call: Node) -> List[Node]:
120
+ args = call.child_by_field_name("arguments")
121
+ if args is None:
122
+ return []
123
+ return [c for c in args.children if c.is_named]
124
+
125
+
126
+ def _sink_args(call: Node, policy: SinkArgumentPolicy) -> List[Node]:
127
+ if policy == "receiver":
128
+ # Zero-argument sink (`in.readObject()`) — the node to check for
129
+ # taint is the receiver expression itself, not a positional
130
+ # argument. Reusing the same "list of nodes checked for taint"
131
+ # contract as the argument-index policies below lets the caller's
132
+ # generic sink-taint check (`analyze_one`) handle this case with no
133
+ # extra branching.
134
+ obj = call.child_by_field_name("object")
135
+ return [obj] if obj is not None else []
136
+ pos_args = _positional_args(call)
137
+ if policy == "all":
138
+ return pos_args
139
+ return [pos_args[idx] for idx in policy if 0 <= idx < len(pos_args)]
140
+
141
+
142
+ def _param_names(func_node: Node, source: bytes) -> List[str]:
143
+ params_node = _method_parameters(func_node)
144
+ if params_node is None:
145
+ return []
146
+ names: List[str] = []
147
+ for child in params_node.children:
148
+ if child.type not in ("formal_parameter", "spread_parameter"):
149
+ continue
150
+ name_node = child.child_by_field_name("name")
151
+ if name_node is not None:
152
+ names.append(_text(name_node, source))
153
+ return names
154
+
155
+
156
+ def _annotation_seeds(
157
+ func_node: Node, source: bytes, config: CFGConfig, ctx: Optional[JavaTypeContext],
158
+ ) -> Dict[str, Tuple[str, bool]]:
159
+ """`{param_name: (source_label, confirmed)}` for every formal parameter
160
+ carrying a request-binding annotation (`@RequestParam` et al.).
161
+ `confirmed` is `False` for an annotation that couldn't be positively
162
+ matched against `TAINT_SOURCE_PARAM_ANNOTATION_OWNERS` via import (F-04,
163
+ PROBLEMS.md P4.6) — still seeded, at reduced downstream confidence,
164
+ unless the annotation is *provably* a different, same-named one (a
165
+ local `@interface` declaration, or a conflicting import), in which case
166
+ it's skipped entirely."""
167
+ params_node = _method_parameters(func_node)
168
+ if params_node is None:
169
+ return {}
170
+ seeds: Dict[str, Tuple[str, bool]] = {}
171
+ for child in params_node.children:
172
+ if child.type not in ("formal_parameter", "spread_parameter"):
173
+ continue
174
+ name_node = child.child_by_field_name("name")
175
+ if name_node is None:
176
+ continue
177
+ for raw_name, at_byte in _annotations_of(child, source):
178
+ simple = raw_name.rsplit(".", 1)[-1]
179
+ label = config.taint_source_param_annotations.get(simple)
180
+ if not label:
181
+ continue
182
+ verdict = _annotation_owner_verdict(
183
+ raw_name, at_byte, TAINT_SOURCE_PARAM_ANNOTATION_OWNERS.get(simple), ctx,
184
+ )
185
+ if verdict == "rejected":
186
+ continue
187
+ seeds[_text(name_node, source)] = (label, verdict == "confirmed")
188
+ break
189
+ return seeds
190
+
191
+
192
+ class JavaTaintStrategy(TaintStrategy):
193
+ """Java's `TaintStrategy` (P4.6)."""
194
+
195
+ def __init__(self, config: CFGConfig) -> None:
196
+ super().__init__(config)
197
+ # Per-file import/declared-type provenance (F-04, PROBLEMS.md P4.6),
198
+ # computed once per file and reused for every function `analyze_one`
199
+ # is called on within it — safe to cache for the lifetime of this
200
+ # strategy instance, which is itself scoped to one `CallGraphBuilder`
201
+ # (one scan snapshot; see `CallGraphBuilder._get_taint_strategy`).
202
+ self._type_ctx_cache: Dict[str, JavaTypeContext] = {}
203
+
204
+ def param_names(self, func_node: Node, source: bytes) -> List[str]:
205
+ return _param_names(func_node, source)
206
+
207
+ def _type_context(self, func_node: Node, source: bytes, file_path: str) -> JavaTypeContext:
208
+ ctx = self._type_ctx_cache.get(file_path)
209
+ if ctx is None:
210
+ root = func_node
211
+ while root.parent is not None:
212
+ root = root.parent
213
+ ctx = build_type_context(root, source)
214
+ self._type_ctx_cache[file_path] = ctx
215
+ return ctx
216
+
217
+ def _source_match(
218
+ self, node: Optional[Node], source: bytes, ctx: Optional[JavaTypeContext],
219
+ ) -> Optional[Tuple[str, bool]]:
220
+ return _source_match_for(node, self.config, source, ctx)
221
+
222
+ def _sink_info(
223
+ self, call: Node, is_new: bool, source: bytes, ctx: Optional[JavaTypeContext],
224
+ ) -> Optional[Tuple[str, str, List[Node], bool]]:
225
+ """Returns ``(callable_text, category, pos_args, confirmed)`` — *all*
226
+ positional args, not just the first: Java varargs sinks (`new
227
+ ProcessBuilder(String...)`, `.command(String...)`) spread the
228
+ attacker-controlled data across multiple sibling argument nodes,
229
+ unlike Python's sinks (`eval`, `os.system`) which are always
230
+ single-argument. ``confirmed`` is `False` when the sink name matched
231
+ but its declaring type couldn't be positively confirmed via imports
232
+ (F-04, PROBLEMS.md P4.6) — a business method named `execute`/
233
+ `invoke`/`exec` on a project-owned, unrelated class returns `None`
234
+ entirely (its type is *confirmed* to be something else), not a
235
+ low-confidence match."""
236
+ if is_new:
237
+ type_node = call.child_by_field_name("type")
238
+ if type_node is None:
239
+ return None
240
+ simple = _erased_type_name(_text(type_node, source))
241
+ sink_key = simple if simple in self.config.sink_constructors else None
242
+ if sink_key is None:
243
+ # Fully-qualified constructed type (`new java.lang.
244
+ # ProcessBuilder(...)`), not just the short `ProcessBuilder`
245
+ # form `_erased_type_name` above catches — same FQN gap as
246
+ # `_source_match_for`'s (PROBLEMS.md P4.6 F-04): `simple`
247
+ # above never equals the short `sink_constructors` key for
248
+ # an FQN-spelled type, so it fell through unmatched. Only
249
+ # trusted when `simple` equals the *entire* owner FQN, not a
250
+ # suffix of it, mirroring the source-side fix.
251
+ for cand_key, owner_fqns in SINK_CONSTRUCTOR_OWNERS.items():
252
+ if cand_key in self.config.sink_constructors and simple in owner_fqns:
253
+ sink_key = cand_key
254
+ break
255
+ category = self.config.sink_constructors.get(sink_key) if sink_key else None
256
+ if category is None:
257
+ return None
258
+ pos_args = _sink_args(call, SINK_CONSTRUCTOR_ARGUMENTS.get(sink_key, "all"))
259
+ if not pos_args:
260
+ return None
261
+ verdict = _owner_provenance(
262
+ (simple,), call.start_byte, SINK_CONSTRUCTOR_OWNERS.get(sink_key), ctx,
263
+ )
264
+ if verdict == "rejected":
265
+ return None
266
+ return f"new {sink_key}", category, pos_args, verdict == "confirmed"
267
+
268
+ name_node = call.child_by_field_name("name")
269
+ if name_node is None:
270
+ return None
271
+ method_name = _text(name_node, source)
272
+ category = self.config.sink_methods.get(method_name)
273
+ if category is None:
274
+ return None
275
+ pos_args = _sink_args(call, SINK_METHOD_ARGUMENTS.get(method_name, "all"))
276
+ if not pos_args:
277
+ return None
278
+ obj = call.child_by_field_name("object")
279
+ receiver = _text(obj, source) if obj is not None else ""
280
+ verdict = _owner_provenance(
281
+ _receiver_type_candidates(
282
+ receiver, ctx, call.start_byte, _constructed_receiver_type(obj, source),
283
+ _enclosing_type_name(call, source) if obj is None else "",
284
+ ),
285
+ call.start_byte, SINK_METHOD_OWNERS.get(method_name), ctx,
286
+ allow_external_subtype_fallback=method_name in SINK_METHOD_EXTERNAL_SUBTYPE_FALLBACKS,
287
+ )
288
+ if verdict == "rejected":
289
+ return None
290
+ callable_text = f"{receiver}.{method_name}" if obj is not None else method_name
291
+ return callable_text, category, pos_args, verdict == "confirmed"
292
+
293
+ @staticmethod
294
+ def _collect_call_sites(body: Node) -> List[Tuple[Node, bool]]:
295
+ """`(call_node, is_object_creation)` for every `method_invocation`/
296
+ `object_creation_expression` in `body`, not descending into a nested
297
+ method/constructor/class body or a lambda (each has its own,
298
+ separately-analyzed scope)."""
299
+ out: List[Tuple[Node, bool]] = []
300
+ stack = [body]
301
+ while stack:
302
+ node = stack.pop()
303
+ if node.type == "method_invocation":
304
+ out.append((node, False))
305
+ elif node.type == "object_creation_expression":
306
+ out.append((node, True))
307
+ if node is not body and node.type in _NESTED_SCOPE_BOUNDARY:
308
+ continue
309
+ stack.extend(node.children)
310
+ return out
311
+
312
+ def analyze_one(
313
+ self,
314
+ func_node: Node,
315
+ source: bytes,
316
+ function_id: str,
317
+ file_path: str,
318
+ seed_params: Set[str],
319
+ ) -> Tuple[Set[str], List[TaintPath], List[_OutCall]]:
320
+ body = func_node.child_by_field_name("body")
321
+ if body is None:
322
+ return set(), [], []
323
+
324
+ ctx = self._type_context(func_node, source, file_path)
325
+
326
+ tainted: Set[str] = set(seed_params)
327
+ source_of: Dict[str, str] = {p: "parameter" for p in seed_params}
328
+ # Whether each binding's taint origin was FQN-confirmed (F-04,
329
+ # PROBLEMS.md P4.6) rather than an unresolved simple-name fallback —
330
+ # cross-function seeds have no source/sink match of their own to
331
+ # confirm, so they default to confirmed (their confidence is already
332
+ # governed by the existing cross-function 0.7-vs-0.85 split below).
333
+ confirmed_of: Dict[str, bool] = {p: True for p in seed_params}
334
+
335
+ # Request-binding annotations are an unconditional source, regardless
336
+ # of caller — see module docstring.
337
+ for name, (label, confirmed) in _annotation_seeds(func_node, source, self.config, ctx).items():
338
+ tainted.add(name)
339
+ source_of[name] = label
340
+ confirmed_of[name] = confirmed
341
+
342
+ # ── collect assignment records ──────────────────────────────────
343
+ # `(names, rhs_ids, direct_label, direct_confirmed)` — `direct_label`
344
+ # is set only when the RHS itself is a recognized source expression.
345
+ assigns: List[Tuple[List[str], Set[str], Optional[str], bool]] = []
346
+
347
+ def _record(target_node: Optional[Node], value: Optional[Node]) -> None:
348
+ names = _target_names(target_node, source)
349
+ if not names or value is None:
350
+ return
351
+ match = self._source_match(value, source, ctx)
352
+ label, confirmed = match if match else (None, True)
353
+ assigns.append((names, _taint_ids_in(value, source), label, confirmed))
354
+
355
+ stack = [body]
356
+ while stack:
357
+ node = stack.pop()
358
+ t = node.type
359
+ if node is not body and t in _NESTED_SCOPE_BOUNDARY:
360
+ continue
361
+ if t == "local_variable_declaration":
362
+ for i, child in enumerate(node.children):
363
+ if node.field_name_for_child(i) != "declarator":
364
+ continue
365
+ _record(child.child_by_field_name("name"), child.child_by_field_name("value"))
366
+ elif t == "assignment_expression":
367
+ lhs = node.child_by_field_name("left")
368
+ op = node.child_by_field_name("operator")
369
+ rhs = node.child_by_field_name("right")
370
+ op_text = _text(op, source) if op is not None else "="
371
+ names = _target_names(lhs, source)
372
+ if names and rhs is not None:
373
+ if op_text == "=":
374
+ _record(lhs, rhs)
375
+ else:
376
+ rhs_ids = _taint_ids_in(rhs, source) | set(names)
377
+ match = self._source_match(rhs, source, ctx)
378
+ label, confirmed = match if match else (None, True)
379
+ assigns.append((names, rhs_ids, label, confirmed))
380
+ elif t == "enhanced_for_statement":
381
+ _record(node.child_by_field_name("name"), node.child_by_field_name("value"))
382
+ elif t == "resource":
383
+ _record(node.child_by_field_name("name"), node.child_by_field_name("value"))
384
+ stack.extend(node.children)
385
+
386
+ # ── propagate to fixpoint ─────────────────────────────────────────
387
+ changed = True
388
+ while changed:
389
+ changed = False
390
+ for names, rhs_ids, lbl, lbl_confirmed in assigns:
391
+ tainted_rhs = lbl or any(i in tainted for i in rhs_ids)
392
+ if not tainted_rhs:
393
+ continue
394
+ if lbl:
395
+ label, confirmed = lbl, lbl_confirmed
396
+ else:
397
+ hit = next((i for i in rhs_ids if i in tainted), None)
398
+ label = source_of.get(hit)
399
+ confirmed = confirmed_of.get(hit, True)
400
+ for n in names:
401
+ if n not in tainted:
402
+ tainted.add(n)
403
+ source_of[n] = label or "tainted"
404
+ confirmed_of[n] = confirmed
405
+ changed = True
406
+
407
+ # ── sink detection ────────────────────────────────────────────────
408
+ paths: List[TaintPath] = []
409
+ seen_sites: Set[Tuple[int, int]] = set()
410
+ for call, is_new in self._collect_call_sites(body):
411
+ info = self._sink_info(call, is_new, source, ctx)
412
+ if info is None:
413
+ continue
414
+ callable_text, category, arg_nodes, sink_confirmed = info
415
+ site = _pos(call)
416
+ site_key = (site.line, site.column)
417
+
418
+ direct: Optional[str] = None
419
+ direct_confirmed = True
420
+ binding: Optional[str] = None
421
+ for arg_node in arg_nodes:
422
+ match = self._source_match(arg_node, source, ctx)
423
+ if match:
424
+ direct, direct_confirmed = match
425
+ break
426
+ arg_ids = _taint_ids_in(arg_node, source)
427
+ hit = next((i for i in arg_ids if i in tainted), None)
428
+ if hit is not None:
429
+ binding = hit
430
+ break
431
+ if direct is None and binding is None:
432
+ continue
433
+
434
+ if direct:
435
+ label = direct
436
+ cross = False
437
+ source_confirmed = direct_confirmed
438
+ else:
439
+ label = source_of.get(binding, "tainted")
440
+ cross = label == "parameter"
441
+ source_confirmed = confirmed_of.get(binding, True)
442
+
443
+ if site_key in seen_sites:
444
+ continue
445
+ seen_sites.add(site_key)
446
+
447
+ chain = [label]
448
+ if binding:
449
+ chain.append(binding)
450
+ chain.append(callable_text)
451
+ # Confidence combines the existing same-function/cross-function
452
+ # split with whether *both* the source and sink API were
453
+ # FQN-confirmed (F-04, PROBLEMS.md P4.6) — an unresolved simple-
454
+ # name match on either side is still reported (recall), just at
455
+ # a markedly lower confidence than a fully-confirmed one.
456
+ confirmed = source_confirmed and sink_confirmed
457
+ if confirmed:
458
+ confidence = 0.7 if cross else 0.85
459
+ else:
460
+ confidence = 0.35 if cross else 0.5
461
+ paths.append(TaintPath(
462
+ function_id=function_id,
463
+ file_path=file_path,
464
+ sink=callable_text,
465
+ sink_category=category,
466
+ source_label=label,
467
+ binding_name=binding,
468
+ sink_site=site,
469
+ chain=chain,
470
+ confidence=confidence,
471
+ cross_function=cross,
472
+ ))
473
+
474
+ # ── outgoing tainted calls (for cross-function propagation) ────────
475
+ out_calls: List[_OutCall] = []
476
+ for call, is_new in self._collect_call_sites(body):
477
+ if is_new:
478
+ continue # constructor targets aren't looked up by-name (P4.4 precedent)
479
+ name_node = call.child_by_field_name("name")
480
+ if name_node is None:
481
+ continue
482
+ callee = _text(name_node, source)
483
+ if not callee:
484
+ continue
485
+ pos_t: Set[int] = set()
486
+ for idx, arg in enumerate(_positional_args(call)):
487
+ if self._source_match(arg, source, ctx) or (_taint_ids_in(arg, source) & tainted):
488
+ pos_t.add(idx)
489
+ if pos_t:
490
+ out_calls.append(_OutCall(
491
+ callee, pos_t, set(),
492
+ site_line=call.start_point[0] + 1,
493
+ site_column=call.start_point[1],
494
+ ))
495
+
496
+ return tainted, paths, out_calls
497
+
498
+
499
+ def default_java_taint_strategy(config: CFGConfig) -> JavaTaintStrategy:
500
+ return JavaTaintStrategy(config)