@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
@@ -19,8 +19,11 @@ from pathlib import Path
19
19
  from typing import Dict, List, Optional, Tuple
20
20
 
21
21
  sys.path.insert(0, str(Path(__file__).resolve().parent))
22
- from lsa_common import (KIT_VERSION, compile_id_patterns, is_id_name, iter_source_files, # noqa: E402
23
- line_of, load_config, read_text, write_json)
22
+ from java_routes import (build_class_index, collect_spring_beans, entry_method_line, java_declared, # noqa: E402
23
+ struts2_action_extension,
24
+ struts_modules, xml_declared)
25
+ from lsa_common import (JAVA_WEB_ROOT, KIT_VERSION, call_args, compile_id_patterns, is_id_name, # noqa: E402
26
+ iter_source_files, line_of, load_config, read_text, split_top, write_json)
24
27
  from nextjs_routes import middleware_note, nextjs_declared # noqa: E402
25
28
  from privilege import annotate_privilege # noqa: E402
26
29
 
@@ -250,8 +253,6 @@ def segments(text: str, decl_rx: re.Pattern) -> List[Tuple[re.Match, int, int]]:
250
253
  # Declared-route extractors
251
254
  # --------------------------------------------------------------------------------------
252
255
  PHP_METHOD = re.compile(r"^[ \t]*(?:(public|protected|private)\s+)?(?:static\s+)?function\s+&?(\w+)\s*\(", re.M)
253
- JAVA_METHOD_ANN = re.compile(r"@(RequestMapping|GetMapping|PostMapping|PutMapping|DeleteMapping|PatchMapping|Path)\b\s*(\(([^)]*)\))?")
254
- JAVA_HTTP_ANN = re.compile(r"@(GET|POST|PUT|DELETE|PATCH)\b")
255
256
  CS_ACTION = re.compile(r"^[ \t]*public\s+(?!record\b|class\b)(?:virtual\s+|async\s+|override\s+|static\s+)*[A-Za-z0-9_<>., \[\]?]+\s+(\w+)\s*\(([^)]*)\)", re.M)
256
257
  VB_ACTION = re.compile(r"^[ \t]*Public\s+(?:Overridable\s+|Async\s+)*Function\s+(\w+)\s*\(([^)]*)\)\s+As\s+[\w.(<>) ]+", re.M | re.I)
