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,386 @@
1
+ """High-signal, single-file Java security checks (taint stays in P4.6)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from pathlib import Path
7
+ from typing import Dict, List, Optional, Set
8
+
9
+ from tree_sitter import Node, Tree
10
+
11
+ from adl_drift.lang.java.detectors.base import DeclaredTypes, JavaDetector
12
+ from adl_drift.lang.java.scopes import TypeScopes
13
+ from adl_drift.utils.types import Pattern, PatternCategory
14
+
15
+ SQL_METHODS = {"execute", "executeQuery", "executeUpdate", "prepareStatement", "createNativeQuery"}
16
+ JDBC_STATEMENT_TYPES = {"Statement", "PreparedStatement", "CallableStatement"}
17
+ SQL_WORDS = re.compile(r"(?i)\b(select|insert|update|delete|merge|drop|alter)\b")
18
+ SECRET_NAME = re.compile(r"(?i)(?:^|_)(password|passwd|secret|token|api_?key)(?:_|$)")
19
+ WEAK_DIGESTS = {"MD2", "MD5", "SHA-1", "SHA1"}
20
+ # JDBC PreparedStatement/CallableStatement parameter-binding setters + JPA's
21
+ # setParameter/registerOutParameter. Deliberately excludes the same-prefixed
22
+ # Statement/Query *configuration* setters (setQueryTimeout, setMaxRows,
23
+ # setFetchSize, setMaxResults, setFirstResult, setHint, setFlushMode,
24
+ # setEscapeProcessing, setCursorName, setPoolable, setFetchDirection,
25
+ # setLargeMaxRows, ...) which bind nothing and must not suppress a real flag.
26
+ BIND_PARAMETER_METHODS = {
27
+ "setArray", "setAsciiStream", "setBigDecimal", "setBinaryStream", "setBlob",
28
+ "setBoolean", "setByte", "setBytes", "setCharacterStream", "setClob",
29
+ "setDate", "setDouble", "setFloat", "setInt", "setLong",
30
+ "setNCharacterStream", "setNClob", "setNString", "setNull", "setObject",
31
+ "setRef", "setRowId", "setSQLXML", "setShort", "setString", "setTime",
32
+ "setTimestamp", "setUnicodeStream", "setURL", "setParameter",
33
+ "registerOutParameter",
34
+ }
35
+ # Scopes _has_subsequent_bind should search up to — method/constructor/lambda
36
+ # bodies, NOT `_tp._declaration_scope`'s notion of scope (which stops at the
37
+ # nearest enclosing `block`, e.g. an `if`/`else` arm — too narrow when the
38
+ # statement variable is assigned inside a branch but bound/executed after it).
39
+ METHOD_SCOPE_TYPES = ("method_declaration", "constructor_declaration", "lambda_expression")
40
+ # `switch_rule` = arrow form (`case 1 -> ...;`, Java 14+, never falls
41
+ # through); `switch_block_statement_group` = classic colon form (`case 1:
42
+ # ...`, falls through to the next group without an explicit terminator).
43
+ SWITCH_GROUP_TYPES = ("switch_rule", "switch_block_statement_group")
44
+ SWITCH_TERMINATOR_TYPES = (
45
+ "break_statement", "return_statement", "throw_statement",
46
+ "continue_statement", "yield_statement",
47
+ )
48
+
49
+
50
+ class JavaSecurityDetector(JavaDetector):
51
+ @property
52
+ def category(self) -> PatternCategory:
53
+ return PatternCategory.SECURITY
54
+
55
+ def detect(self, tree: Tree, source_bytes: bytes, file_path: Path) -> List[Pattern]:
56
+ root = tree.root_node
57
+ imports = self._imports(root, source_bytes)
58
+ types = self._declared_types(root, source_bytes)
59
+ local_types = self._declared_type_names(root, source_bytes)
60
+ patterns: List[Pattern] = []
61
+ for node in self._walk(root):
62
+ if node.type == "method_invocation":
63
+ patterns.extend(self._check_invocation(
64
+ node, source_bytes, file_path, types, imports, local_types
65
+ ))
66
+ elif node.type == "object_creation_expression":
67
+ pattern = self._check_constructor(
68
+ node, source_bytes, file_path, imports, local_types
69
+ )
70
+ if pattern:
71
+ patterns.append(pattern)
72
+ elif node.type == "variable_declarator":
73
+ pattern = self._check_secret(node, source_bytes, file_path)
74
+ if pattern:
75
+ patterns.append(pattern)
76
+ return patterns
77
+
78
+ def _check_invocation(
79
+ self, node: Node, source: bytes, path: Path,
80
+ types: DeclaredTypes, imports: Set[str], local_types: TypeScopes,
81
+ ) -> List[Pattern]:
82
+ method = self._text(node.child_by_field_name("name"), source)
83
+ receiver = self._text(node.child_by_field_name("object"), source)
84
+ first_arg = self._first_argument(node)
85
+ out: List[Pattern] = []
86
+
87
+ runtime_type = receiver.removesuffix(".getRuntime()")
88
+ declared_type = self._declared_type(receiver, types, node)
89
+ if method == "exec" and (
90
+ self._type_has_provenance(
91
+ runtime_type, imports, local_types, "java.lang.Runtime", node
92
+ )
93
+ or self._type_has_provenance(
94
+ declared_type, imports, local_types, "java.lang.Runtime", node
95
+ )
96
+ ):
97
+ out.append(self._finding(
98
+ "runtime_exec", "Runtime.exec executes an operating-system command",
99
+ node, path, 0.95, "HIGH", "Runtime receiver invokes exec",
100
+ ))
101
+ elif method == "readObject" and self._type_has_provenance(
102
+ self._declared_type(receiver, types, node), imports, local_types,
103
+ "java.io.ObjectInputStream", node,
104
+ ):
105
+ out.append(self._finding(
106
+ "unsafe_deserialization", "ObjectInputStream.readObject may instantiate unsafe objects",
107
+ node, path, 0.95, "HIGH", "Java native deserialization call",
108
+ ))
109
+
110
+ raw_sql_type = self._declared_type(receiver, types, node)
111
+ sql_type = self._simple_type(raw_sql_type)
112
+ sql_types = {
113
+ "Statement": ("java.sql.Statement",),
114
+ "PreparedStatement": ("java.sql.PreparedStatement",),
115
+ "CallableStatement": ("java.sql.CallableStatement",),
116
+ "Connection": ("java.sql.Connection",),
117
+ "EntityManager": (
118
+ "jakarta.persistence.EntityManager", "javax.persistence.EntityManager",
119
+ ),
120
+ }
121
+ sql_receiver = any(
122
+ self._type_has_provenance(raw_sql_type, imports, local_types, qualified, node)
123
+ for qualified in sql_types.get(sql_type, ())
124
+ )
125
+ sql_method = method in SQL_METHODS or (
126
+ sql_type in JDBC_STATEMENT_TYPES and method.startswith("execute")
127
+ )
128
+ if sql_method and sql_receiver and first_arg is not None \
129
+ and first_arg.type == "binary_expression" \
130
+ and SQL_WORDS.search(self._text(first_arg, source)):
131
+ confidence = 0.92
132
+ reason = "SQL keyword and concatenation occur in the SQL argument"
133
+ is_bound = method in ("prepareStatement", "createNativeQuery") and (
134
+ self._is_immediately_chained_bind(node, source)
135
+ or self._has_subsequent_bind(self._bound_variable_name(node, source), node, source)
136
+ )
137
+ if is_bound:
138
+ # The statement produced here is bound via setXxx(...)/setParameter(...) before
139
+ # execute — the classic dynamic-IN-clause pattern, where the concatenated text is
140
+ # typically a placeholder list, not attacker data. Can't prove it's safe from
141
+ # syntax alone (a table/column name could still be concatenated in), so this stays
142
+ # a low-confidence lead (CONFIDENCE.md's "Guess" band) rather than a hard flag.
143
+ confidence = 0.35
144
+ reason = (
145
+ "SQL keyword and concatenation occur in the SQL argument, but the statement "
146
+ "is bound via setXxx/setParameter before execute — likely a safe "
147
+ "dynamic-placeholder pattern rather than unparameterized concatenation"
148
+ )
149
+ out.append(self._finding(
150
+ "sql_string_concatenation", "SQL statement is assembled with string concatenation",
151
+ node, path, confidence, "HIGH", reason,
152
+ {"method": method},
153
+ ))
154
+
155
+ if method == "getInstance" and first_arg is not None:
156
+ algorithm = self._string(first_arg, source)
157
+ if self._type_has_provenance(
158
+ receiver, imports, local_types, "java.security.MessageDigest", node) \
159
+ and algorithm.upper() in WEAK_DIGESTS:
160
+ out.append(self._finding(
161
+ "weak_message_digest", f"Weak message digest: {algorithm}",
162
+ node, path, 0.98, "HIGH", "MessageDigest requests a collision-prone algorithm",
163
+ ))
164
+ elif self._type_has_provenance(
165
+ receiver, imports, local_types, "javax.crypto.Cipher", node) \
166
+ and self._weak_cipher(algorithm):
167
+ out.append(self._finding(
168
+ "weak_cipher", f"Weak cipher transformation: {algorithm}",
169
+ node, path, 0.9, "HIGH", "Cipher transformation is obsolete or uses ECB mode",
170
+ ))
171
+
172
+ reflection_types = {
173
+ "AccessibleObject": "java.lang.reflect.AccessibleObject",
174
+ "Field": "java.lang.reflect.Field",
175
+ "Method": "java.lang.reflect.Method",
176
+ "Constructor": "java.lang.reflect.Constructor",
177
+ }
178
+ raw_reflection_type = self._declared_type(receiver, types, node)
179
+ reflection_receiver = any(
180
+ self._type_has_provenance(
181
+ raw_reflection_type, imports, local_types, qualified, node
182
+ )
183
+ for qualified in reflection_types.values()
184
+ ) or any(marker in receiver for marker in ("getDeclaredField(", "getDeclaredMethod("))
185
+ if method == "setAccessible" and reflection_receiver \
186
+ and self._text(first_arg, source) == "true":
187
+ out.append(self._finding(
188
+ "unsafe_reflection_access", "Reflection bypasses Java access checks",
189
+ node, path, 0.9, "MEDIUM", "setAccessible(true) disables member access checks",
190
+ ))
191
+ return out
192
+
193
+ def _bound_variable_name(self, node: Node, source: bytes) -> str:
194
+ parent = node.parent
195
+ if parent is not None and parent.type == "variable_declarator" \
196
+ and parent.child_by_field_name("value") == node:
197
+ return self._text(parent.child_by_field_name("name"), source)
198
+ if parent is not None and parent.type == "assignment_expression" \
199
+ and parent.child_by_field_name("right") == node:
200
+ return self._text(parent.child_by_field_name("left"), source)
201
+ return ""
202
+
203
+ def _is_immediately_chained_bind(self, node: Node, source: bytes) -> bool:
204
+ current = node
205
+ parent = current.parent
206
+ while parent is not None and parent.type == "method_invocation" \
207
+ and parent.child_by_field_name("object") == current:
208
+ if self._text(parent.child_by_field_name("name"), source) in BIND_PARAMETER_METHODS:
209
+ return True
210
+ current = parent
211
+ parent = current.parent
212
+ return False
213
+
214
+ def _enclosing_method_scope(self, node: Node) -> Node:
215
+ parent = node.parent
216
+ while parent is not None and parent.type not in METHOD_SCOPE_TYPES:
217
+ parent = parent.parent
218
+ return parent or node
219
+
220
+ @staticmethod
221
+ def _direct_child_toward(ancestor: Node, descendant: Node) -> Optional[Node]:
222
+ current = descendant
223
+ while current.parent is not None and current.parent != ancestor:
224
+ current = current.parent
225
+ return current if current.parent == ancestor else None
226
+
227
+ def _is_exclusive_branch(self, node: Node, candidate: Node) -> bool:
228
+ """True when `node` and `candidate` sit in different, mutually
229
+ exclusive arms of the same `if`/`else` or `switch` — a reassignment
230
+ or bind call on `candidate`'s side must not affect `node`'s side."""
231
+ node_ancestor_ids = set()
232
+ walker = node
233
+ while walker is not None:
234
+ node_ancestor_ids.add(walker.id)
235
+ walker = walker.parent
236
+ walker = candidate
237
+ while walker is not None and walker.id not in node_ancestor_ids:
238
+ walker = walker.parent
239
+ if walker is None:
240
+ return False
241
+ if walker.type == "if_statement":
242
+ consequence = walker.child_by_field_name("consequence")
243
+ alternative = walker.child_by_field_name("alternative")
244
+ node_branch = self._direct_child_toward(walker, node)
245
+ candidate_branch = self._direct_child_toward(walker, candidate)
246
+ node_side = "consequence" if node_branch == consequence \
247
+ else "alternative" if node_branch == alternative else None
248
+ candidate_side = "consequence" if candidate_branch == consequence \
249
+ else "alternative" if candidate_branch == alternative else None
250
+ return bool(node_side and candidate_side and node_side != candidate_side)
251
+ if walker.type == "switch_block":
252
+ return self._is_exclusive_switch_branch(walker, node, candidate)
253
+ return False
254
+
255
+ def _is_exclusive_switch_branch(self, block: Node, node: Node, candidate: Node) -> bool:
256
+ """True when `node` and `candidate` sit in different `case`/`default`
257
+ arms of the same `switch` that cannot both run for one evaluation of
258
+ the switch subject — arrow-form (`switch_rule`) arms never fall
259
+ through, so any two distinct arms qualify; classic colon-form arms
260
+ (`switch_block_statement_group`) fall through to the next arm unless
261
+ terminated, so every arm from `node`'s through the one just before
262
+ `candidate`'s must end in break/return/throw/continue/yield for the
263
+ two to be considered exclusive — otherwise `candidate`'s reassignment
264
+ can still run on `node`'s own execution path. `block` (the LCA when
265
+ node/candidate sit in different arms) is the `switch_block` itself —
266
+ the groups' direct parent — not the enclosing `switch_expression`,
267
+ which sits one level further up via its `body` field."""
268
+ groups = [c for c in block.children if c.type in SWITCH_GROUP_TYPES]
269
+ node_group = self._direct_child_toward(block, node)
270
+ candidate_group = self._direct_child_toward(block, candidate)
271
+ if node_group not in groups or candidate_group not in groups:
272
+ return False
273
+ if node_group is candidate_group:
274
+ return False
275
+ node_index = groups.index(node_group)
276
+ candidate_index = groups.index(candidate_group)
277
+ if candidate_index <= node_index:
278
+ return False
279
+ if node_group.type == "switch_rule":
280
+ return True
281
+ # Colon-form groups fall through into the *next* physical group when
282
+ # they lack a terminator, so control reaches `candidate_group` unless
283
+ # some group at or after `node_group` (and before `candidate_group`)
284
+ # terminates first — it doesn't matter whether the groups between
285
+ # that point and `candidate_group` fall through, they're never
286
+ # reached from `node_group`'s side either way.
287
+ return any(
288
+ self._switch_group_terminates(group)
289
+ for group in groups[node_index:candidate_index]
290
+ )
291
+
292
+ @staticmethod
293
+ def _switch_group_terminates(group: Node) -> bool:
294
+ return any(child.type in SWITCH_TERMINATOR_TYPES for child in group.children)
295
+
296
+ def _has_subsequent_bind(self, var_name: str, node: Node, source: bytes) -> bool:
297
+ if not var_name:
298
+ return False
299
+ scope = self._enclosing_method_scope(node)
300
+ for candidate in self._walk(scope):
301
+ if candidate.start_byte <= node.end_byte:
302
+ continue
303
+ if self._is_exclusive_branch(node, candidate):
304
+ continue
305
+ if candidate.type == "assignment_expression" \
306
+ and self._text(candidate.child_by_field_name("left"), source) == var_name:
307
+ # var_name is reassigned before any bind call was found for
308
+ # *this* statement — a later statement's bind call belongs to
309
+ # the new value, not the one `node` produced.
310
+ return False
311
+ if candidate.type == "variable_declarator" \
312
+ and self._text(candidate.child_by_field_name("name"), source) == var_name:
313
+ return False
314
+ if candidate.type != "method_invocation":
315
+ continue
316
+ if self._text(candidate.child_by_field_name("object"), source) != var_name:
317
+ continue
318
+ candidate_method = self._text(candidate.child_by_field_name("name"), source)
319
+ if candidate_method in BIND_PARAMETER_METHODS:
320
+ return True
321
+ if candidate_method.startswith("execute"):
322
+ # The statement already ran unbound at this call — a bind call
323
+ # appearing later (dead code, variable reuse, ...) does not
324
+ # retroactively parameterize the query that already executed.
325
+ return False
326
+ return False
327
+
328
+ def _check_constructor(
329
+ self, node: Node, source: bytes, path: Path,
330
+ imports: Set[str], local_types: TypeScopes,
331
+ ) -> Optional[Pattern]:
332
+ type_name = self._text(node.child_by_field_name("type"), source)
333
+ if not self._type_has_provenance(
334
+ type_name, imports, local_types, "java.lang.ProcessBuilder", node
335
+ ):
336
+ return None
337
+ return self._finding(
338
+ "process_builder", "ProcessBuilder creates an operating-system process",
339
+ node, path, 0.9, "HIGH", "ProcessBuilder constructor receives a command",
340
+ )
341
+
342
+ def _check_secret(self, node: Node, source: bytes, path: Path) -> Optional[Pattern]:
343
+ name = self._text(node.child_by_field_name("name"), source)
344
+ normalized_name = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", name).replace("$", "_")
345
+ value = node.child_by_field_name("value")
346
+ if not SECRET_NAME.search(normalized_name) or value is None \
347
+ or value.type != "string_literal":
348
+ return None
349
+ secret = self._string(value, source)
350
+ if len(secret) < 8:
351
+ return None
352
+ return self._finding(
353
+ "hardcoded_secret", f"Possible hardcoded secret: {name}",
354
+ # CONFIDENCE.md's "Pattern confidence" table bands a bare
355
+ # name+length regex guess (no entropy/context check) at 0.3 —
356
+ # this rule is that exact class of FP-prone match (e.g. `String
357
+ # passwordPrompt = "Please enter your password!!";`), so it
358
+ # can't carry a higher score than Python's own regex-only
359
+ # hardcoded-secret detector (0.6). Severity stays HIGH: severity
360
+ # answers "how bad if real", confidence answers "how likely" —
361
+ # the two axes are independent per CONFIDENCE.md.
362
+ node, path, 0.3, "HIGH", "Secret-like variable is assigned a non-trivial string literal",
363
+ {"key_name": name},
364
+ )
365
+
366
+ def _finding(
367
+ self, name: str, description: str, node: Node, path: Path,
368
+ confidence: float, severity: str, reason: str,
369
+ extra: Optional[Dict[str, str]] = None,
370
+ ) -> Pattern:
371
+ return self._make_pattern(
372
+ name=name, description=description, node=node, file_path=path,
373
+ confidence=confidence,
374
+ metadata={"severity": severity, "reason": reason, **(extra or {})},
375
+ )
376
+
377
+ def _string(self, node: Node, source: bytes) -> str:
378
+ if node.type != "string_literal":
379
+ return ""
380
+ raw = self._text(node, source)
381
+ return raw[1:-1] if len(raw) >= 2 else ""
382
+
383
+ @staticmethod
384
+ def _weak_cipher(algorithm: str) -> bool:
385
+ upper = algorithm.upper()
386
+ return upper in {"DES", "DESEDE", "RC2", "RC4", "BLOWFISH"} or "/ECB/" in upper
@@ -0,0 +1,74 @@
1
+ """JUnit and TestNG detector."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import List, Set
7
+
8
+ from tree_sitter import Node, Tree
9
+
10
+ from adl_drift.lang.java.detectors.base import JavaDetector
11
+ from adl_drift.utils.types import Pattern, PatternCategory
12
+
13
+ JUNIT_PACKAGES = ("org.junit", "org.junit.jupiter.api", "org.junit.jupiter.params")
14
+ TESTNG_PACKAGE = "org.testng.annotations"
15
+ TEST_ANNOTATIONS = {"Test", "ParameterizedTest", "RepeatedTest", "TestFactory", "TestTemplate"}
16
+ LIFECYCLE_ANNOTATIONS = {
17
+ "Before", "After", "BeforeClass", "AfterClass", "BeforeEach", "AfterEach",
18
+ "BeforeAll", "AfterAll", "BeforeMethod", "AfterMethod", "BeforeSuite", "AfterSuite",
19
+ }
20
+
21
+
22
+ class JavaTestDetector(JavaDetector):
23
+ @property
24
+ def category(self) -> PatternCategory:
25
+ return PatternCategory.TESTS
26
+
27
+ def detect(self, tree: Tree, source_bytes: bytes, file_path: Path) -> List[Pattern]:
28
+ imports = self._imports(tree.root_node, source_bytes)
29
+ patterns: List[Pattern] = []
30
+ for node in self._walk(tree.root_node):
31
+ if node.type == "method_declaration":
32
+ for annotation in self._annotations(node):
33
+ name = self._annotation_name(annotation, source_bytes).rsplit(".", 1)[-1]
34
+ framework = self._framework(annotation, source_bytes, imports)
35
+ if framework and name in TEST_ANNOTATIONS:
36
+ patterns.append(self._finding(
37
+ "java_test", framework, name, node, file_path, 0.99,
38
+ f"Method uses {framework} @{name}",
39
+ ))
40
+ elif framework and name in LIFECYCLE_ANNOTATIONS:
41
+ patterns.append(self._finding(
42
+ "test_lifecycle", framework, name, node, file_path, 0.98,
43
+ f"Method uses {framework} lifecycle annotation @{name}",
44
+ ))
45
+ elif node.type == "class_declaration" and "extends TestCase" in self._text(node, source_bytes):
46
+ if any(i.startswith("junit.framework.TestCase") for i in imports):
47
+ patterns.append(self._finding(
48
+ "junit3_testcase", "junit", "TestCase", node, file_path, 0.98,
49
+ "Class extends imported junit.framework.TestCase",
50
+ ))
51
+ return patterns
52
+
53
+ def _framework(self, annotation: Node, source: bytes, imports: Set[str]) -> str:
54
+ name = self._annotation_name(annotation, source)
55
+ simple = name.rsplit(".", 1)[-1]
56
+ if any(self._annotation_is(annotation, source, imports, f"{p}.{simple}")
57
+ for p in JUNIT_PACKAGES):
58
+ return "junit"
59
+ if self._annotation_is(annotation, source, imports, f"{TESTNG_PACKAGE}.{simple}"):
60
+ return "testng"
61
+ return ""
62
+
63
+ def _finding(
64
+ self, name: str, framework: str, annotation: str, node: Node,
65
+ path: Path, confidence: float, reason: str,
66
+ ) -> Pattern:
67
+ return self._make_pattern(
68
+ name=name,
69
+ description=f"{framework} test annotation: @{annotation}",
70
+ node=node,
71
+ file_path=path,
72
+ confidence=confidence,
73
+ metadata={"framework": framework, "annotation": annotation, "reason": reason},
74
+ )