@hoanghainh/komagu 2.3.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.
Files changed (60) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/LICENSE +202 -0
  3. package/README.md +1 -0
  4. package/assess/lsa/CHANGELOG.md +15 -0
  5. package/assess/lsa/scripts/detect_stack.py +15 -6
  6. package/assess/lsa/scripts/find_injection.py +35 -2
  7. package/assess/lsa/scripts/java_routes.py +323 -0
  8. package/assess/lsa/scripts/lsa_common.py +73 -7
  9. package/assess/lsa/scripts/map_routes.py +36 -160
  10. package/assess/lsa/scripts/merge_findings.py +2 -0
  11. package/assess/lsa/scripts/redaction.py +118 -0
  12. package/assess/lsa/tests/test_java_legacy_realistic.py +453 -0
  13. package/assess/lsa/tests/test_modern_stacks.py +13 -0
  14. package/contracts/schemas/persona-suite.schema.json +38 -2
  15. package/examples/member-portal/backend-dotnet/Dockerfile +2 -1
  16. package/examples/member-portal/backend-spring/Dockerfile +2 -0
  17. package/examples/member-portal/docker-compose.yml +1 -1
  18. package/examples/member-portal/frontend/Dockerfile +3 -2
  19. package/examples/member-portal/frontend/package-lock.json +24 -656
  20. package/examples/member-portal/frontend/package.json +1 -1
  21. package/operate/ai/context.py +22 -2
  22. package/operate/ai/runner.py +20 -2
  23. package/operate/cli/__init__.py +1 -1
  24. package/operate/cli/commands/fix.py +4 -0
  25. package/operate/cli/commands/leak_check.py +75 -0
  26. package/operate/cli/commands/protect.py +32 -3
  27. package/operate/cli/commands/test.py +131 -15
  28. package/operate/cli/main.py +5 -3
  29. package/operate/cross_user_tester/persona_matrix.py +118 -25
  30. package/operate/cross_user_tester/runner.py +190 -7
  31. package/operate/cross_user_tester/session_config.py +180 -0
  32. package/operate/cross_user_tester/session_http.py +288 -0
  33. package/operate/devsecops/leak_check.py +314 -0
  34. package/operate/remediator/runner.py +18 -1
  35. package/operate/remediator/strategies.py +2 -2
  36. package/operate/simulate/scenarios.py +2 -2
  37. package/operate/tests/mock_legacy_session_app.py +160 -0
  38. package/operate/tests/test_ai_redaction.py +181 -0
  39. package/operate/tests/test_cross_user_session.py +565 -0
  40. package/operate/tests/test_docker_packaging.py +3 -3
  41. package/operate/tests/test_headless_ai.py +22 -1
  42. package/operate/tests/test_leak_check.py +250 -0
  43. package/operate/tests/test_legacy_session_edge.py +192 -0
  44. package/operate/tests/test_pilot_safeguards.py +245 -0
  45. package/operate/tests/test_remediator_java_legacy.py +330 -0
  46. package/package.json +2 -1
  47. package/remediate/authz/patcher.py +120 -11
  48. package/remediate/authz/tests/test_bola_patcher.py +4 -1
  49. package/remediate/edge/coraza/Caddyfile +1 -1
  50. package/remediate/edge/coraza/Dockerfile +9 -0
  51. package/remediate/edge/coraza/rules/coraza-crs-custom.conf +12 -0
  52. package/remediate/edge/coraza/virtual_patcher.py +24 -0
  53. package/remediate/edge/docker-compose.yml +7 -2
  54. package/remediate/edge/kong/kong-legacy-session.yaml +51 -0
  55. package/remediate/edge/mock-backend/Dockerfile +2 -0
  56. package/remediate/edge/tests/test_edge_security.py +21 -1
  57. package/remediate/resilience/terraform/backup_worm.tf +39 -1
  58. package/remediate/resilience/terraform/main.tf +15 -3
  59. package/remediate/resilience/terraform/variables.tf +11 -0
  60. 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
- rel_slash = rel.replace(os.sep, "/") + ("/" if not rel.endswith("/") else "")
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
- if rel_slash.startswith(ex) or ("/" + ex) in ("/" + rel_slash):
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
 
@@ -166,8 +236,4 @@ def snippet(lines: List[str], lineno: int, max_len: int = 220) -> str:
166
236
  return s if len(s) <= max_len else s[: max_len - 3] + "..."
167
237
 
168
238
 
169
- SECRET_RE = re.compile(r"(?i)(password|passwd|pwd|secret|api[_-]?key|token)\s*[=:]\s*['\"][^'\"]{4,}['\"]")
170
-
171
-
172
- def redact(s: str) -> str:
173
- return SECRET_RE.sub(lambda m: m.group(1) + "=[REDACTED]", s)
239
+ from redaction import redact, redact_lines # noqa: E402,F401 (re-exported for the Tier 1 scripts)