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.
- package/INSTALL_TOOLKIT.md +163 -0
- package/README.md +269 -2
- package/adl_drift/__init__.py +6 -0
- package/adl_drift/cli/__init__.py +1 -0
- package/adl_drift/cli/commands.py +993 -0
- package/adl_drift/cli/main.py +744 -0
- package/adl_drift/cortex/__init__.py +1 -0
- package/adl_drift/cortex/embeddings.py +96 -0
- package/adl_drift/cortex/memory.py +1749 -0
- package/adl_drift/cortex/patterns.py +89 -0
- package/adl_drift/cortex/telemetry.py +192 -0
- package/adl_drift/detectors/__init__.py +1 -0
- package/adl_drift/detectors/async_patterns.py +115 -0
- package/adl_drift/detectors/base.py +121 -0
- package/adl_drift/detectors/config.py +122 -0
- package/adl_drift/detectors/data_access.py +128 -0
- package/adl_drift/detectors/decorators.py +122 -0
- package/adl_drift/detectors/dynamic_patterns.py +260 -0
- package/adl_drift/detectors/errors.py +134 -0
- package/adl_drift/detectors/imports.py +132 -0
- package/adl_drift/detectors/logging_patterns.py +108 -0
- package/adl_drift/detectors/routes.py +257 -0
- package/adl_drift/detectors/security.py +236 -0
- package/adl_drift/detectors/taint.py +406 -0
- package/adl_drift/detectors/tests.py +95 -0
- package/adl_drift/detectors/type_conventions.py +81 -0
- package/adl_drift/graph/__init__.py +1 -0
- package/adl_drift/graph/builder.py +2670 -0
- package/adl_drift/graph/cfg.py +461 -0
- package/adl_drift/graph/cluster.py +40 -0
- package/adl_drift/graph/edges.py +86 -0
- package/adl_drift/graph/node.py +90 -0
- package/adl_drift/harness/__init__.py +10 -0
- package/adl_drift/harness/setup.py +832 -0
- package/adl_drift/harness/skills/drift-antigravity.md +19 -0
- package/adl_drift/harness/skills/drift-generic.md +19 -0
- package/adl_drift/harness/skills/drift.md +25 -0
- package/adl_drift/harness/watch.py +84 -0
- package/adl_drift/lang/__init__.py +8 -0
- package/adl_drift/lang/base.py +464 -0
- package/adl_drift/lang/java/__init__.py +6 -0
- package/adl_drift/lang/java/backend.py +97 -0
- package/adl_drift/lang/java/cfg.py +1424 -0
- package/adl_drift/lang/java/config.py +707 -0
- package/adl_drift/lang/java/detectors/__init__.py +29 -0
- package/adl_drift/lang/java/detectors/additional.py +445 -0
- package/adl_drift/lang/java/detectors/base.py +154 -0
- package/adl_drift/lang/java/detectors/data_access.py +148 -0
- package/adl_drift/lang/java/detectors/imports.py +53 -0
- package/adl_drift/lang/java/detectors/routes.py +159 -0
- package/adl_drift/lang/java/detectors/security.py +386 -0
- package/adl_drift/lang/java/detectors/tests.py +74 -0
- package/adl_drift/lang/java/extractor.py +582 -0
- package/adl_drift/lang/java/ids.py +99 -0
- package/adl_drift/lang/java/parser.py +49 -0
- package/adl_drift/lang/java/resolution/__init__.py +1 -0
- package/adl_drift/lang/java/resolution/constants.py +1619 -0
- package/adl_drift/lang/java/resolution/constructors.py +278 -0
- package/adl_drift/lang/java/resolution/context.py +305 -0
- package/adl_drift/lang/java/resolution/dispatch.py +540 -0
- package/adl_drift/lang/java/resolution/functional.py +1318 -0
- package/adl_drift/lang/java/resolution/generated.py +95 -0
- package/adl_drift/lang/java/resolution/model.py +153 -0
- package/adl_drift/lang/java/resolution/orchestration.py +866 -0
- package/adl_drift/lang/java/resolution/overloads.py +1275 -0
- package/adl_drift/lang/java/resolution/scopes.py +1111 -0
- package/adl_drift/lang/java/resolution/types.py +2947 -0
- package/adl_drift/lang/java/resolver.py +1401 -0
- package/adl_drift/lang/java/scopes.py +69 -0
- package/adl_drift/lang/java/taint.py +500 -0
- package/adl_drift/lang/java/type_provenance.py +342 -0
- package/adl_drift/lang/python/__init__.py +8 -0
- package/adl_drift/lang/python/backend.py +94 -0
- package/adl_drift/lang/python/cfg.py +537 -0
- package/adl_drift/lang/python/extractor.py +454 -0
- package/adl_drift/lang/python/parser.py +41 -0
- package/adl_drift/lang/python/resolver.py +1789 -0
- package/adl_drift/lang/python/taint.py +365 -0
- package/adl_drift/lang/registry.py +117 -0
- package/adl_drift/parser/__init__.py +1 -0
- package/adl_drift/parser/cache.py +38 -0
- package/adl_drift/parser/core.py +33 -0
- package/adl_drift/parser/extractor.py +17 -0
- package/adl_drift/parser/literal_eval.py +326 -0
- package/adl_drift/scanner/__init__.py +1 -0
- package/adl_drift/scanner/discovery.py +338 -0
- package/adl_drift/scanner/incremental.py +261 -0
- package/adl_drift/scanner/parallel.py +224 -0
- package/adl_drift/server/__init__.py +1 -0
- package/adl_drift/server/handlers.py +2849 -0
- package/adl_drift/server/mcp_server.py +127 -0
- package/adl_drift/server/mcp_stdio.py +335 -0
- package/adl_drift/server/protocol.py +71 -0
- package/adl_drift/server/static/index.html +696 -0
- package/adl_drift/server/web.py +350 -0
- package/adl_drift/utils/__init__.py +1 -0
- package/adl_drift/utils/config.py +77 -0
- package/adl_drift/utils/fswalk.py +52 -0
- package/adl_drift/utils/types.py +345 -0
- package/bin/adl-drift.mjs +35 -0
- package/bin/adl-toolkit.mjs +488 -0
- package/package.json +29 -4
- package/pyproject.toml +49 -0
- package/scripts/install_toolkit.py +3584 -0
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
"""Python intra-function taint mechanics (P2.19, ported unchanged onto the
|
|
2
|
+
P4.6 seam).
|
|
3
|
+
|
|
4
|
+
`PythonTaintStrategy` is the exact pre-P4.6 `detectors.taint._analyze_one`
|
|
5
|
+
CST walk, now a `detectors.taint.TaintStrategy` subclass reading its
|
|
6
|
+
source/sink tables from `self.config` (a `CFGConfig`) instead of hardcoded
|
|
7
|
+
module constants. No behavioral change for Python — `default_python_taint_
|
|
8
|
+
strategy()` below is what `lang/python/cfg.py::default_python_cfg_config()`
|
|
9
|
+
packages `TAINT_SINKS`/`SINK_METHODS` into, so every existing call site sees
|
|
10
|
+
byte-identical output.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import Dict, List, Optional, Set, Tuple
|
|
16
|
+
|
|
17
|
+
from tree_sitter import Node
|
|
18
|
+
|
|
19
|
+
from adl_drift.detectors.taint import TaintPath, TaintStrategy, _OutCall
|
|
20
|
+
from adl_drift.lang.base import CFGConfig
|
|
21
|
+
from adl_drift.utils.types import SourcePosition
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"TAINT_SINKS",
|
|
25
|
+
"SINK_METHODS",
|
|
26
|
+
"PythonTaintStrategy",
|
|
27
|
+
"default_python_taint_strategy",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
# ─── sinks ────────────────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
# Exact dotted/identifier callable text → vulnerability category.
|
|
33
|
+
TAINT_SINKS: Dict[str, str] = {
|
|
34
|
+
"eval": "code_injection",
|
|
35
|
+
"exec": "code_injection",
|
|
36
|
+
"os.system": "command_injection",
|
|
37
|
+
"os.popen": "command_injection",
|
|
38
|
+
"subprocess.run": "command_injection",
|
|
39
|
+
"subprocess.Popen": "command_injection",
|
|
40
|
+
"subprocess.call": "command_injection",
|
|
41
|
+
"subprocess.check_call": "command_injection",
|
|
42
|
+
"subprocess.check_output": "command_injection",
|
|
43
|
+
"pickle.load": "unsafe_deserialization",
|
|
44
|
+
"pickle.loads": "unsafe_deserialization",
|
|
45
|
+
"yaml.load": "unsafe_deserialization",
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
# Final attribute name (any receiver) → category. Matches `db.execute(q)`,
|
|
49
|
+
# `conn.cursor().execute(q)`, `self.session.execute(q)`, etc.
|
|
50
|
+
SINK_METHODS: Dict[str, str] = {
|
|
51
|
+
"execute": "sql_injection",
|
|
52
|
+
"executemany": "sql_injection",
|
|
53
|
+
"executescript": "sql_injection",
|
|
54
|
+
"raw": "sql_injection",
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# ─── CST helpers (self-contained; mirror lang/python/cfg.py conventions) ─────
|
|
59
|
+
|
|
60
|
+
def _text(node: Node, source: bytes) -> str:
|
|
61
|
+
try:
|
|
62
|
+
return source[node.start_byte:node.end_byte].decode("utf-8", errors="replace")
|
|
63
|
+
except Exception:
|
|
64
|
+
return ""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _pos(node: Node) -> SourcePosition:
|
|
68
|
+
return SourcePosition(
|
|
69
|
+
line=node.start_point[0] + 1,
|
|
70
|
+
column=node.start_point[1],
|
|
71
|
+
end_line=node.end_point[0] + 1,
|
|
72
|
+
end_column=node.end_point[1],
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _ids_in(node: Optional[Node], source: bytes) -> Set[str]:
|
|
77
|
+
"""Identifier *reads* under *node* (attribute receivers only, not field names)."""
|
|
78
|
+
out: Set[str] = set()
|
|
79
|
+
if node is None:
|
|
80
|
+
return out
|
|
81
|
+
stack = [node]
|
|
82
|
+
while stack:
|
|
83
|
+
n = stack.pop()
|
|
84
|
+
t = n.type
|
|
85
|
+
if t == "attribute":
|
|
86
|
+
obj = n.child_by_field_name("object")
|
|
87
|
+
if obj is not None:
|
|
88
|
+
stack.append(obj)
|
|
89
|
+
continue
|
|
90
|
+
if t == "keyword_argument":
|
|
91
|
+
val = n.child_by_field_name("value")
|
|
92
|
+
if val is not None:
|
|
93
|
+
stack.append(val)
|
|
94
|
+
continue
|
|
95
|
+
if t in ("function_definition", "class_definition", "lambda"):
|
|
96
|
+
continue
|
|
97
|
+
if t == "identifier":
|
|
98
|
+
txt = _text(n, source)
|
|
99
|
+
if txt:
|
|
100
|
+
out.add(txt)
|
|
101
|
+
continue
|
|
102
|
+
stack.extend(n.children)
|
|
103
|
+
return out
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _lhs_names(node: Node, source: bytes) -> List[str]:
|
|
107
|
+
if node.type == "identifier":
|
|
108
|
+
return [_text(node, source)]
|
|
109
|
+
names: List[str] = []
|
|
110
|
+
for child in node.children:
|
|
111
|
+
if child.type not in (",", "(", ")", "[", "]", "*", "."):
|
|
112
|
+
names.extend(_lhs_names(child, source))
|
|
113
|
+
return names
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _param_names(func_node: Node, source: bytes) -> List[str]:
|
|
117
|
+
"""Ordered parameter identifiers (includes ``self``/``cls``)."""
|
|
118
|
+
params = func_node.child_by_field_name("parameters")
|
|
119
|
+
if params is None:
|
|
120
|
+
return []
|
|
121
|
+
names: List[str] = []
|
|
122
|
+
for child in params.children:
|
|
123
|
+
ct = child.type
|
|
124
|
+
if ct == "identifier":
|
|
125
|
+
names.append(_text(child, source))
|
|
126
|
+
elif ct in ("typed_parameter", "default_parameter", "typed_default_parameter",
|
|
127
|
+
"list_splat_pattern", "dictionary_splat_pattern"):
|
|
128
|
+
inner = child.child_by_field_name("name") or (
|
|
129
|
+
child.children[0] if child.children else None
|
|
130
|
+
)
|
|
131
|
+
if inner is not None and inner.type == "identifier":
|
|
132
|
+
names.append(_text(inner, source))
|
|
133
|
+
return names
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _collect_calls(body: Node) -> List[Node]:
|
|
137
|
+
"""Every ``call`` node under *body*, not descending into nested functions."""
|
|
138
|
+
out: List[Node] = []
|
|
139
|
+
stack = [body]
|
|
140
|
+
while stack:
|
|
141
|
+
node = stack.pop()
|
|
142
|
+
if node.type == "call":
|
|
143
|
+
out.append(node)
|
|
144
|
+
if node.type == "function_definition" and node is not body:
|
|
145
|
+
continue
|
|
146
|
+
stack.extend(node.children)
|
|
147
|
+
return out
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _positional_args(call: Node) -> List[Node]:
|
|
151
|
+
args = call.child_by_field_name("arguments")
|
|
152
|
+
if args is None:
|
|
153
|
+
return []
|
|
154
|
+
return [c for c in args.children
|
|
155
|
+
if c.type not in ("(", ")", ",", "comment", "keyword_argument",
|
|
156
|
+
"list_splat", "dictionary_splat")]
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _keyword_args(call: Node, source: bytes) -> List[Tuple[str, Node]]:
|
|
160
|
+
args = call.child_by_field_name("arguments")
|
|
161
|
+
if args is None:
|
|
162
|
+
return []
|
|
163
|
+
out: List[Tuple[str, Node]] = []
|
|
164
|
+
for c in args.children:
|
|
165
|
+
if c.type == "keyword_argument":
|
|
166
|
+
name = c.child_by_field_name("name")
|
|
167
|
+
val = c.child_by_field_name("value")
|
|
168
|
+
if name is not None and val is not None:
|
|
169
|
+
out.append((_text(name, source), val))
|
|
170
|
+
return out
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _callee_simple_name(call: Node, source: bytes) -> Optional[str]:
|
|
174
|
+
"""Final identifier of the call target: ``f`` / ``m.f`` / ``a.b.f`` → ``f``."""
|
|
175
|
+
fn = call.child_by_field_name("function")
|
|
176
|
+
if fn is None:
|
|
177
|
+
return None
|
|
178
|
+
if fn.type == "identifier":
|
|
179
|
+
return _text(fn, source)
|
|
180
|
+
if fn.type == "attribute":
|
|
181
|
+
attr = fn.child_by_field_name("attribute")
|
|
182
|
+
if attr is not None and attr.type == "identifier":
|
|
183
|
+
return _text(attr, source)
|
|
184
|
+
return None
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
class PythonTaintStrategy(TaintStrategy):
|
|
188
|
+
"""Python's `TaintStrategy` (P2.19/P4.6)."""
|
|
189
|
+
|
|
190
|
+
def param_names(self, func_node: Node, source: bytes) -> List[str]:
|
|
191
|
+
return _param_names(func_node, source)
|
|
192
|
+
|
|
193
|
+
def _source_prefix(self, text: str) -> Optional[str]:
|
|
194
|
+
"""Return the taint-source label if *text* begins with a known source."""
|
|
195
|
+
t = text.strip()
|
|
196
|
+
for src in self.config.taint_sources:
|
|
197
|
+
if t.startswith(src):
|
|
198
|
+
return src
|
|
199
|
+
return None
|
|
200
|
+
|
|
201
|
+
def _sink_info(self, call: Node, source: bytes) -> Optional[Tuple[str, str, Node]]:
|
|
202
|
+
"""If *call* is a taint sink, return ``(callable_text, category, arg_node)``.
|
|
203
|
+
|
|
204
|
+
``arg_node`` is the first positional argument (the data the sink consumes).
|
|
205
|
+
"""
|
|
206
|
+
fn = call.child_by_field_name("function")
|
|
207
|
+
if fn is None:
|
|
208
|
+
return None
|
|
209
|
+
func_text = _text(fn, source).strip()
|
|
210
|
+
category = self.config.taint_sinks.get(func_text)
|
|
211
|
+
if category is None and fn.type == "attribute":
|
|
212
|
+
attr = fn.child_by_field_name("attribute")
|
|
213
|
+
if attr is not None:
|
|
214
|
+
category = self.config.sink_methods.get(_text(attr, source))
|
|
215
|
+
if category is None:
|
|
216
|
+
return None
|
|
217
|
+
pos_args = _positional_args(call)
|
|
218
|
+
if not pos_args:
|
|
219
|
+
return None
|
|
220
|
+
return func_text, category, pos_args[0]
|
|
221
|
+
|
|
222
|
+
def analyze_one(
|
|
223
|
+
self,
|
|
224
|
+
func_node: Node,
|
|
225
|
+
source: bytes,
|
|
226
|
+
function_id: str,
|
|
227
|
+
file_path: str,
|
|
228
|
+
seed_params: Set[str],
|
|
229
|
+
) -> Tuple[Set[str], List[TaintPath], List[_OutCall]]:
|
|
230
|
+
body = func_node.child_by_field_name("body")
|
|
231
|
+
if body is None:
|
|
232
|
+
return set(), [], []
|
|
233
|
+
|
|
234
|
+
tainted: Set[str] = set(seed_params)
|
|
235
|
+
source_of: Dict[str, str] = {p: "parameter" for p in seed_params}
|
|
236
|
+
|
|
237
|
+
# ── collect assignment records (lhs names, rhs identifier reads, src label) ──
|
|
238
|
+
assigns: List[Tuple[List[str], Set[str], Optional[str]]] = []
|
|
239
|
+
|
|
240
|
+
def _record_assign(lhs: Optional[Node], rhs: Optional[Node]) -> None:
|
|
241
|
+
if lhs is None or rhs is None:
|
|
242
|
+
return
|
|
243
|
+
names = [n for n in _lhs_names(lhs, source) if n]
|
|
244
|
+
if not names:
|
|
245
|
+
return
|
|
246
|
+
assigns.append((names, _ids_in(rhs, source),
|
|
247
|
+
self._source_prefix(_text(rhs, source))))
|
|
248
|
+
|
|
249
|
+
stack = [body]
|
|
250
|
+
while stack:
|
|
251
|
+
node = stack.pop()
|
|
252
|
+
t = node.type
|
|
253
|
+
if t == "function_definition" and node is not body:
|
|
254
|
+
continue # nested function — separate analysis
|
|
255
|
+
if t == "assignment":
|
|
256
|
+
_record_assign(node.child_by_field_name("left"),
|
|
257
|
+
node.child_by_field_name("right"))
|
|
258
|
+
elif t == "augmented_assignment":
|
|
259
|
+
lhs = node.child_by_field_name("left")
|
|
260
|
+
rhs = node.child_by_field_name("right")
|
|
261
|
+
if lhs is not None and rhs is not None:
|
|
262
|
+
names = [n for n in _lhs_names(lhs, source) if n]
|
|
263
|
+
rhs_ids = _ids_in(rhs, source) | set(names) # x += y keeps x
|
|
264
|
+
assigns.append((names, rhs_ids, self._source_prefix(_text(rhs, source))))
|
|
265
|
+
elif t == "named_expression":
|
|
266
|
+
_record_assign(node.child_by_field_name("name"),
|
|
267
|
+
node.child_by_field_name("value"))
|
|
268
|
+
elif t == "for_statement":
|
|
269
|
+
# `for x in <iter>` taints x when the iterable is tainted
|
|
270
|
+
_record_assign(node.child_by_field_name("left"),
|
|
271
|
+
node.child_by_field_name("right"))
|
|
272
|
+
elif t == "with_item":
|
|
273
|
+
_record_assign(node.child_by_field_name("alias"),
|
|
274
|
+
node.child_by_field_name("value"))
|
|
275
|
+
stack.extend(node.children)
|
|
276
|
+
|
|
277
|
+
# ── propagate to fixpoint ───────────────────────────────────────────────
|
|
278
|
+
changed = True
|
|
279
|
+
while changed:
|
|
280
|
+
changed = False
|
|
281
|
+
for names, rhs_ids, lbl in assigns:
|
|
282
|
+
tainted_rhs = lbl or any(i in tainted for i in rhs_ids)
|
|
283
|
+
if not tainted_rhs:
|
|
284
|
+
continue
|
|
285
|
+
label = lbl or next(
|
|
286
|
+
(source_of.get(i) for i in rhs_ids if i in tainted), None
|
|
287
|
+
)
|
|
288
|
+
for n in names:
|
|
289
|
+
if n not in tainted:
|
|
290
|
+
tainted.add(n)
|
|
291
|
+
source_of[n] = label or "tainted"
|
|
292
|
+
changed = True
|
|
293
|
+
|
|
294
|
+
# ── sink detection ──────────────────────────────────────────────────────
|
|
295
|
+
paths: List[TaintPath] = []
|
|
296
|
+
seen_sites: Set[Tuple[int, int]] = set()
|
|
297
|
+
for call in _collect_calls(body):
|
|
298
|
+
info = self._sink_info(call, source)
|
|
299
|
+
if info is None:
|
|
300
|
+
continue
|
|
301
|
+
callable_text, category, arg_node = info
|
|
302
|
+
site = _pos(call)
|
|
303
|
+
site_key = (site.line, site.column)
|
|
304
|
+
|
|
305
|
+
direct = self._source_prefix(_text(arg_node, source))
|
|
306
|
+
if direct:
|
|
307
|
+
binding: Optional[str] = None
|
|
308
|
+
label = direct
|
|
309
|
+
cross = False
|
|
310
|
+
else:
|
|
311
|
+
arg_ids = _ids_in(arg_node, source)
|
|
312
|
+
hit = next((i for i in arg_ids if i in tainted), None)
|
|
313
|
+
if hit is None:
|
|
314
|
+
continue
|
|
315
|
+
binding = hit
|
|
316
|
+
label = source_of.get(hit, "tainted")
|
|
317
|
+
cross = label == "parameter"
|
|
318
|
+
|
|
319
|
+
if site_key in seen_sites:
|
|
320
|
+
continue
|
|
321
|
+
seen_sites.add(site_key)
|
|
322
|
+
|
|
323
|
+
chain = [label]
|
|
324
|
+
if binding:
|
|
325
|
+
chain.append(binding)
|
|
326
|
+
chain.append(callable_text)
|
|
327
|
+
paths.append(TaintPath(
|
|
328
|
+
function_id=function_id,
|
|
329
|
+
file_path=file_path,
|
|
330
|
+
sink=callable_text,
|
|
331
|
+
sink_category=category,
|
|
332
|
+
source_label=label,
|
|
333
|
+
binding_name=binding,
|
|
334
|
+
sink_site=site,
|
|
335
|
+
chain=chain,
|
|
336
|
+
confidence=0.7 if cross else 0.85,
|
|
337
|
+
cross_function=cross,
|
|
338
|
+
))
|
|
339
|
+
|
|
340
|
+
# ── outgoing tainted calls (for cross-function propagation) ─────────────
|
|
341
|
+
out_calls: List[_OutCall] = []
|
|
342
|
+
for call in _collect_calls(body):
|
|
343
|
+
callee = _callee_simple_name(call, source)
|
|
344
|
+
if not callee:
|
|
345
|
+
continue
|
|
346
|
+
pos_t: Set[int] = set()
|
|
347
|
+
for idx, arg in enumerate(_positional_args(call)):
|
|
348
|
+
if self._source_prefix(_text(arg, source)) or (_ids_in(arg, source) & tainted):
|
|
349
|
+
pos_t.add(idx)
|
|
350
|
+
kw_t: Set[str] = set()
|
|
351
|
+
for kw_name, val in _keyword_args(call, source):
|
|
352
|
+
if self._source_prefix(_text(val, source)) or (_ids_in(val, source) & tainted):
|
|
353
|
+
kw_t.add(kw_name)
|
|
354
|
+
if pos_t or kw_t:
|
|
355
|
+
out_calls.append(_OutCall(
|
|
356
|
+
callee, pos_t, kw_t,
|
|
357
|
+
site_line=call.start_point[0] + 1,
|
|
358
|
+
site_column=call.start_point[1],
|
|
359
|
+
))
|
|
360
|
+
|
|
361
|
+
return tainted, paths, out_calls
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def default_python_taint_strategy(config: CFGConfig) -> PythonTaintStrategy:
|
|
365
|
+
return PythonTaintStrategy(config)
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Backend registry — dispatch by name / file extension / project root.
|
|
2
|
+
|
|
3
|
+
The engine layer (scanner, cli/commands, server/handlers) asks this registry
|
|
4
|
+
for a `LanguageBackend` instead of importing a concrete implementation
|
|
5
|
+
(P4.0/P4.3, PROBLEMS.md). File discovery is still Python-only until P4.8.
|
|
6
|
+
|
|
7
|
+
A simple dict-keyed registry is deliberate — no plugin discovery, no entry
|
|
8
|
+
points. Backends register themselves via `register_backend()`.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Dict, List, Optional
|
|
15
|
+
|
|
16
|
+
from adl_drift.lang.base import LanguageBackend
|
|
17
|
+
from adl_drift.utils.fswalk import iter_tree
|
|
18
|
+
|
|
19
|
+
_REGISTRY: Dict[str, LanguageBackend] = {}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def register_backend(backend: LanguageBackend) -> None:
|
|
23
|
+
"""Register (or replace) a backend under `backend.name`."""
|
|
24
|
+
_REGISTRY[backend.name] = backend
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def get_backend(name: str) -> LanguageBackend:
|
|
28
|
+
"""Look up a backend by name (e.g. "python", "java").
|
|
29
|
+
|
|
30
|
+
Raises `KeyError` if no backend is registered under that name — there is
|
|
31
|
+
no silent Python fallback, so an unregistered language fails loudly.
|
|
32
|
+
"""
|
|
33
|
+
try:
|
|
34
|
+
return _REGISTRY[name]
|
|
35
|
+
except KeyError:
|
|
36
|
+
raise KeyError(
|
|
37
|
+
f"No language backend registered for {name!r}. "
|
|
38
|
+
f"Registered backends: {sorted(_REGISTRY)}"
|
|
39
|
+
) from None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def backend_for_file(path: Path) -> Optional[LanguageBackend]:
|
|
43
|
+
"""Dispatch a file path to the backend claiming its extension.
|
|
44
|
+
|
|
45
|
+
Returns `None` when no registered backend claims the extension (e.g.
|
|
46
|
+
`.java` today, since no Java backend is registered yet) — callers decide
|
|
47
|
+
whether that's an error or a file to skip.
|
|
48
|
+
"""
|
|
49
|
+
suffix = Path(path).suffix
|
|
50
|
+
for backend in _REGISTRY.values():
|
|
51
|
+
if suffix in backend.file_extensions:
|
|
52
|
+
return backend
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def detect_backends(root: Path) -> List[LanguageBackend]:
|
|
57
|
+
"""Scan a project root for which registered languages are present.
|
|
58
|
+
|
|
59
|
+
A backend is considered present if any of its `project_markers` exist
|
|
60
|
+
directly under `root`, or if any file with one of its
|
|
61
|
+
`file_extensions` exists anywhere under `root`.
|
|
62
|
+
|
|
63
|
+
MCP_GRAPH_CACHE_PLAN.md Lớp 2: backends without a root marker are
|
|
64
|
+
probed together in one ``os.walk`` that stops as soon as every pending
|
|
65
|
+
backend has been seen, instead of one full ``rglob`` per backend per
|
|
66
|
+
extension (proving a language is *absent* used to cost a whole-tree
|
|
67
|
+
walk each). ``rglob`` matched directory entries too, so directory names
|
|
68
|
+
count as well.
|
|
69
|
+
"""
|
|
70
|
+
root = Path(root)
|
|
71
|
+
backends = list(_REGISTRY.values())
|
|
72
|
+
present = {
|
|
73
|
+
backend.name for backend in backends
|
|
74
|
+
if any((root / marker).exists() for marker in backend.project_markers)
|
|
75
|
+
}
|
|
76
|
+
pending: Dict[str, str] = {
|
|
77
|
+
ext: backend.name
|
|
78
|
+
for backend in backends if backend.name not in present
|
|
79
|
+
for ext in backend.file_extensions
|
|
80
|
+
}
|
|
81
|
+
if pending:
|
|
82
|
+
for _dirpath, dirs, others in iter_tree(str(root)):
|
|
83
|
+
for entry in dirs + others:
|
|
84
|
+
for ext, backend_name in list(pending.items()):
|
|
85
|
+
if entry.name.endswith(ext):
|
|
86
|
+
present.add(backend_name)
|
|
87
|
+
pending = {
|
|
88
|
+
e: b for e, b in pending.items() if b != backend_name
|
|
89
|
+
}
|
|
90
|
+
if not pending:
|
|
91
|
+
break
|
|
92
|
+
return [backend for backend in backends if backend.name in present]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _register_default_backends() -> None:
|
|
96
|
+
"""Best-effort registration of bundled backends.
|
|
97
|
+
|
|
98
|
+
Wrapped in try/except so importing `adl_drift.lang.registry` never
|
|
99
|
+
fails outright even if the shim's optional dependencies are missing —
|
|
100
|
+
matches this module's job as pure dispatch scaffolding, not a hard
|
|
101
|
+
dependency on any concrete backend.
|
|
102
|
+
"""
|
|
103
|
+
try:
|
|
104
|
+
from adl_drift.lang.python.backend import PythonBackend
|
|
105
|
+
if "python" not in _REGISTRY:
|
|
106
|
+
register_backend(PythonBackend())
|
|
107
|
+
except Exception:
|
|
108
|
+
pass
|
|
109
|
+
try:
|
|
110
|
+
from adl_drift.lang.java.backend import JavaBackend
|
|
111
|
+
if "java" not in _REGISTRY:
|
|
112
|
+
register_backend(JavaBackend())
|
|
113
|
+
except Exception:
|
|
114
|
+
pass
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
_register_default_backends()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Tree-sitter parser wrapper for Python source files."""
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Minimal in-memory parse cache (SHA256-keyed). Disk persistence stubbed."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Dict, Optional
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ParseCache:
|
|
11
|
+
def __init__(self, cache_dir: Optional[Path] = None) -> None:
|
|
12
|
+
self.cache_dir = cache_dir
|
|
13
|
+
self._mem: Dict[str, Any] = {}
|
|
14
|
+
|
|
15
|
+
@staticmethod
|
|
16
|
+
def _file_hash(path: Path) -> str:
|
|
17
|
+
return hashlib.sha256(path.read_bytes()).hexdigest()
|
|
18
|
+
|
|
19
|
+
def get(self, path: Path) -> Optional[Any]:
|
|
20
|
+
try:
|
|
21
|
+
key = self._file_hash(path)
|
|
22
|
+
except OSError:
|
|
23
|
+
return None
|
|
24
|
+
return self._mem.get(key)
|
|
25
|
+
|
|
26
|
+
def put(self, path: Path, value: Any) -> None:
|
|
27
|
+
try:
|
|
28
|
+
key = self._file_hash(path)
|
|
29
|
+
except OSError:
|
|
30
|
+
return
|
|
31
|
+
self._mem[key] = value
|
|
32
|
+
|
|
33
|
+
def invalidate(self, path: Path) -> None:
|
|
34
|
+
try:
|
|
35
|
+
key = self._file_hash(path)
|
|
36
|
+
except OSError:
|
|
37
|
+
return
|
|
38
|
+
self._mem.pop(key, None)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Tree-sitter Python parser wrapper.
|
|
2
|
+
|
|
3
|
+
Generalized into `lang/base.py::LanguageParser` (P4.1, PROBLEMS.md) — the
|
|
4
|
+
grammar-agnostic parsing logic (error-node collection, byte-offset ->
|
|
5
|
+
`SourcePosition` conversion, `node_text`) now lives there, constructed with a
|
|
6
|
+
`tree_sitter.Language` + query map instead of a hardcoded global. This module
|
|
7
|
+
re-exports the Python-configured parser under its original names
|
|
8
|
+
(`PythonParser`, `ParseResult`, `PYTHON_LANGUAGE`, `QUERIES`) so existing call
|
|
9
|
+
sites (`DriftCommands`, `MCPHandlers`, `scanner/parallel.py::_make_components`,
|
|
10
|
+
and everything else importing from here) keep working unchanged.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from adl_drift.lang.base import LanguageParser, ParseResult
|
|
16
|
+
from adl_drift.lang.python.parser import PYTHON_LANGUAGE, QUERIES
|
|
17
|
+
|
|
18
|
+
__all__ = ["ParseResult", "PythonParser", "PYTHON_LANGUAGE", "QUERIES"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class PythonParser(LanguageParser):
|
|
22
|
+
"""Python-configured `LanguageParser`.
|
|
23
|
+
|
|
24
|
+
Kept as a subclass (rather than a bare factory function) so direct
|
|
25
|
+
`PythonParser()` construction keeps working for every existing caller.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(self) -> None:
|
|
29
|
+
if PYTHON_LANGUAGE is None:
|
|
30
|
+
raise RuntimeError(
|
|
31
|
+
"tree-sitter-python not installed. pip install tree-sitter tree-sitter-python"
|
|
32
|
+
)
|
|
33
|
+
super().__init__(PYTHON_LANGUAGE, QUERIES)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Symbol table extractor — walks CST and produces Symbol records.
|
|
2
|
+
|
|
3
|
+
Moved to `lang/python/extractor.py` (P4.2, PROBLEMS.md) as part of
|
|
4
|
+
generalizing symbol extraction behind the `LanguageBackend` contract: the
|
|
5
|
+
shared parse/dedupe/`AnalysisResult`-assembly plumbing now lives in
|
|
6
|
+
`lang/base.py::SymbolExtractor`, and the Python-specific CST walk lives in
|
|
7
|
+
`lang/python/extractor.py`. This module re-exports the Python-configured
|
|
8
|
+
extractor under its original name so existing call sites (`DriftCommands`,
|
|
9
|
+
`MCPHandlers`, `scanner/parallel.py::_make_components`, and everything else
|
|
10
|
+
importing from here) keep working unchanged.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from adl_drift.lang.python.extractor import SymbolExtractor
|
|
16
|
+
|
|
17
|
+
__all__ = ["SymbolExtractor"]
|