257
258
  WEBMETHOD = re.compile(r"(\[WebMethod[^\]]*\]|<WebMethod[^>]*>|\[OperationContract[^\]]*\]|<OperationContract[^>]*>)[\s\S]{0,300}?"
@@ -305,107 +306,6 @@ def php_declared(c: Collector, rel: str, text: str, stacks: set) -> bool:
305
306
  return found
306
307
 
307
308
 
308
- def java_declared(c: Collector, rel: str, text: str) -> bool:
309
- found = False
310
- cls_prefix = ""
311
- cm = re.search(r"@(RequestMapping|Path)\s*\(\s*(?:value\s*=\s*)?\"([^\"]*)\"[^)]*\)\s*(?:@\w+(?:\([^)]*\))?\s*)*(?:public\s+)?(?:abstract\s+)?class", text)
312
- if cm:
313
- cls_prefix = cm.group(2)
314
- is_servlet = re.search(r"extends\s+HttpServlet", text)
315
- if is_servlet:
316
- ws = re.search(r"@WebServlet\s*\(\s*(?:urlPatterns\s*=\s*|value\s*=\s*)?\{?\s*\"([^\"]+)\"", text)
317
- for m in re.finditer(r"(?:public|protected)\s+void\s+(doGet|doPost|doPut|doDelete|service)\s*\(", text):
318
- start = m.start()
319
- nxt = re.search(r"\n\s*(?:public|protected|private)\s+[\w<>\[\]]+\s+\w+\s*\(", text[m.end():])
320
- body = text[start: m.end() + (nxt.start() if nxt else len(text))]
321
- line = line_of(text, start)
322
- verb = {"doGet": "GET", "doPost": "POST", "doPut": "PUT", "doDelete": "DELETE"}.get(m.group(1), "ANY")
323
- c.add(stack="java-servlet", kind="declared_route", method=[verb],
324
- path=ws.group(1) if ws else "<see web.xml servlet-mapping>",
325
- handler={"file": rel, "line": line, "symbol": m.group(1)},
326
- id_params=c.inputs("java", body, line), guard_hints=c.guards(body, line),
327
- data_hints=c.data_ops(body, line), notes="" if ws else "resolve path from web.xml")
328
- found = True
329
- return found
330
- anns = list(JAVA_METHOD_ANN.finditer(text))
331
- for m in anns:
332
- if cm and m.start() == cm.start():
333
- continue
334
- after = text[m.end(): m.end() + 600]
335
- # skip class-level annotation
336
- if re.match(r"\s*(?:@\w+(?:\([^)]*\))?\s*)*(?:public\s+)?(?:abstract\s+)?class\b", after):
337
- continue
338
- sig = re.search(r"(?:(?:public|protected)\s+[\w<>\[\],.? ]+|fun|(?:override|suspend)\s+fun)\s+(\w+)\s*\(", after)
339
- if not sig:
340
- continue
341
- before = text[:m.start()]
342
- cls_m = list(re.finditer(r"@(?:RequestMapping|Path)\s*\(\s*(?:value\s*=\s*|path\s*=\s*)?\"([^\"]*)\"[^)]*\)[\s\S]{0,300}?(?:class\b)", before))
343
- method_cls_prefix = cls_m[-1].group(1) if cls_m else cls_prefix
344
- args = m.group(3) or ""
345
- pv = re.search(r"(?:value\s*=\s*|path\s*=\s*)?\{?\s*\"([^\"]*)\"", args)
346
- sub = pv.group(1) if pv else ""
347
- verbs = re.findall(r"RequestMethod\.(\w+)", args)
348
- ann = m.group(1)
349
- if ann.endswith("Mapping") and ann != "RequestMapping":
350
- verbs = [ann[:-7].upper()]
351
- if ann == "Path":
352
- hv = JAVA_HTTP_ANN.search(text[max(0, m.start() - 200): m.end() + 200])
353
- verbs = [hv.group(1)] if hv else ["ANY"]
354
- path = "/" + "/".join(p.strip("/") for p in (method_cls_prefix, sub) if p.strip("/"))
355
- nxt = re.search(r"\n\s*@(RequestMapping|GetMapping|PostMapping|PutMapping|DeleteMapping|PatchMapping|Path)\b(?!Variable|Param)", text[m.end():])
356
- body = text[m.start(): m.end() + (nxt.start() if nxt else len(text))]
357
- line = line_of(text, m.start())
358
- c.add(stack="java-spring-mvc" if ann != "Path" else "java-jaxrs", kind="declared_route", method=verbs or ["ANY"],
359
- path=path, handler={"file": rel, "line": line, "symbol": sig.group(1)},
360
- id_params=c.path_params(path) + c.inputs("java", body, line),
361
- guard_hints=c.guards(text[:m.start()][-1500:], 1)[:2] + c.guards(body, line),
362
- data_hints=c.data_ops(body, line), notes="")
363
- found = True
364
- # Struts 1 / 2 Action classes without annotations are mapped from XML (see xml_declared)
365
- return found
366
-
367
-
368
- def xml_declared(c: Collector, rel: str, text: str, class_index: Dict[str, str]) -> bool:
369
- found = False
370
- if rel.endswith("web.xml"):
371
- servlets = dict(re.findall(r"<servlet>\s*<servlet-name>\s*([^<]+?)\s*</servlet-name>[\s\S]*?<servlet-class>\s*([^<]+?)\s*</servlet-class>", text))
372
- for m in re.finditer(r"<servlet-mapping>\s*<servlet-name>\s*([^<]+?)\s*</servlet-name>\s*<url-pattern>\s*([^<]+?)\s*</url-pattern>", text):
373
- name, pattern = m.group(1), m.group(2)
374
- cls = servlets.get(name, "")
375
- hfile = class_index.get(cls.split(".")[-1], rel)
376
- c.add(stack="java-servlet", kind="declared_route", method=["ANY"], path=pattern,
377
- handler={"file": hfile, "line": 1, "symbol": cls or name}, id_params=[], guard_hints=[],
378
- data_hints=[], notes=f"mapped in {rel}:{line_of(text, m.start())}")
379
- found = True
380
- if "struts-config" in rel:
381
- for m in re.finditer(r"<action\b([^>]*)>", text):
382
- attrs = dict(re.findall(r"(\w+)\s*=\s*\"([^\"]*)\"", m.group(1)))
383
- if "path" not in attrs:
384
- continue
385
- cls = attrs.get("type", "")
386
- hfile = class_index.get(cls.split(".")[-1], rel)
387
- note = f"declared in {rel}:{line_of(text, m.start())}"
388
- if attrs.get("parameter"):
389
- note += f"; DispatchAction parameter '{attrs['parameter']}' -> one endpoint per method"
390
- c.add(stack="java-struts1", kind="declared_route", method=["ANY"], path=attrs["path"] + ".do",
391
- handler={"file": hfile, "line": 1, "symbol": cls}, id_params=[], guard_hints=[], data_hints=[], notes=note)
392
- found = True
393
- if rel.endswith("struts.xml"):
394
- for pm in re.finditer(r"<package\b([^>]*)>([\s\S]*?)</package>", text):
395
- pattrs = dict(re.findall(r"(\w+)\s*=\s*\"([^\"]*)\"", pm.group(1)))
396
- ns = pattrs.get("namespace", "/").rstrip("/")
397
- for am in re.finditer(r"<action\b([^>]*)>", pm.group(2)):
398
- attrs = dict(re.findall(r"(\w+)\s*=\s*\"([^\"]*)\"", am.group(1)))
399
- cls = attrs.get("class", "")
400
- hfile = class_index.get(cls.split(".")[-1], rel)
401
- c.add(stack="java-struts2", kind="declared_route", method=["ANY"], path=f"{ns}/{attrs.get('name', '?')}.action",
402
- handler={"file": hfile, "line": 1, "symbol": f"{cls}#{attrs.get('method', 'execute')}"},
403
- id_params=[], guard_hints=[], data_hints=[],
404
- notes=f"declared in {rel}:{line_of(text, pm.start() + am.start())}; action setters are inputs")
405
- found = True
406
- return found
407
-
408
-
409
309
  def dotnet_declared(c: Collector, rel: str, text: str) -> bool:
410
310
  found = False
411
311
  cm = re.search(r"(?:\[ApiController\][\s\S]{0,100}?)?class\s+(\w+?)Controller\s*(?::\s*(\w+))?|Class\s+(\w+?)Controller[\s\S]{0,80}?Inherits\s+(\w+)", text, re.I)
@@ -560,48 +460,6 @@ GO_FRAMEWORKS = [("go-gin", "github.com/gin-gonic/gin"), ("go-echo", "github.com
560
460
  GO_PATTERN = re.compile(r"^(?:([A-Z]+)\s+)?(/\S*)$")
561
461
 
562
462
 
563
- def call_args(text: str, open_idx: int, limit: int = 4000) -> Tuple[str, int]:
564
- """Return (argument text, index after the closing paren) for the call whose '(' is at open_idx."""
565
- depth, i, end = 0, open_idx, min(len(text), open_idx + limit)
566
- while i < end:
567
- ch = text[i]
568
- if ch in "\"`":
569
- close = text.find(ch, i + 1)
570
- while ch == '"' and close != -1 and text[close - 1] == "\\":
571
- close = text.find(ch, close + 1)
572
- i = close if close != -1 else end
573
- elif ch in "([{":
574
- depth += 1
575
- elif ch in ")]}":
576
- depth -= 1
577
- if depth == 0:
578
- return text[open_idx + 1:i], i + 1
579
- i += 1
580
- return text[open_idx + 1:end], end
581
-
582
-
583
- def split_top(args: str) -> List[str]:
584
- """Split call arguments on top-level commas."""
585
- parts, depth, cur, quote = [], 0, [], None
586
- for ch in args:
587
- if quote:
588
- quote = None if ch == quote else quote
589
- elif ch in "\"`":
590
- quote = ch
591
- elif ch in "([{":
592
- depth += 1
593
- elif ch in ")]}":
594
- depth -= 1
595
- elif ch == "," and depth == 0:
596
- parts.append("".join(cur).strip())
597
- cur = []
598
- continue
599
- cur.append(ch)
600
- if "".join(cur).strip():
601
- parts.append("".join(cur).strip())
602
- return parts
603
-
604
-
605
463
  def guards_before(c: Collector, text: str, start: int, end: int, span: int = 500) -> List[Dict]:
606
464
  ws = max(0, start - span)
607
465
  return c.guards(text[ws:end], line_of(text, ws))
@@ -712,6 +570,9 @@ def file_endpoint(c: Collector, rel: str, text: str, lang: str, ext: str, target
712
570
  wr = wr.strip("/") + "/"
713
571
  if rel.startswith(wr):
714
572
  path = "/" + rel[len(wr):]
573
+ root = JAVA_WEB_ROOT.search(rel) if lang == "java" and path == "/" + rel else None
574
+ if root:
575
+ path = "/" + rel[root.end():]
715
576
  stack = {"php": "php", "java": "java-jsp", "dotnet": "dotnet-webforms" if ext in (".aspx",) else "dotnet-handlers",
716
577
  "asp": "classic-asp", "perl": "perl-cgi", "cfml": "coldfusion"}.get(lang, lang)
717
578
  if ext in (".asmx", ".svc"):
@@ -775,7 +636,11 @@ def _scan_route_file_batch(args: Tuple[List[Tuple[str, str]], Dict, set, str]) -
775
636
  declared = go_declared(c, rel, t)
776
637
  if declared or ext not in FILE_ENDPOINT_EXTS:
777
638
  continue
778
- if INCLUDE_DIR_RE.search(rel.rsplit("/", 1)[0] + "/" if "/" in rel else ""):
639
+ if ext == ".jsp":
640
+ # Tomcat serves any JSP outside WEB-INF, whatever its directory is called; those inside are only forwarded to
641
+ if "/WEB-INF/" in "/" + rel:
642
+ continue
643
+ elif INCLUDE_DIR_RE.search(rel.rsplit("/", 1)[0] + "/" if "/" in rel else ""):
779
644
  continue
780
645
  if framework_php and lang == "php" and re.search(r"(^|/)(application|system|app|vendor|routes|resources|storage|bootstrap)/", rel):
781
646
  continue
@@ -791,15 +656,22 @@ def map_routes(target: Path, config: Dict, stack_ids: Optional[set] = None, work
791
656
  files = list(iter_source_files(target, config))
792
657
  stack_ids = stack_ids or set()
793
658
  framework_php = bool(stack_ids & {"php-codeigniter", "php-cakephp", "php-laravel", "php-zend1"})
794
- class_index = {p.stem: rel for p, rel in files if p.suffix == ".java"}
659
+ class_index = build_class_index(rel for _, rel in files)
795
660
  texts: Dict[str, str] = {}
796
661
 
797
- # 1. XML pre-pass (web.xml / struts.xml declarative mappings)
798
- for p, rel in files:
799
- if p.suffix.lower() == ".xml":
800
- t = read_text(p, enc)
801
- if "<servlet" in t or "<action" in t:
802
- xml_declared(c, rel, t, class_index)
662
+ # 1. XML pre-pass (web.xml / Struts / Spring declarative mappings)
663
+ xml_texts = {rel: read_text(p, enc) for p, rel in files if p.suffix.lower() == ".xml"}
664
+ modules: Dict[str, str] = {}
665
+ beans: Dict[str, str] = {}
666
+ for rel, t in xml_texts.items():
667
+ if rel.endswith("web.xml"):
668
+ modules.update(struts_modules(rel, t))
669
+ if "<bean" in t:
670
+ beans.update(collect_spring_beans(t))
671
+ extension = struts2_action_extension(target, config, xml_texts.values(), enc)
672
+ for rel, t in xml_texts.items():
673
+ if "<servlet" in t or "<action" in t or "UrlHandlerMapping" in t:
674
+ xml_declared(c, rel, t, class_index, modules, beans, extension)
803
675
 
804
676
  xml_items = list(c.items)
805
677
 
@@ -865,20 +737,24 @@ def map_routes(target: Path, config: Dict, stack_ids: Optional[set] = None, work
865
737
  # post-pass 1: routes declared in XML -> enrich from the handler class file
866
738
  for it in c.items:
867
739
  hf = it["handler"]["file"]
740
+ if it["kind"] == "declared_route" and it["handler"]["line"] == 1 and hf in texts and hf.endswith(".java"):
741
+ it["handler"]["line"] = entry_method_line(texts[hf], it["handler"].get("symbol")) or 1
868
742
  if it["kind"] == "declared_route" and not it["id_params"] and hf in texts and hf.endswith(".java"):
869
743
  t = texts[hf]
870
744
  it["id_params"] = c.inputs("java", t)
871
745
  it["guard_hints"] = it["guard_hints"] or c.guards(t)
872
746
  it["data_hints"] = it["data_hints"] or c.data_ops(t)
873
747
  # post-pass 2: servlet classes without @WebServlet -> take the path from web.xml
874
- xml_paths = {it["handler"]["file"]: it["path"] for it in c.items
875
- if it["stack"] == "java-servlet" and it["notes"].startswith("mapped in ")}
748
+ xml_paths: Dict[str, List[str]] = {}
749
+ for it in c.items:
750
+ if it["stack"] == "java-servlet" and it["notes"].startswith("mapped in "):
751
+ xml_paths.setdefault(it["handler"]["file"], []).append(it["path"])
876
752
  keep = []
877
753
  for it in c.items:
878
754
  if it["path"] == "<see web.xml servlet-mapping>" and it["handler"]["file"] in xml_paths:
879
- it["path"] = xml_paths[it["handler"]["file"]]
880
- it["notes"] = "path from web.xml"
881
- keep.append(it)
755
+ keep.extend(dict(it, path=mapped, notes="path from web.xml") for mapped in xml_paths[it["handler"]["file"]])
756
+ else:
757
+ keep.append(it)
882
758
  mapped_with_methods = {it["handler"]["file"] for it in keep if it["notes"] == "path from web.xml"}
883
759
  keep = [it for it in keep if not (it["notes"].startswith("mapped in ") and it["handler"]["file"] in mapped_with_methods)]
884
760
  for n, it in enumerate(keep, 1):
@@ -403,6 +403,8 @@ def main() -> int:
403
403
  "injection_candidates_without_verdict", "bfla_routes_without_verdict",
404
404
  "mass_candidates_without_verdict", "validation_errors",
405
405
  "new_issues_from_verifier"))
406
+ for error in cov.get("validation_errors") or []:
407
+ print(f"validation error (finding not merged): {error}", file=sys.stderr)
406
408
  print(json.dumps({"summary": summary, "coverage_gaps": gaps}, indent=2))
407
409
  return 1 if gaps else 0
408
410
 
@@ -0,0 +1,118 @@
1
+ """Secret redaction for code excerpts: Tier 1 snippets and every excerpt the headless agents send to a model (#350).
2
+
3
+ Pattern-based, so it cannot catch every secret: the pilot runbook also leaves key and environment config files
4
+ out of the source copy. Standard-library only (Python 3.8+ compatible).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ from typing import List, Optional, Tuple
11
+
12
+ MASK = "[REDACTED]"
13
+ # Longer lines (minified bundles, generated data) are dropped whole: they are not useful to an auditor and
14
+ # keep the regexes below from running on megabyte-long input
15
+ MAX_LINE_CHARS = 4000
16
+
17
+ # A secret-like key name; the lookahead keeps passwordPolicy, tokenizer and Passenger out
18
+ _KW = r"(?:password|passwd|passphrase|pwd|pw|pass|secret|credential|api[_-]?key|token)(?![a-z])"
19
+ _NAME_TAGS = r"(?:param-name|name|key)"
20
+ _VALUE_TAGS = r"(?:param-value|value)"
21
+
22
+
23
+ def _rx(pattern: str, flags: int = re.IGNORECASE) -> re.Pattern[str]:
24
+ return re.compile(pattern, flags)
25
+
26
+
27
+ # (pattern, replacement); applied in order to each line
28
+ _RULES: Tuple[Tuple[re.Pattern[str], str], ...] = (
29
+ # -----BEGIN PRIVATE KEY----- ... -----END PRIVATE KEY----- on one line
30
+ (_rx(r"(-----BEGIN [A-Z ]*PRIVATE KEY-----).*?(-----END [A-Z ]*PRIVATE KEY-----)", 0), r"\1" + MASK + r"\2"),
31
+ # "password": "x", 'password' => 'x', pw = "x", keystorePass="x", setProperty("mail.smtp.password", "x")
32
+ (_rx(r"(?<![\w.\-])((['\"]?)[\w.\-]*?" + _KW + r"\2\s*(?:=>|->|[=:,])\s*)(['\"])[^'\"]+\3"), r"\1\3" + MASK + r"\3"),
33
+ # conn.setPassword("x"), setKeystorePass("x")
34
+ (_rx(r"(\bset\w*?" + _KW + r"\s*\(\s*)(['\"])[^'\"]*\2"), r"\1\2" + MASK + r"\2"),
35
+ # DriverManager.getConnection(url, "sa", "x")
36
+ (_rx(r"(\bgetConnection\s*\([^,()]*,[^,()]*,\s*)(['\"])[^'\"]*\2"), r"\1\2" + MASK + r"\2"),
37
+ # new PasswordAuthentication("u", "x".toCharArray())
38
+ (_rx(r"(\bPasswordAuthentication\s*\([^,()]*,\s*)(['\"])[^'\"]*\2"), r"\1\2" + MASK + r"\2"),
39
+ # <property name="jdbc.password" value="x"/>
40
+ (_rx(r"(\bname\s*=\s*['\"][^'\"]*?" + _KW + r"[^'\"]*['\"][^>]*?\bvalue\s*=\s*)(['\"])[^'\"]*\2"),
41
+ r"\1\2" + MASK + r"\2"),
42
+ # <param-name>password</param-name><param-value>x</param-value>
43
+ (_rx(r"(<" + _NAME_TAGS + r">[^<]*?" + _KW + r"[^<]*</" + _NAME_TAGS + r">\s*<" + _VALUE_TAGS + r">)[^<]+"),
44
+ r"\1" + MASK),
45
+ # <password>x</password>
46
+ (_rx(r"(<([\w.\-:]*?" + _KW + r")\s*>)[^<]+(?=</\2\s*>)"), r"\1" + MASK),
47
+ # jdbc:sqlserver://h;password=x ?pwd=x& password={a;b}
48
+ (_rx(r"([;?&](?:password|passwd|pwd)=)(?:\{[^}]*\}|[^;\"'&\s]+)"), r"\1" + MASK),
49
+ # scheme://user:pass@host
50
+ (_rx(r"(?<![a-z0-9+.\-])([a-z][a-z0-9+.\-]*://[^/\s:@\"']+:)[^@\s\"'/]+@"), r"\1" + MASK + "@"),
51
+ # jdbc:oracle:thin:user/pass@host
52
+ (_rx(r"(\bjdbc:oracle:\w+:[^/\s:@\"']+/)[^@\s\"']+@"), r"\1" + MASK + "@"),
53
+ # Authorization: Basic xxx / Bearer xxx
54
+ (_rx(r"(\b(?:Basic|Bearer)\s+)[A-Za-z0-9._~+/=\-]{8,}"), r"\1" + MASK),
55
+ # Well-known token formats (case-sensitive)
56
+ (_rx(r"\b(?:AKIA[0-9A-Z]{16}|gh[pousr]_[A-Za-z0-9]{30,}|github_pat_\w{20,}|sk-[A-Za-z0-9_\-]{20,}"
57
+ r"|AIza[0-9A-Za-z_\-]{35})\b", 0), MASK),
58
+ # Unquoted config values (.properties, YAML, .env) on their own line; ${PLACEHOLDER} and code are kept
59
+ (_rx(r"^(\s*[\w.\-]*?" + _KW + r"[\w.\-]*\s*[=:]\s*)(?![\"'$=\s])[^\s\"'(){}<>,;]+\s*(?:(?:#|//).*)?$"), r"\1" + MASK),
60
+ # Dotted config keys inside comments or strings: <%-- db.password=x --%>
61
+ (_rx(r"(?<![\w.\-])([\w\-]+\.[\w.\-]*?" + _KW + r"[\w\-]*\s*=\s*)(?![\"'$=\s])[^\s\"'(){}<>,;]+(?=\s|$|[\"'])"),
62
+ r"\1" + MASK),
63
+ )
64
+
65
+ _PRIVATE_KEY_BEGIN = _rx(r"-----BEGIN [A-Z ]*PRIVATE KEY-----", 0)
66
+ _PRIVATE_KEY_END = _rx(r"-----END [A-Z ]*PRIVATE KEY-----", 0)
67
+ # A secret name whose value follows on a later line: <property name="password"\n value="x"/>, web.xml params
68
+ _PENDING_NAME = _rx(r"\bname\s*=\s*['\"][^'\"]*?" + _KW + r"[^'\"]*['\"]|<" + _NAME_TAGS + r">[^<]*?" + _KW)
69
+ _PENDING_VALUE: Tuple[Tuple[re.Pattern[str], str], ...] = (
70
+ (_rx(r"(\bvalue\s*=\s*)(['\"])[^'\"]*\2"), r"\1\2" + MASK + r"\2"),
71
+ (_rx(r"(<" + _VALUE_TAGS + r">)[^<]+"), r"\1" + MASK),
72
+ )
73
+ _PENDING_LINES = 2
74
+
75
+
76
+ def _too_long(s: str) -> Optional[str]:
77
+ return f"[line of {len(s)} characters omitted]" if len(s) > MAX_LINE_CHARS else None
78
+
79
+
80
+ def redact(s: str) -> str:
81
+ """Mask secrets in one line of code or config."""
82
+ omitted = _too_long(s)
83
+ if omitted:
84
+ return omitted
85
+ for pattern, repl in _RULES:
86
+ s = pattern.sub(repl, s)
87
+ return s
88
+
89
+
90
+ def _redact_pending(line: str) -> Tuple[str, bool]:
91
+ for pattern, repl in _PENDING_VALUE:
92
+ masked, n = pattern.subn(repl, line)
93
+ if n:
94
+ return masked, True
95
+ return line, False
96
+
97
+
98
+ def redact_lines(lines: List[str]) -> List[str]:
99
+ """Redact each line, blank out private key bodies and mask values split from their names.
100
+
101
+ The line count stays the same, so line numbers in excerpts remain valid.
102
+ """
103
+ out: List[str] = []
104
+ in_key, pending = False, 0
105
+ for line in lines:
106
+ if in_key:
107
+ in_key = not _PRIVATE_KEY_END.search(line)
108
+ out.append(MASK if in_key else line)
109
+ continue
110
+ masked = redact(line)
111
+ if pending:
112
+ masked, done = _redact_pending(masked)
113
+ pending = 0 if done else pending - 1
114
+ if _PENDING_NAME.search(line) and MASK not in masked:
115
+ pending = _PENDING_LINES
116
+ in_key = bool(_PRIVATE_KEY_BEGIN.search(line)) and not _PRIVATE_KEY_END.search(line)
117
+ out.append(masked)
118
+ return out