adl-toolkit 0.0.0-stage → 0.1.3
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/adl-drift.md +25 -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/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,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
|
+
)
|