@hoanghainh/komagu 2.4.0 → 2.5.0
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/CHANGELOG.md +24 -0
- package/LICENSE +202 -0
- package/README.md +1 -0
- package/assess/lsa/scripts/detect_stack.py +13 -5
- package/assess/lsa/scripts/find_injection.py +35 -2
- package/assess/lsa/scripts/java_routes.py +323 -0
- package/assess/lsa/scripts/lsa_common.py +72 -2
- package/assess/lsa/scripts/map_routes.py +36 -160
- package/assess/lsa/scripts/merge_findings.py +2 -0
- package/assess/lsa/tests/test_java_legacy_realistic.py +453 -0
- package/contracts/schemas/persona-suite.schema.json +38 -2
- package/examples/member-portal/backend-dotnet/Dockerfile +2 -1
- package/examples/member-portal/backend-spring/Dockerfile +2 -0
- package/examples/member-portal/docker-compose.yml +1 -1
- package/examples/member-portal/frontend/Dockerfile +3 -2
- package/examples/member-portal/frontend/package-lock.json +24 -656
- package/examples/member-portal/frontend/package.json +1 -1
- package/operate/ai/runner.py +20 -2
- package/operate/cli/__init__.py +1 -1
- package/operate/cli/commands/fix.py +4 -0
- package/operate/cli/commands/leak_check.py +75 -0
- package/operate/cli/commands/protect.py +32 -3
- package/operate/cli/commands/test.py +131 -15
- package/operate/cli/main.py +5 -3
- package/operate/cross_user_tester/persona_matrix.py +118 -25
- package/operate/cross_user_tester/runner.py +190 -7
- package/operate/cross_user_tester/session_config.py +180 -0
- package/operate/cross_user_tester/session_http.py +288 -0
- package/operate/devsecops/leak_check.py +314 -0
- package/operate/remediator/runner.py +18 -1
- package/operate/remediator/strategies.py +2 -2
- package/operate/simulate/scenarios.py +2 -2
- package/operate/tests/mock_legacy_session_app.py +160 -0
- package/operate/tests/test_cross_user_session.py +565 -0
- package/operate/tests/test_docker_packaging.py +3 -3
- package/operate/tests/test_headless_ai.py +6 -0
- package/operate/tests/test_leak_check.py +250 -0
- package/operate/tests/test_legacy_session_edge.py +192 -0
- package/operate/tests/test_pilot_safeguards.py +245 -0
- package/operate/tests/test_remediator_java_legacy.py +330 -0
- package/package.json +2 -1
- package/remediate/authz/patcher.py +120 -11
- package/remediate/authz/tests/test_bola_patcher.py +4 -1
- package/remediate/edge/coraza/Caddyfile +1 -1
- package/remediate/edge/coraza/Dockerfile +9 -0
- package/remediate/edge/coraza/rules/coraza-crs-custom.conf +12 -0
- package/remediate/edge/coraza/virtual_patcher.py +24 -0
- package/remediate/edge/docker-compose.yml +7 -2
- package/remediate/edge/kong/kong-legacy-session.yaml +51 -0
- package/remediate/edge/mock-backend/Dockerfile +2 -0
- package/remediate/edge/tests/test_edge_security.py +21 -1
- package/remediate/resilience/terraform/backup_worm.tf +39 -1
- package/remediate/resilience/terraform/main.tf +15 -3
- package/remediate/resilience/terraform/variables.tf +11 -0
- package/remediate/resilience/tests/test_resilience.py +16 -0
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
"""Declared-route extractors for Java web stacks (web.xml, Struts 1/2, Spring XML, annotations).
|
|
2
|
+
|
|
3
|
+
Used by map_routes.py. Python 3.8+, standard library only.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import re
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Dict, Iterable, List, Optional, Tuple
|
|
10
|
+
|
|
11
|
+
from lsa_common import blank_java_comments, call_args, iter_source_files, line_of, read_text, split_top
|
|
12
|
+
|
|
13
|
+
JAVA_METHOD_ANN = re.compile(r"@(RequestMapping|GetMapping|PostMapping|PutMapping|DeleteMapping|PatchMapping|Path)\b")
|
|
14
|
+
JAVA_HTTP_ANN = re.compile(r"@(GET|POST|PUT|DELETE|PATCH)\b")
|
|
15
|
+
JAVA_CLASS_AFTER = re.compile(r"\s*(?:@\w+(?:\([^)]*\))?\s*)*(?:(?:public|protected|private|abstract|final|static)\s+)*"
|
|
16
|
+
r"(?:class|interface)\b")
|
|
17
|
+
# The return type may follow annotations, as in: public @ResponseBody String show(
|
|
18
|
+
JAVA_HANDLER_SIG = re.compile(r"(?:(?:public|protected)\s+(?:@\w+(?:\([^)]*\))?\s+|(?:final|static|synchronized)\s+)*"
|
|
19
|
+
r"[\w<>\[\],.?\s]+?|fun|(?:override|suspend)\s+fun)\s+(\w+)\s*\(")
|
|
20
|
+
JAVA_NEXT_ANN = re.compile(r"\n\s*@(RequestMapping|GetMapping|PostMapping|PutMapping|DeleteMapping|PatchMapping|Path)\b(?!Variable|Param)")
|
|
21
|
+
STRING_LITERAL = re.compile(r"\"((?:[^\"\\]|\\.)*)\"")
|
|
22
|
+
JAVA_STRING_CONSTANT = re.compile(r"\bfinal\s+String\s+(\w+)\s*=\s*\"([^\"]*)\"\s*;")
|
|
23
|
+
SPRING_PLACEHOLDER = re.compile(r"\$\{[^}]*\}")
|
|
24
|
+
XML_COMMENT = re.compile(r"<!--[\s\S]*?-->")
|
|
25
|
+
STRUTS2_MARKER = re.compile(r"<!DOCTYPE\s+struts\b(?!-)|<struts[\s>]")
|
|
26
|
+
STRUTS_EXT_CONSTANT = re.compile(r"<constant\s+name=\"struts\.action\.extension\"\s+value=\"([^\"]*)\"")
|
|
27
|
+
STRUTS_EXT_PROPERTY = re.compile(r"^\s*struts\.action\.extension\s*[=:]\s*(\S*)", re.M)
|
|
28
|
+
STRUTS_MODULE_PARAM = re.compile(r"<param-name>\s*config/([^<\s]+)\s*</param-name>\s*<param-value>\s*([^<]+?)\s*</param-value>")
|
|
29
|
+
SPRING_BEAN = re.compile(r"<bean\b([^>]*)>")
|
|
30
|
+
SPRING_URL_MAPPER = re.compile(r"<bean\b[^>]*class=\"[^\"]*UrlHandlerMapping\"[^>]*>")
|
|
31
|
+
SPRING_URL_PROPS = re.compile(r"<props>([\s\S]*?)</props>")
|
|
32
|
+
SPRING_URL_PROP = re.compile(r"<prop\s+key=\"([^\"]+)\"\s*>\s*([^<]+?)\s*</prop>")
|
|
33
|
+
XML_ATTR = re.compile(r"(\w+)\s*=\s*\"([^\"]*)\"")
|
|
34
|
+
|
|
35
|
+
DEFAULT_ACTION_EXTENSION = "action"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def strip_xml_comments(text: str) -> str:
|
|
39
|
+
"""Blank XML comments out, keeping every other character at its original offset."""
|
|
40
|
+
return XML_COMMENT.sub(lambda m: re.sub(r"[^\n]", " ", m.group(0)), text)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def build_class_index(rels: Iterable[str]) -> Dict[str, List[str]]:
|
|
44
|
+
"""Map a simple class name to every Java source file with that name."""
|
|
45
|
+
index: Dict[str, List[str]] = {}
|
|
46
|
+
for rel in rels:
|
|
47
|
+
if rel.endswith(".java"):
|
|
48
|
+
index.setdefault(rel.rsplit("/", 1)[-1][:-5], []).append(rel)
|
|
49
|
+
return index
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def resolve_class(cls: str, index: Dict[str, List[str]], default: str) -> str:
|
|
53
|
+
"""Find the source file of a class name, matching on the package path when one is given."""
|
|
54
|
+
name = cls.split("$", 1)[0]
|
|
55
|
+
candidates = index.get(name.split(".")[-1], [])
|
|
56
|
+
if "." not in name:
|
|
57
|
+
return candidates[0] if candidates else default
|
|
58
|
+
suffix = name.replace(".", "/") + ".java"
|
|
59
|
+
return next((rel for rel in candidates if rel == suffix or rel.endswith("/" + suffix)), default)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _annotation_args(text: str, m: re.Match) -> Tuple[str, int]:
|
|
63
|
+
"""Return (argument text, offset after the annotation)."""
|
|
64
|
+
paren = re.match(r"\s*\(", text[m.end():])
|
|
65
|
+
if not paren:
|
|
66
|
+
return "", m.end()
|
|
67
|
+
return call_args(text, m.end() + paren.end() - 1)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _annotation_attrs(args: str) -> Dict[str, str]:
|
|
71
|
+
attrs: Dict[str, str] = {}
|
|
72
|
+
for part in split_top(args):
|
|
73
|
+
named = re.match(r"\s*(\w+)\s*=", part)
|
|
74
|
+
if named:
|
|
75
|
+
attrs[named.group(1)] = part[named.end():]
|
|
76
|
+
else:
|
|
77
|
+
attrs["value"] = part
|
|
78
|
+
return attrs
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _literals(expr: str) -> List[str]:
|
|
82
|
+
return STRING_LITERAL.findall(expr or "")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _string_constants(text: str) -> Dict[str, str]:
|
|
86
|
+
return dict(JAVA_STRING_CONSTANT.findall(text))
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _resolve_term(term: str, constants: Dict[str, str]) -> str:
|
|
90
|
+
term = term.strip()
|
|
91
|
+
literal = STRING_LITERAL.fullmatch(term)
|
|
92
|
+
if literal:
|
|
93
|
+
return literal.group(1)
|
|
94
|
+
name = term.rsplit(".", 1)[-1]
|
|
95
|
+
return constants.get(name, "{" + name + "}" if name else "")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _path_values(expr: str, constants: Dict[str, str]) -> List[str]:
|
|
99
|
+
"""Evaluate a mapping attribute: a literal, a constant, a concatenation of both, or an array of those."""
|
|
100
|
+
inner = expr.strip()
|
|
101
|
+
if inner.startswith("{") and inner.endswith("}"):
|
|
102
|
+
inner = inner[1:-1]
|
|
103
|
+
return ["".join(_resolve_term(t, constants) for t in re.split(r"\+(?=(?:[^\"]*\"[^\"]*\")*[^\"]*$)", element))
|
|
104
|
+
for element in split_top(inner)]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _mapped_paths(attrs: Dict[str, str], constants: Optional[Dict[str, str]] = None) -> List[str]:
|
|
108
|
+
"""Every mapped path with its required request parameters as a query string."""
|
|
109
|
+
paths = _path_values(attrs.get("value", "") or attrs.get("path", ""), constants or {}) or [""]
|
|
110
|
+
required = [p for p in _literals(attrs.get("params", "")) if not p.startswith("!")]
|
|
111
|
+
query = "?" + "&".join(required) if required else ""
|
|
112
|
+
return [p + query for p in paths]
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _join_path(prefix: str, sub: str) -> str:
|
|
116
|
+
sub_path, sep, query = sub.partition("?")
|
|
117
|
+
return "/" + "/".join(p.strip("/") for p in (prefix, sub_path) if p.strip("/")) + sep + query
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _class_mappings(text: str, constants: Dict[str, str]) -> List[Tuple[int, List[str]]]:
|
|
121
|
+
"""(offset, mapped paths) of every class-level @RequestMapping / @Path, in source order."""
|
|
122
|
+
out: List[Tuple[int, List[str]]] = []
|
|
123
|
+
for m in JAVA_METHOD_ANN.finditer(text):
|
|
124
|
+
args, end = _annotation_args(text, m)
|
|
125
|
+
if JAVA_CLASS_AFTER.match(text[end:end + 600]):
|
|
126
|
+
out.append((m.start(), [p.partition("?")[0] for p in _mapped_paths(_annotation_attrs(args), constants)]))
|
|
127
|
+
return out
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _servlet_declared(c, rel: str, text: str) -> bool:
|
|
131
|
+
found = False
|
|
132
|
+
ws = re.search(r"@WebServlet\s*\(\s*(?:urlPatterns\s*=\s*|value\s*=\s*)?\{?\s*\"([^\"]+)\"", text)
|
|
133
|
+
for m in re.finditer(r"(?:public|protected)\s+void\s+(doGet|doPost|doPut|doDelete|service)\s*\(", text):
|
|
134
|
+
start = m.start()
|
|
135
|
+
nxt = re.search(r"\n\s*(?:public|protected|private)\s+[\w<>\[\]]+\s+\w+\s*\(", text[m.end():])
|
|
136
|
+
body = text[start: m.end() + (nxt.start() if nxt else len(text))]
|
|
137
|
+
line = line_of(text, start)
|
|
138
|
+
verb = {"doGet": "GET", "doPost": "POST", "doPut": "PUT", "doDelete": "DELETE"}.get(m.group(1), "ANY")
|
|
139
|
+
c.add(stack="java-servlet", kind="declared_route", method=[verb],
|
|
140
|
+
path=ws.group(1) if ws else "<see web.xml servlet-mapping>",
|
|
141
|
+
handler={"file": rel, "line": line, "symbol": m.group(1)},
|
|
142
|
+
id_params=c.inputs("java", body, line), guard_hints=c.guards(body, line),
|
|
143
|
+
data_hints=c.data_ops(body, line), notes="" if ws else "resolve path from web.xml")
|
|
144
|
+
found = True
|
|
145
|
+
return found
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def java_declared(c, rel: str, raw: str) -> bool:
|
|
149
|
+
text = blank_java_comments(raw)
|
|
150
|
+
if re.search(r"extends\s+HttpServlet", text):
|
|
151
|
+
return _servlet_declared(c, rel, text)
|
|
152
|
+
found = False
|
|
153
|
+
constants = _string_constants(text)
|
|
154
|
+
classes = _class_mappings(text, constants)
|
|
155
|
+
for m in JAVA_METHOD_ANN.finditer(text):
|
|
156
|
+
args, ann_end = _annotation_args(text, m)
|
|
157
|
+
after = text[ann_end: ann_end + 600]
|
|
158
|
+
if JAVA_CLASS_AFTER.match(after):
|
|
159
|
+
continue
|
|
160
|
+
sig = JAVA_HANDLER_SIG.search(after)
|
|
161
|
+
if not sig:
|
|
162
|
+
continue
|
|
163
|
+
attrs = _annotation_attrs(args)
|
|
164
|
+
ann = m.group(1)
|
|
165
|
+
verbs = re.findall(r"RequestMethod\.(\w+)", args)
|
|
166
|
+
if ann.endswith("Mapping") and ann != "RequestMapping":
|
|
167
|
+
verbs = [ann[:-7].upper()]
|
|
168
|
+
if ann == "Path":
|
|
169
|
+
hv = JAVA_HTTP_ANN.search(text[max(0, m.start() - 200): ann_end + 200])
|
|
170
|
+
verbs = [hv.group(1)] if hv else ["ANY"]
|
|
171
|
+
nxt = JAVA_NEXT_ANN.search(text, ann_end)
|
|
172
|
+
body = text[m.start(): nxt.start() if nxt else len(text)]
|
|
173
|
+
line = line_of(text, m.start())
|
|
174
|
+
enclosing = [paths for offset, paths in classes if offset < m.start()]
|
|
175
|
+
for prefix in (enclosing[-1] if enclosing else [""]):
|
|
176
|
+
for sub in _mapped_paths(attrs, constants):
|
|
177
|
+
path = _join_path(prefix, sub)
|
|
178
|
+
c.add(stack="java-spring-mvc" if ann != "Path" else "java-jaxrs", kind="declared_route",
|
|
179
|
+
method=verbs or ["ANY"], path=path,
|
|
180
|
+
handler={"file": rel, "line": line, "symbol": sig.group(1)},
|
|
181
|
+
id_params=c.path_params(SPRING_PLACEHOLDER.sub("", path)) + c.inputs("java", body, line),
|
|
182
|
+
guard_hints=c.guards(text[:m.start()][-1500:], 1)[:2] + c.guards(body, line),
|
|
183
|
+
data_hints=c.data_ops(body, line), notes="")
|
|
184
|
+
found = True
|
|
185
|
+
# Struts 1 / 2 Action classes without annotations are mapped from XML (see xml_declared)
|
|
186
|
+
return found
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def struts_modules(rel: str, text: str) -> Dict[str, str]:
|
|
190
|
+
"""Struts 1 module prefix for every config file a web.xml assigns to a module."""
|
|
191
|
+
webapp = rel.rsplit("WEB-INF/", 1)[0]
|
|
192
|
+
modules: Dict[str, str] = {}
|
|
193
|
+
for m in STRUTS_MODULE_PARAM.finditer(strip_xml_comments(text)):
|
|
194
|
+
for value in m.group(2).split(","):
|
|
195
|
+
modules[webapp + value.strip().lstrip("/")] = "/" + m.group(1)
|
|
196
|
+
return modules
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def struts2_action_extension(target: Path, config: Dict, xml_texts: Iterable[str], encodings: List[str]) -> str:
|
|
200
|
+
"""The URL extension of Struts 2 actions: struts.properties overrides constants in struts.xml."""
|
|
201
|
+
value: Optional[str] = None
|
|
202
|
+
for text in xml_texts:
|
|
203
|
+
m = STRUTS_EXT_CONSTANT.search(strip_xml_comments(text))
|
|
204
|
+
value = m.group(1) if m else value
|
|
205
|
+
for path, rel in iter_source_files(target, config, exts={".properties"}):
|
|
206
|
+
if rel.rsplit("/", 1)[-1] == "struts.properties":
|
|
207
|
+
m = STRUTS_EXT_PROPERTY.search(read_text(path, encodings))
|
|
208
|
+
value = m.group(1) if m else value
|
|
209
|
+
if value is None:
|
|
210
|
+
return DEFAULT_ACTION_EXTENSION
|
|
211
|
+
return next((e for e in value.split(",") if e), "")
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def collect_spring_beans(text: str) -> Dict[str, str]:
|
|
215
|
+
"""Map bean id / name to class for one Spring XML file."""
|
|
216
|
+
beans: Dict[str, str] = {}
|
|
217
|
+
for m in SPRING_BEAN.finditer(strip_xml_comments(text)):
|
|
218
|
+
attrs = dict(XML_ATTR.findall(m.group(1)))
|
|
219
|
+
if attrs.get("class"):
|
|
220
|
+
for key in filter(None, (attrs.get("id"), attrs.get("name"))):
|
|
221
|
+
beans[key] = attrs["class"]
|
|
222
|
+
return beans
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def entry_method_line(text: str, symbol: Optional[str]) -> Optional[int]:
|
|
226
|
+
"""Line of the method a route declared in XML dispatches to, or None when the class has no known entry method."""
|
|
227
|
+
named = symbol.split("#", 1)[1] if symbol and "#" in symbol else None
|
|
228
|
+
names = [re.escape(named)] if named else ["execute", "doPost", "doGet", "service", "handleRequest"]
|
|
229
|
+
for name in names:
|
|
230
|
+
m = re.search(rf"\b(?:public|protected)\b[^;{{}}(]*\b{name}\s*\(", text)
|
|
231
|
+
if m:
|
|
232
|
+
return text.count("\n", 0, m.end()) + 1
|
|
233
|
+
return None
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _spring_url_mappings(c, rel: str, text: str, class_index: Dict[str, List[str]], beans: Dict[str, str]) -> bool:
|
|
237
|
+
found = False
|
|
238
|
+
mappers = list(SPRING_URL_MAPPER.finditer(text))
|
|
239
|
+
for i, mapper in enumerate(mappers):
|
|
240
|
+
# nested <bean> elements (interceptors) make </bean> useless as the end of the mapper definition
|
|
241
|
+
end = mappers[i + 1].start() if i + 1 < len(mappers) else len(text)
|
|
242
|
+
props = SPRING_URL_PROPS.search(text, mapper.end(), end)
|
|
243
|
+
if not props:
|
|
244
|
+
continue
|
|
245
|
+
for m in SPRING_URL_PROP.finditer(props.group(1)):
|
|
246
|
+
bean = m.group(2)
|
|
247
|
+
cls = beans.get(bean, "")
|
|
248
|
+
c.add(stack="java-spring-mvc", kind="declared_route", method=["ANY"], path=m.group(1),
|
|
249
|
+
handler={"file": resolve_class(cls, class_index, rel) if cls else rel, "line": 1, "symbol": cls or bean},
|
|
250
|
+
id_params=[], guard_hints=[], data_hints=[],
|
|
251
|
+
notes=f"declared in {rel}:{line_of(text, props.start(1) + m.start())}; UrlHandlerMapping bean '{bean}'")
|
|
252
|
+
found = True
|
|
253
|
+
return found
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _web_xml_routes(c, rel: str, text: str, class_index: Dict[str, List[str]]) -> bool:
|
|
257
|
+
found = False
|
|
258
|
+
servlets: Dict[str, str] = {}
|
|
259
|
+
for sm in re.finditer(r"<servlet>([\s\S]*?)</servlet>", text):
|
|
260
|
+
name = re.search(r"<servlet-name>\s*([^<]+?)\s*</servlet-name>", sm.group(1))
|
|
261
|
+
cls = re.search(r"<servlet-class>\s*([^<]+?)\s*</servlet-class>", sm.group(1))
|
|
262
|
+
if name and cls:
|
|
263
|
+
servlets[name.group(1)] = cls.group(1)
|
|
264
|
+
for m in re.finditer(r"<servlet-mapping>([\s\S]*?)</servlet-mapping>", text):
|
|
265
|
+
name = re.search(r"<servlet-name>\s*([^<]+?)\s*</servlet-name>", m.group(1))
|
|
266
|
+
if not name:
|
|
267
|
+
continue
|
|
268
|
+
cls = servlets.get(name.group(1), "")
|
|
269
|
+
for pattern in re.findall(r"<url-pattern>\s*([^<]+?)\s*</url-pattern>", m.group(1)):
|
|
270
|
+
c.add(stack="java-servlet", kind="declared_route", method=["ANY"], path=pattern,
|
|
271
|
+
handler={"file": resolve_class(cls, class_index, rel), "line": 1, "symbol": cls or name.group(1)},
|
|
272
|
+
id_params=[], guard_hints=[], data_hints=[], notes=f"mapped in {rel}:{line_of(text, m.start())}")
|
|
273
|
+
found = True
|
|
274
|
+
return found
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _struts1_routes(c, rel: str, text: str, class_index: Dict[str, List[str]], module: str) -> bool:
|
|
278
|
+
found = False
|
|
279
|
+
for m in re.finditer(r"<action\b([^>]*)>", text):
|
|
280
|
+
attrs = dict(XML_ATTR.findall(m.group(1)))
|
|
281
|
+
if "path" not in attrs:
|
|
282
|
+
continue
|
|
283
|
+
cls = attrs.get("type", "")
|
|
284
|
+
note = f"declared in {rel}:{line_of(text, m.start())}"
|
|
285
|
+
if attrs.get("parameter"):
|
|
286
|
+
note += f"; DispatchAction parameter '{attrs['parameter']}' -> one endpoint per method"
|
|
287
|
+
c.add(stack="java-struts1", kind="declared_route", method=["ANY"], path=module + attrs["path"] + ".do",
|
|
288
|
+
handler={"file": resolve_class(cls, class_index, rel), "line": 1, "symbol": cls}, id_params=[],
|
|
289
|
+
guard_hints=[], data_hints=[], notes=note)
|
|
290
|
+
found = True
|
|
291
|
+
return found
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _struts2_routes(c, rel: str, text: str, class_index: Dict[str, List[str]], extension: str) -> bool:
|
|
295
|
+
found = False
|
|
296
|
+
suffix = "." + extension if extension else ""
|
|
297
|
+
for pm in re.finditer(r"<package\b([^>]*)>([\s\S]*?)</package>", text):
|
|
298
|
+
ns = dict(XML_ATTR.findall(pm.group(1))).get("namespace", "/").rstrip("/")
|
|
299
|
+
for am in re.finditer(r"<action\b([^>]*)>", pm.group(2)):
|
|
300
|
+
attrs = dict(XML_ATTR.findall(am.group(1)))
|
|
301
|
+
cls = attrs.get("class", "")
|
|
302
|
+
c.add(stack="java-struts2", kind="declared_route", method=["ANY"], path=f"{ns}/{attrs.get('name', '?')}{suffix}",
|
|
303
|
+
handler={"file": resolve_class(cls, class_index, rel), "line": 1, "symbol": f"{cls}#{attrs.get('method', 'execute')}"},
|
|
304
|
+
id_params=[], guard_hints=[], data_hints=[],
|
|
305
|
+
notes=f"declared in {rel}:{line_of(text, pm.start(2) + am.start())}; action setters are inputs")
|
|
306
|
+
found = True
|
|
307
|
+
return found
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def xml_declared(c, rel: str, raw: str, class_index: Dict[str, List[str]], modules: Optional[Dict[str, str]] = None,
|
|
311
|
+
beans: Optional[Dict[str, str]] = None, action_extension: str = DEFAULT_ACTION_EXTENSION) -> bool:
|
|
312
|
+
"""Extract every route a deployment or framework XML file declares, ignoring commented-out ones."""
|
|
313
|
+
text = strip_xml_comments(raw)
|
|
314
|
+
found = False
|
|
315
|
+
if rel.endswith("web.xml"):
|
|
316
|
+
found = _web_xml_routes(c, rel, text, class_index) or found
|
|
317
|
+
if "struts-config" in rel:
|
|
318
|
+
found = _struts1_routes(c, rel, text, class_index, (modules or {}).get(rel, "")) or found
|
|
319
|
+
if rel.endswith("struts.xml") or STRUTS2_MARKER.search(text):
|
|
320
|
+
found = _struts2_routes(c, rel, text, class_index, action_extension) or found
|
|
321
|
+
if "UrlHandlerMapping" in text:
|
|
322
|
+
found = _spring_url_mappings(c, rel, text, class_index, beans or {}) or found
|
|
323
|
+
return found
|
|
@@ -82,12 +82,32 @@ def read_text(path: Path, encodings: List[str]) -> str:
|
|
|
82
82
|
return data.decode("latin-1", errors="replace")
|
|
83
83
|
|
|
84
84
|
|
|
85
|
+
# Default exclude names that are also ordinary Java package and web directory names
|
|
86
|
+
AMBIGUOUS_DIR_EXCLUDES = {"vendor/", "packages/", "bin/", "obj/", "target/", "build/", "dist/",
|
|
87
|
+
"test/", "tests/", "spec/", "docs/", "out/"}
|
|
88
|
+
JAVA_PACKAGE_ROOT = re.compile(r"(?:^|/)src/main/java/|(?:^|/)(?=(?:com|org|net|jp|vn|cn|kr|de|fr|uk|io|edu|gov|biz|info)/[\w-]+/)")
|
|
89
|
+
JAVA_WEB_ROOT = re.compile(r"(?:^|/)(?:src/main/webapp|WebContent|WebRoot|webapp)/")
|
|
90
|
+
JAVA_TREE_EXTS = (".java", ".kt", ".groovy", ".jsp", ".xml")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _inside_java_tree(rel_slash: str, index: int, is_dir: bool) -> bool:
|
|
94
|
+
"""True when the directory at `index` is a package inside a Java source tree or a directory under a web root."""
|
|
95
|
+
name = rel_slash.rstrip("/")
|
|
96
|
+
package_root = JAVA_PACKAGE_ROOT.search(rel_slash)
|
|
97
|
+
if package_root and package_root.start() <= index and (is_dir or name.endswith(JAVA_TREE_EXTS)):
|
|
98
|
+
return True
|
|
99
|
+
web_root = JAVA_WEB_ROOT.search(rel_slash)
|
|
100
|
+
return bool(web_root and web_root.start() <= index and (is_dir or name.endswith(".jsp")))
|
|
101
|
+
|
|
102
|
+
|
|
85
103
|
def _excluded(rel: str, excludes: List[str]) -> bool:
|
|
86
|
-
|
|
104
|
+
is_dir = rel.endswith("/")
|
|
105
|
+
rel_slash = rel.replace(os.sep, "/") + ("" if is_dir else "/")
|
|
87
106
|
for ex in excludes:
|
|
88
107
|
ex = ex.replace("\\", "/")
|
|
89
108
|
if ex.endswith("/"):
|
|
90
|
-
|
|
109
|
+
index = ("/" + rel_slash).find("/" + ex)
|
|
110
|
+
if index != -1 and not (ex in AMBIGUOUS_DIR_EXCLUDES and _inside_java_tree(rel_slash, index, is_dir)):
|
|
91
111
|
return True
|
|
92
112
|
elif re.fullmatch(ex.replace(".", r"\.").replace("*", ".*"), rel.replace(os.sep, "/")):
|
|
93
113
|
return True
|
|
@@ -137,6 +157,56 @@ def iter_source_files(root: Path, config: Dict, exts: Optional[set] = None) -> I
|
|
|
137
157
|
yield p, rel
|
|
138
158
|
|
|
139
159
|
|
|
160
|
+
JAVA_COMMENT = re.compile(r"(?m:^[ \t]*)(?:/\*[\s\S]*?\*/|//[^\n]*)")
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def blank_java_comments(text: str) -> str:
|
|
164
|
+
"""Blank whole-line Java comments, keeping every other character at its original offset."""
|
|
165
|
+
return JAVA_COMMENT.sub(lambda m: re.sub(r"[^\n]", " ", m.group(0)), text)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def call_args(text: str, open_idx: int, limit: int = 4000) -> Tuple[str, int]:
|
|
169
|
+
"""Return (argument text, index after the closing paren) for the call whose '(' is at open_idx."""
|
|
170
|
+
depth, i, end = 0, open_idx, min(len(text), open_idx + limit)
|
|
171
|
+
while i < end:
|
|
172
|
+
ch = text[i]
|
|
173
|
+
if ch in "\"`":
|
|
174
|
+
close = text.find(ch, i + 1)
|
|
175
|
+
while ch == '"' and close != -1 and text[close - 1] == "\\":
|
|
176
|
+
close = text.find(ch, close + 1)
|
|
177
|
+
i = close if close != -1 else end
|
|
178
|
+
elif ch in "([{":
|
|
179
|
+
depth += 1
|
|
180
|
+
elif ch in ")]}":
|
|
181
|
+
depth -= 1
|
|
182
|
+
if depth == 0:
|
|
183
|
+
return text[open_idx + 1:i], i + 1
|
|
184
|
+
i += 1
|
|
185
|
+
return text[open_idx + 1:end], end
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def split_top(args: str) -> List[str]:
|
|
189
|
+
"""Split call arguments on top-level commas."""
|
|
190
|
+
parts, depth, cur, quote = [], 0, [], None
|
|
191
|
+
for ch in args:
|
|
192
|
+
if quote:
|
|
193
|
+
quote = None if ch == quote else quote
|
|
194
|
+
elif ch in "\"`":
|
|
195
|
+
quote = ch
|
|
196
|
+
elif ch in "([{":
|
|
197
|
+
depth += 1
|
|
198
|
+
elif ch in ")]}":
|
|
199
|
+
depth -= 1
|
|
200
|
+
elif ch == "," and depth == 0:
|
|
201
|
+
parts.append("".join(cur).strip())
|
|
202
|
+
cur = []
|
|
203
|
+
continue
|
|
204
|
+
cur.append(ch)
|
|
205
|
+
if "".join(cur).strip():
|
|
206
|
+
parts.append("".join(cur).strip())
|
|
207
|
+
return parts
|
|
208
|
+
|
|
209
|
+
|
|
140
210
|
def line_of(text: str, index: int) -> int:
|
|
141
211
|
return text.count("\n", 0, index) + 1
|
|
142
212
|
|