@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.
Files changed (55) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/LICENSE +202 -0
  3. package/README.md +1 -0
  4. package/assess/lsa/scripts/detect_stack.py +13 -5
  5. package/assess/lsa/scripts/find_injection.py +35 -2
  6. package/assess/lsa/scripts/java_routes.py +323 -0
  7. package/assess/lsa/scripts/lsa_common.py +72 -2
  8. package/assess/lsa/scripts/map_routes.py +36 -160
  9. package/assess/lsa/scripts/merge_findings.py +2 -0
  10. package/assess/lsa/tests/test_java_legacy_realistic.py +453 -0
  11. package/contracts/schemas/persona-suite.schema.json +38 -2
  12. package/examples/member-portal/backend-dotnet/Dockerfile +2 -1
  13. package/examples/member-portal/backend-spring/Dockerfile +2 -0
  14. package/examples/member-portal/docker-compose.yml +1 -1
  15. package/examples/member-portal/frontend/Dockerfile +3 -2
  16. package/examples/member-portal/frontend/package-lock.json +24 -656
  17. package/examples/member-portal/frontend/package.json +1 -1
  18. package/operate/ai/runner.py +20 -2
  19. package/operate/cli/__init__.py +1 -1
  20. package/operate/cli/commands/fix.py +4 -0
  21. package/operate/cli/commands/leak_check.py +75 -0
  22. package/operate/cli/commands/protect.py +32 -3
  23. package/operate/cli/commands/test.py +131 -15
  24. package/operate/cli/main.py +5 -3
  25. package/operate/cross_user_tester/persona_matrix.py +118 -25
  26. package/operate/cross_user_tester/runner.py +190 -7
  27. package/operate/cross_user_tester/session_config.py +180 -0
  28. package/operate/cross_user_tester/session_http.py +288 -0
  29. package/operate/devsecops/leak_check.py +314 -0
  30. package/operate/remediator/runner.py +18 -1
  31. package/operate/remediator/strategies.py +2 -2
  32. package/operate/simulate/scenarios.py +2 -2
  33. package/operate/tests/mock_legacy_session_app.py +160 -0
  34. package/operate/tests/test_cross_user_session.py +565 -0
  35. package/operate/tests/test_docker_packaging.py +3 -3
  36. package/operate/tests/test_headless_ai.py +6 -0
  37. package/operate/tests/test_leak_check.py +250 -0
  38. package/operate/tests/test_legacy_session_edge.py +192 -0
  39. package/operate/tests/test_pilot_safeguards.py +245 -0
  40. package/operate/tests/test_remediator_java_legacy.py +330 -0
  41. package/package.json +2 -1
  42. package/remediate/authz/patcher.py +120 -11
  43. package/remediate/authz/tests/test_bola_patcher.py +4 -1
  44. package/remediate/edge/coraza/Caddyfile +1 -1
  45. package/remediate/edge/coraza/Dockerfile +9 -0
  46. package/remediate/edge/coraza/rules/coraza-crs-custom.conf +12 -0
  47. package/remediate/edge/coraza/virtual_patcher.py +24 -0
  48. package/remediate/edge/docker-compose.yml +7 -2
  49. package/remediate/edge/kong/kong-legacy-session.yaml +51 -0
  50. package/remediate/edge/mock-backend/Dockerfile +2 -0
  51. package/remediate/edge/tests/test_edge_security.py +21 -1
  52. package/remediate/resilience/terraform/backup_worm.tf +39 -1
  53. package/remediate/resilience/terraform/main.tf +15 -3
  54. package/remediate/resilience/terraform/variables.tf +11 -0
  55. package/remediate/resilience/tests/test_resilience.py +16 -0
@@ -0,0 +1,288 @@
1
+ """HTTP and verdict helpers for session-based legacy apps (JSP / Struts / old Spring MVC).
2
+
3
+ Redirects are never followed here: a 302 to the login page is the answer to
4
+ evaluate, and following it would turn a refusal into a 200. Cookies and CSRF
5
+ tokens stay inside the request and are never put in a reply, reason or error.
6
+
7
+ Python standard library only.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import re
14
+ import urllib.error
15
+ import urllib.parse
16
+ import urllib.request
17
+ from dataclasses import dataclass
18
+ from html.parser import HTMLParser
19
+ from typing import Any, Dict, List, Mapping, NamedTuple, Optional, Tuple
20
+
21
+ ALLOWED = "allowed"
22
+ DENIED = "denied"
23
+ INCONCLUSIVE = "inconclusive"
24
+
25
+ DEFAULT_DENY_STATUS = (401, 403, 404)
26
+ STRICT_DENY_STATUS = (404,)
27
+ BODY_FORMATS = ("json", "form")
28
+ WRITE_METHODS = ("POST", "PUT", "PATCH", "DELETE")
29
+ MAX_BODY_BYTES = 1_000_000
30
+ FORM_CONTENT_TYPE = "application/x-www-form-urlencoded"
31
+
32
+ _FORM_ACTION_SUFFIXES = (".do", ".action", ".jsp", ".jspx")
33
+ _STRUTS2_NAME_FIELD = "struts.token.name"
34
+ _CHARSET = re.compile(r"charset\s*=\s*[\"']?([A-Za-z0-9_.:-]+)", re.IGNORECASE)
35
+ # Pages labelled Shift_JIS are usually cp932 in practice, and Python has no windows-31j alias.
36
+ _CHARSET_ALIASES = {
37
+ "windows-31j": "cp932",
38
+ "ms932": "cp932",
39
+ "shift_jis": "cp932",
40
+ "shift-jis": "cp932",
41
+ "sjis": "cp932",
42
+ "x-sjis": "cp932",
43
+ }
44
+
45
+
46
+ class SessionConfigError(ValueError):
47
+ """Invalid session, form or CSRF configuration. Messages never contain secret values."""
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class CsrfConfig:
52
+ """Page to fetch and field to read. The token is fetched fresh for every write."""
53
+
54
+ page: str
55
+ field: str
56
+
57
+
58
+ @dataclass(frozen=True)
59
+ class DenialSignal:
60
+ """How this application says no, and optionally how it says yes."""
61
+
62
+ deny_status: Tuple[int, ...] = ()
63
+ deny_redirects: Tuple[str, ...] = ()
64
+ deny_markers: Tuple[str, ...] = ()
65
+ allow_redirects: Tuple[str, ...] = ()
66
+ allow_markers: Tuple[str, ...] = ()
67
+
68
+ def declared(self) -> bool:
69
+ return bool(self.deny_status or self.deny_redirects or self.deny_markers)
70
+
71
+
72
+ class HttpReply(NamedTuple):
73
+ status: int
74
+ location: str
75
+ body: str
76
+ error: Optional[str]
77
+
78
+
79
+ class SessionRequest(NamedTuple):
80
+ method: str
81
+ path: str
82
+ cookie: Optional[str] = None
83
+ auth_header: Optional[Tuple[str, str]] = None
84
+ payload: Optional[Dict[str, Any]] = None
85
+ body_format: str = "json"
86
+ csrf: Optional[CsrfConfig] = None
87
+ headers: Optional[Dict[str, str]] = None
88
+
89
+
90
+ def is_form_action(path: str) -> bool:
91
+ """True for routes shaped like a Java legacy form action (/order/update.do)."""
92
+ return path.split("?", 1)[0].lower().endswith(_FORM_ACTION_SUFFIXES)
93
+
94
+
95
+ def parse_body_format(raw: Any, where: str, default: str = "json") -> str:
96
+ if raw is None:
97
+ return default
98
+ if raw not in BODY_FORMATS:
99
+ raise SessionConfigError(f"{where}: body_format must be one of {list(BODY_FORMATS)}")
100
+ return str(raw)
101
+
102
+
103
+ def parse_csrf(raw: Any, where: str, default: Optional[CsrfConfig] = None) -> Optional[CsrfConfig]:
104
+ if raw is None:
105
+ return default
106
+ if not isinstance(raw, dict) or set(raw) != {"page", "field"}:
107
+ raise SessionConfigError(f"{where}: csrf must be an object with 'page' and 'field'")
108
+ page, field_name = raw["page"], raw["field"]
109
+ if not isinstance(page, str) or not page.startswith("/") or not isinstance(field_name, str) or not field_name:
110
+ raise SessionConfigError(f"{where}: csrf.page must start with '/' and csrf.field must be a name")
111
+ return CsrfConfig(page=page, field=field_name)
112
+
113
+
114
+ def encode_form(payload: Mapping[str, Any]) -> bytes:
115
+ """Encode a flat mapping as a form body. Raises ValueError on nested values."""
116
+ for key, value in payload.items():
117
+ if isinstance(value, dict) or (isinstance(value, list) and any(isinstance(v, (dict, list)) for v in value)):
118
+ raise ValueError(f"form field '{key}' must be a plain value")
119
+ return urllib.parse.urlencode(payload, doseq=True).encode("ascii")
120
+
121
+
122
+ def decode_body(raw: bytes, content_type: str) -> str:
123
+ """Decode using the declared charset, so markers in Japanese pages can match."""
124
+ found = _CHARSET.search(content_type or "") or _CHARSET.search(raw[:2048].decode("ascii", "ignore"))
125
+ if found:
126
+ name = found.group(1).lower()
127
+ try:
128
+ return raw.decode(_CHARSET_ALIASES.get(name, name), errors="replace")
129
+ except LookupError:
130
+ pass
131
+ # Nothing usable declared: legacy Japanese pages are often cp932 with no charset at all.
132
+ for encoding in ("utf-8", "cp932"):
133
+ try:
134
+ return raw.decode(encoding)
135
+ except UnicodeDecodeError:
136
+ continue
137
+ return raw.decode("utf-8", errors="replace")
138
+
139
+
140
+ class _FieldCollector(HTMLParser):
141
+ def __init__(self) -> None:
142
+ super().__init__(convert_charrefs=True)
143
+ self.fields: List[Tuple[str, str]] = []
144
+
145
+ def handle_starttag(self, tag: str, attrs: List[Tuple[str, Optional[str]]]) -> None:
146
+ values = dict(attrs)
147
+ name = values.get("name")
148
+ if not name:
149
+ return
150
+ if tag == "input":
151
+ self.fields.append((name, values.get("value") or ""))
152
+ elif tag == "meta":
153
+ self.fields.append((name, values.get("content") or ""))
154
+
155
+
156
+ def extract_form_token(html: str, field_name: str) -> Optional[Dict[str, str]]:
157
+ """Fields to send for a token, or None when the page does not carry it.
158
+
159
+ Struts 2 ``<s:token>`` needs a second field naming the token field, so it
160
+ is returned too.
161
+ """
162
+ collector = _FieldCollector()
163
+ collector.feed(html)
164
+ found = dict(reversed(collector.fields))
165
+ token = found.get(field_name)
166
+ if not token:
167
+ return None
168
+ if found.get(_STRUTS2_NAME_FIELD) == field_name:
169
+ return {_STRUTS2_NAME_FIELD: field_name, field_name: token}
170
+ return {field_name: token}
171
+
172
+
173
+ def safe_location(location: str) -> str:
174
+ """Redirect target fit for a report: no ;jsessionid=, query string or fragment."""
175
+ return re.split(r"[;?#]", location, maxsplit=1)[0]
176
+
177
+
178
+ def _matches(location: str, patterns: Tuple[str, ...]) -> bool:
179
+ # Patterns without '?' look at the path only, so /login.do?next=/list.do is not /list.do.
180
+ return any(p in (location if "?" in p else location.split("?", 1)[0]) for p in patterns)
181
+
182
+
183
+ def _both_signals(where: str) -> str:
184
+ return f"{where} matches both a denial and an allow signal"
185
+
186
+
187
+ def _classify_redirect(signal: DenialSignal, status: int, location: str) -> Tuple[str, str]:
188
+ denied, allowed = _matches(location, signal.deny_redirects), _matches(location, signal.allow_redirects)
189
+ target = safe_location(location) or "(no Location)"
190
+ if denied and allowed:
191
+ return INCONCLUSIVE, _both_signals(f"redirect to {target}")
192
+ if denied:
193
+ return DENIED, f"redirect to {target}"
194
+ if allowed:
195
+ return ALLOWED, f"redirect to {target}"
196
+ return INCONCLUSIVE, f"HTTP {status} redirect to {target} is not a declared denial or allow target"
197
+
198
+
199
+ def _classify_page(signal: DenialSignal, body: str) -> Tuple[str, str]:
200
+ denied = any(m in body for m in signal.deny_markers)
201
+ allowed = any(m in body for m in signal.allow_markers)
202
+ if denied and allowed:
203
+ return INCONCLUSIVE, _both_signals("response body")
204
+ if denied:
205
+ return DENIED, "denial marker in response body"
206
+ if allowed or not signal.allow_markers:
207
+ return ALLOWED, "response served without a denial signal"
208
+ return INCONCLUSIVE, "response body has neither a denial nor an allow marker"
209
+
210
+
211
+ def classify(signal: DenialSignal, status: int, location: str, body: str) -> Tuple[str, str]:
212
+ """Return (verdict, reason). Anything not positively identified is inconclusive."""
213
+ if status in signal.deny_status:
214
+ return DENIED, f"HTTP {status} is a declared denial status"
215
+ if 300 <= status < 400:
216
+ return _classify_redirect(signal, status, location)
217
+ if 200 <= status < 300:
218
+ return _classify_page(signal, body)
219
+ return INCONCLUSIVE, f"HTTP {status} is not a declared denial status"
220
+
221
+
222
+ class _NoRedirect(urllib.request.HTTPRedirectHandler):
223
+ def redirect_request(self, *args: Any, **kwargs: Any) -> None:
224
+ return None
225
+
226
+
227
+ def _send(url: str, method: str, headers: Dict[str, str], data: Optional[bytes], timeout: float) -> HttpReply:
228
+ if not url.lower().startswith(("http://", "https://")):
229
+ return HttpReply(0, "", "", "base URL must be an http:// or https:// URL")
230
+ request = urllib.request.Request(url, data=data, headers=headers, method=method)
231
+ opener = urllib.request.build_opener(_NoRedirect)
232
+ try:
233
+ # The scheme is checked above.
234
+ with opener.open(request, timeout=timeout) as resp: # nosec B310
235
+ raw = resp.read(MAX_BODY_BYTES)
236
+ return HttpReply(resp.status, "", decode_body(raw, resp.headers.get("Content-Type", "")), None)
237
+ except urllib.error.HTTPError as err:
238
+ raw = err.read(MAX_BODY_BYTES)
239
+ return HttpReply(
240
+ err.code, err.headers.get("Location", ""), decode_body(raw, err.headers.get("Content-Type", "")), None
241
+ )
242
+ except Exception as ex: # network errors are reported, not raised
243
+ return HttpReply(0, "", "", f"{type(ex).__name__}: request failed")
244
+
245
+
246
+ def _base_headers(req: SessionRequest) -> Dict[str, str]:
247
+ headers: Dict[str, str] = dict(req.headers or {})
248
+ if req.cookie:
249
+ headers["Cookie"] = req.cookie
250
+ if req.auth_header:
251
+ headers[req.auth_header[0]] = req.auth_header[1]
252
+ return headers
253
+
254
+
255
+ def fetch_page(base_url: str, path: str, req: SessionRequest, timeout: float) -> HttpReply:
256
+ """GET a page with the same credentials as ``req``."""
257
+ return _send(f"{base_url}{path}", "GET", _base_headers(req), None, timeout)
258
+
259
+
260
+ def _csrf_fields(base_url: str, req: SessionRequest, timeout: float) -> Tuple[Dict[str, str], Optional[str]]:
261
+ assert req.csrf is not None
262
+ page = fetch_page(base_url, req.csrf.page, req, timeout)
263
+ if page.error or not 200 <= page.status < 300:
264
+ return {}, f"CSRF page {req.csrf.page} failed: {page.error or f'HTTP {page.status}'}"
265
+ fields = extract_form_token(page.body, req.csrf.field)
266
+ if fields is None:
267
+ return {}, f"CSRF token field '{req.csrf.field}' was not found on {req.csrf.page}"
268
+ return fields, None
269
+
270
+
271
+ def deliver(base_url: str, req: SessionRequest, timeout: float) -> HttpReply:
272
+ """Send one request, adding a fresh CSRF token to writes when configured."""
273
+ payload = req.payload
274
+ if req.csrf is not None and req.method in WRITE_METHODS:
275
+ fields, problem = _csrf_fields(base_url, req, timeout)
276
+ if problem:
277
+ return HttpReply(0, "", "", problem)
278
+ payload = {**(payload or {}), **fields}
279
+
280
+ headers = _base_headers(req)
281
+ data: Optional[bytes] = None
282
+ if payload is not None:
283
+ try:
284
+ data = encode_form(payload) if req.body_format == "form" else json.dumps(payload).encode("utf-8")
285
+ except ValueError as err:
286
+ return HttpReply(0, "", "", str(err))
287
+ headers.setdefault("Content-Type", FORM_CONTENT_TYPE if req.body_format == "form" else "application/json")
288
+ return _send(f"{base_url}{req.path}", req.method, headers, data, timeout)
@@ -0,0 +1,314 @@
1
+ """Post-run leak check for pilot outputs: ai-raw/, findings and reports (runbook §9).
2
+
3
+ Four independent detectors, because the output must be checked for what the redaction step missed:
4
+ client-provided canary strings, the redaction patterns, further token and credential patterns, and Shannon entropy.
5
+ Text is also checked after JSON/backslash and HTML unescaping, so "password": "x" stored inside a JSON string is
6
+ seen the way a reader would see it. Reported values are always masked. Standard-library only (Python 3.8+).
7
+
8
+ A clean result is not proof that no secret remains: the detectors are heuristics, so Person B still reviews.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import base64
14
+ import html
15
+ import math
16
+ import os
17
+ import re
18
+ import unicodedata
19
+ from collections import Counter
20
+ from dataclasses import dataclass
21
+ from typing import Callable, Dict, Iterable, List, Pattern, Sequence, Set, Tuple
22
+ from urllib.parse import quote
23
+
24
+ from operate.ai.context import _load_redaction
25
+ from operate.devsecops.pipeline import SECRET_PATTERNS
26
+
27
+ _redaction = _load_redaction()
28
+ MASK: str = _redaction.MASK
29
+ MAX_LINE_CHARS: int = _redaction.MAX_LINE_CHARS
30
+ _CHUNK_OVERLAP = 200
31
+ MIN_CANARY_CHARS = 4
32
+ MIN_ALIGNED_BASE64_CHARS = 8
33
+
34
+ # (line number in the file, text); one physical line can become several entries after unescaping
35
+ Entry = Tuple[int, str]
36
+
37
+ _PRIVATE_KEY = re.compile(r"-----BEGIN (?:[A-Z0-9]+ )*PRIVATE KEY(?: BLOCK)?-----")
38
+ _TOKEN_PATTERNS: Tuple[Tuple[Pattern[str], str], ...] = tuple(
39
+ (re.compile(pattern), name) for pattern, name in SECRET_PATTERNS if "PRIVATE KEY" not in pattern
40
+ ) + (
41
+ (re.compile(r"\beyJ[A-Za-z0-9_\-]{8,}\.eyJ[A-Za-z0-9_\-]{8,}\.[A-Za-z0-9_\-]+"), "JSON Web Token"),
42
+ (re.compile(r"\bAccountKey=[A-Za-z0-9+/]{20,}=*"), "Azure Storage Account Key"),
43
+ (re.compile(r"\b(?:sk|rk)_live_[A-Za-z0-9]{16,}"), "Stripe Live Key"),
44
+ # Japanese key names, which the English keyword patterns cannot see
45
+ (re.compile(r"(?:パスワード|暗証番号|秘密鍵|認証キー)\s*[::==]\s*(?!\[REDACTED)[!-~]{4,}"), "Japanese-labelled credential"),
46
+ )
47
+
48
+ _TOKEN = re.compile(r"[A-Za-z0-9+/=_\-]{20,}")
49
+ _HEX_LIKE = re.compile(r"[0-9a-fA-F\-]+")
50
+ _SECRET_WORD = re.compile(r"(?:secret|token|passw|credential|api[_-]?key|private[_-]?key)", re.IGNORECASE)
51
+ _HASH_WORD = re.compile(r"(?:fingerprint|sha\d*|hash|digest|md5|checksum|integrity|commit|etag)", re.IGNORECASE)
52
+ _ENTROPY_RATIO = 0.85
53
+ _MIN_DIGIT_TRANSITIONS = 3
54
+ _DIGIT_TRANSITION_RATE = 0.12
55
+
56
+ _BACKSLASH_ESCAPE = re.compile(r"\\(u[0-9a-fA-F]{4}|[\"'\\/nrtbf])")
57
+ _BACKSLASH_MAP: Dict[str, str] = {"n": "\n", "r": "\n", "t": "\t", "b": " ", "f": " "}
58
+ _UNESCAPE_ROUNDS = 3
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class Hit:
63
+ """One suspected leak. Never holds the value itself."""
64
+
65
+ path: str
66
+ line: int
67
+ detector: str
68
+ rule: str
69
+ masked: str
70
+
71
+
72
+ @dataclass(frozen=True)
73
+ class ScanResult:
74
+ hits: Tuple[Hit, ...]
75
+ files_scanned: int
76
+ unscanned: Tuple[Tuple[str, str], ...]
77
+ canaries_loaded: int
78
+
79
+ @property
80
+ def exit_code(self) -> int:
81
+ """1 on any hit; 2 when the scan is incomplete or saw nothing, so a bad path cannot pass as clean."""
82
+ if self.hits:
83
+ return 1
84
+ return 2 if self.unscanned or not self.files_scanned else 0
85
+
86
+
87
+ class CanaryFileError(ValueError):
88
+ """The canary file cannot be used, so the check must not report a clean result."""
89
+
90
+
91
+ def load_canaries(path: str) -> Tuple[str, ...]:
92
+ """Read one canary per line. Every non-blank line counts: a "#" comment syntax could hide a real value."""
93
+ try:
94
+ with open(path, "r", encoding="utf-8-sig") as handle:
95
+ canaries = tuple(line.strip() for line in handle if line.strip())
96
+ except (OSError, UnicodeDecodeError) as exc:
97
+ raise CanaryFileError(f"cannot read canary file: {exc}") from exc
98
+ if not canaries:
99
+ raise CanaryFileError("canary file has no entries")
100
+ short = [number for number, value in enumerate(canaries, 1) if len(value) < MIN_CANARY_CHARS]
101
+ if short:
102
+ raise CanaryFileError(f"canary entries shorter than {MIN_CANARY_CHARS} characters: {short}")
103
+ return canaries
104
+
105
+
106
+ def _base64_forms(raw: bytes) -> Set[str]:
107
+ """The canary's base64 at each byte alignment, so it is found inside a longer encoded payload too."""
108
+ forms: Set[str] = set()
109
+ for offset in range(3):
110
+ encoded = base64.b64encode(b"\0" * offset + raw).decode().rstrip("=")
111
+ # The first and last characters also carry bits of the neighbouring bytes
112
+ form = encoded[math.ceil(offset * 4 / 3) :]
113
+ form = form[:-1] if (offset + len(raw)) % 3 else form
114
+ if offset == 0 or len(form) >= MIN_ALIGNED_BASE64_CHARS:
115
+ forms.update((form, form.replace("+", "-").replace("/", "_")))
116
+ return forms
117
+
118
+
119
+ def _compile_canaries(canaries: Sequence[str]) -> Tuple[Tuple[Pattern[str], str], ...]:
120
+ """Each canary as typed, and as it appears once URL or HTML encoded (any case), or base64 encoded (exact)."""
121
+ compiled: List[Tuple[Pattern[str], str]] = []
122
+ for number, value in enumerate(canaries, 1):
123
+ parts = [re.escape(form) for form in (value, html.escape(value), quote(value, safe=""))]
124
+ rule = f"canary#{number}"
125
+ compiled.append((re.compile("|".join(parts), re.IGNORECASE), rule))
126
+ base64_forms = sorted(_base64_forms(value.encode("utf-8")))
127
+ compiled.append((re.compile("|".join(re.escape(form) for form in base64_forms)), rule))
128
+ return tuple(compiled)
129
+
130
+
131
+ def shannon_entropy(token: str) -> float:
132
+ """Bits per character."""
133
+ length = len(token)
134
+ return -sum((count / length) * math.log2(count / length) for count in Counter(token).values())
135
+
136
+
137
+ def _is_random_token(token: str, line: str) -> bool:
138
+ """Random-looking token that is not a hash, path or identifier."""
139
+ if not any(ch.isdigit() for ch in token) or not any(ch.isalpha() for ch in token):
140
+ return False
141
+ if _HEX_LIKE.fullmatch(token):
142
+ # Hashes fill audit output (fingerprints, checksums), so bare hex needs a secret word on the same line
143
+ return bool(_SECRET_WORD.search(line)) and not _HASH_WORD.search(line)
144
+ transitions = sum(1 for a, b in zip(token, token[1:]) if a.isdigit() != b.isdigit())
145
+ if transitions < max(_MIN_DIGIT_TRANSITIONS, _DIGIT_TRANSITION_RATE * len(token)):
146
+ return False
147
+ return shannon_entropy(token) >= _ENTROPY_RATIO * math.log2(len(token))
148
+
149
+
150
+ def _masked(value: str) -> str:
151
+ return f"[MASKED len={len(value)}]"
152
+
153
+
154
+ def _unescape_once(text: str) -> str:
155
+ def replace(match: "re.Match[str]") -> str:
156
+ escape = match.group(1)
157
+ if escape.startswith("u"):
158
+ return chr(int(escape[1:], 16))
159
+ return _BACKSLASH_MAP.get(escape, escape)
160
+
161
+ unescaped = html.unescape(_BACKSLASH_ESCAPE.sub(replace, text))
162
+ # Full-width forms and zero-width characters read the same to a person
163
+ normalized = unicodedata.normalize("NFKC", unescaped)
164
+ return "".join(ch for ch in normalized if unicodedata.category(ch) != "Cf")
165
+
166
+
167
+ def _decoded_entries(lines: Sequence[str]) -> List[Entry]:
168
+ """Lines that change when unescaped, split as a reader would see them; handles double-encoded JSON."""
169
+ entries: List[Entry] = []
170
+ for number, line in enumerate(lines, 1):
171
+ current = line
172
+ for _ in range(_UNESCAPE_ROUNDS):
173
+ decoded = _unescape_once(current)
174
+ if decoded == current:
175
+ break
176
+ current = decoded
177
+ if current != line:
178
+ entries.extend((number, part) for part in current.split("\n"))
179
+ return entries
180
+
181
+
182
+ def _bounded(entries: Iterable[Entry]) -> List[Entry]:
183
+ """Split very long lines into overlapping chunks: redact() would otherwise drop them whole."""
184
+ bounded: List[Entry] = []
185
+ step = MAX_LINE_CHARS - _CHUNK_OVERLAP
186
+ for number, text in entries:
187
+ if len(text) <= MAX_LINE_CHARS:
188
+ bounded.append((number, text))
189
+ continue
190
+ bounded.extend((number, text[start : start + MAX_LINE_CHARS]) for start in range(0, len(text), step))
191
+ return bounded
192
+
193
+
194
+ def _find_canaries(entries: Sequence[Entry], compiled: Sequence[Tuple[Pattern[str], str]]) -> Iterable[Tuple[int, str, str, str]]:
195
+ for number, text in entries:
196
+ for pattern, rule in compiled:
197
+ for match in pattern.finditer(text):
198
+ yield number, "canary", rule, _masked(match.group(0))
199
+
200
+
201
+ def _find_redaction_misses(entries: Sequence[Entry]) -> Iterable[Tuple[int, str, str, str]]:
202
+ masked_lines = _redaction.redact_lines([text for _, text in entries])
203
+ for (number, text), masked in zip(entries, masked_lines):
204
+ if text != masked:
205
+ yield number, "redaction-pattern", "value the redaction step would mask", "[MASKED]"
206
+
207
+
208
+ def _find_tokens(entries: Sequence[Entry]) -> Iterable[Tuple[int, str, str, str]]:
209
+ for number, text in entries:
210
+ for pattern, name in _TOKEN_PATTERNS:
211
+ for match in pattern.finditer(text):
212
+ yield number, "token-pattern", name, _masked(match.group(0))
213
+
214
+
215
+ def _find_private_keys(entries: Sequence[Entry]) -> Iterable[Tuple[int, str, str, str]]:
216
+ """A key header counts unless its body was masked, either on the same line or on the next one."""
217
+ for index, (number, text) in enumerate(entries):
218
+ for match in _PRIVATE_KEY.finditer(text):
219
+ rest = text[match.end() :].strip()
220
+ following = entries[index + 1][1].strip() if index + 1 < len(entries) else ""
221
+ if not (rest.startswith(MASK) or (not rest and following.startswith(MASK))):
222
+ yield number, "token-pattern", "Private Key", _masked(match.group(0))
223
+
224
+
225
+ def _find_high_entropy(entries: Sequence[Entry]) -> Iterable[Tuple[int, str, str, str]]:
226
+ for number, text in entries:
227
+ for match in _TOKEN.finditer(text):
228
+ if _is_random_token(match.group(0), text):
229
+ yield number, "entropy", "high-entropy token", _masked(match.group(0))
230
+
231
+
232
+ _DETECTORS: Tuple[Callable[[Sequence[Entry]], Iterable[Tuple[int, str, str, str]]], ...] = (
233
+ _find_redaction_misses,
234
+ _find_tokens,
235
+ _find_private_keys,
236
+ _find_high_entropy,
237
+ )
238
+
239
+
240
+ def scan_text(path: str, text: str, compiled: Sequence[Tuple[Pattern[str], str]] = ()) -> List[Hit]:
241
+ """Run every detector over the text as stored and as unescaped; repeated sightings are reported once."""
242
+ lines = text.splitlines()
243
+ streams = (
244
+ [(number, line) for number, line in enumerate(lines, 1)],
245
+ _decoded_entries(lines),
246
+ )
247
+ found: List[Tuple[int, str, str, str]] = []
248
+ for entries in streams:
249
+ found.extend(_find_canaries(entries, compiled))
250
+ bounded = _bounded(entries)
251
+ for detector in _DETECTORS:
252
+ found.extend(detector(bounded))
253
+ return [Hit(path, number, detector, rule, masked) for number, detector, rule, masked in sorted(set(found))]
254
+
255
+
256
+ def _decode(data: bytes) -> str:
257
+ """Komagu writes UTF-8; copied source excerpts in Japanese systems are often Shift_JIS."""
258
+ if data.startswith((b"\xff\xfe", b"\xfe\xff")):
259
+ return data.decode("utf-16", errors="replace")
260
+ for encoding in ("utf-8-sig", "cp932"):
261
+ try:
262
+ return data.decode(encoding)
263
+ except UnicodeDecodeError:
264
+ continue
265
+ return data.decode("latin-1")
266
+
267
+
268
+ def _walk(paths: Sequence[str], errors: List[Tuple[str, str]]) -> Iterable[str]:
269
+ """Every file below the paths, plus symlinked directories for _read to refuse. Hidden directories count."""
270
+
271
+ def record(error: OSError) -> None:
272
+ errors.append((str(error.filename), f"unreadable: {error.strerror}"))
273
+
274
+ for root in paths:
275
+ if not os.path.isdir(root) or os.path.islink(root):
276
+ yield root
277
+ continue
278
+ for directory, subdirs, names in os.walk(root, onerror=record):
279
+ subdirs.sort()
280
+ links = [name for name in subdirs if os.path.islink(os.path.join(directory, name))]
281
+ yield from (os.path.join(directory, name) for name in links + sorted(names))
282
+
283
+
284
+ def _read(path: str) -> Tuple[str, str]:
285
+ """Return (text, "") or ("", reason not scanned)."""
286
+ if os.path.islink(path):
287
+ return "", "symbolic link"
288
+ if not os.path.isfile(path):
289
+ return "", "not found"
290
+ try:
291
+ with open(path, "rb") as handle:
292
+ data = handle.read()
293
+ except OSError as exc:
294
+ return "", f"unreadable: {exc.strerror}"
295
+ if b"\0" in data[:8192] and not data.startswith((b"\xff\xfe", b"\xfe\xff")):
296
+ return "", "binary file"
297
+ text = _decode(data)
298
+ return ("", "binary file") if "\0" in text else (text, "")
299
+
300
+
301
+ def scan_paths(paths: Sequence[str], canaries: Sequence[str] = ()) -> ScanResult:
302
+ """Scan files and directories; anything that cannot be read is listed, never silently skipped."""
303
+ compiled = _compile_canaries(canaries)
304
+ hits: List[Hit] = []
305
+ unscanned: List[Tuple[str, str]] = []
306
+ files_scanned = 0
307
+ for path in _walk(paths, unscanned):
308
+ text, reason = _read(path)
309
+ if reason:
310
+ unscanned.append((path, reason))
311
+ continue
312
+ files_scanned += 1
313
+ hits.extend(scan_text(path, text, compiled))
314
+ return ScanResult(tuple(hits), files_scanned, tuple(unscanned), len(canaries))
@@ -128,6 +128,16 @@ def write_source_file_safe(file_path: Path, content: str, encoding: str, newline
128
128
  file_path.write_bytes(encode_source(content, encoding, newline))
129
129
 
130
130
 
131
+ def line_after_earlier_patches(pre_patch: str, current: str, line: int) -> int:
132
+ """Map a 1-based line of the unpatched file (what findings refer to) into the patched content."""
133
+ idx = line - 1
134
+ matcher = difflib.SequenceMatcher(None, pre_patch.splitlines(), current.splitlines(), autojunk=False)
135
+ for _, i1, i2, j1, j2 in matcher.get_opcodes():
136
+ if i1 <= idx < i2 and j2 > j1:
137
+ return j1 + min(idx - i1, j2 - j1 - 1) + 1
138
+ return line
139
+
140
+
131
141
  class RemediationRunner:
132
142
  """Coordinates automated code patching based on security audit findings."""
133
143
 
@@ -178,6 +188,7 @@ class RemediationRunner:
178
188
  self.sync_lockfiles = sync_lockfiles
179
189
  self.registry = StrategyRegistry()
180
190
  self._virtual_contents: Dict[Path, str] = {}
191
+ self._pre_patch_contents: Dict[Path, str] = {}
181
192
 
182
193
  def discover_findings_file(self) -> Path:
183
194
  """Locate findings.json in default known output paths if not explicitly provided."""
@@ -365,7 +376,11 @@ class RemediationRunner:
365
376
  ),
366
377
  )
367
378
  original_content = self._virtual_contents.get(abs_file, disk_content)
368
- patched_content, explanation = strategy.generate_patch(abs_file, original_content, finding)
379
+ current_line = start_line
380
+ if abs_file in self._virtual_contents:
381
+ current_line = line_after_earlier_patches(self._pre_patch_contents[abs_file], original_content, start_line)
382
+ located_finding = {**finding, "location": {**loc, "start_line": current_line}}
383
+ patched_content, explanation = strategy.generate_patch(abs_file, original_content, located_finding)
369
384
 
370
385
  if patched_content == original_content:
371
386
  return RemediationPlan(
@@ -385,6 +400,7 @@ class RemediationRunner:
385
400
  )
386
401
 
387
402
  self._virtual_contents[abs_file] = patched_content
403
+ self._pre_patch_contents.setdefault(abs_file, original_content)
388
404
 
389
405
  # Generate unified diff
390
406
  orig_lines = original_content.splitlines(keepends=True)
@@ -469,6 +485,7 @@ class RemediationRunner:
469
485
  def run(self) -> RemediationReport:
470
486
  """Execute the remediation workflow."""
471
487
  self._virtual_contents.clear()
488
+ self._pre_patch_contents.clear()
472
489
  findings = self.load_findings()
473
490
 
474
491
  # Track (relative_file, finding_id) already queued so companion lockfiles are not duplicated
@@ -98,12 +98,12 @@ class BOLARemediationStrategy(BaseRemediationStrategy):
98
98
 
99
99
  # Use intelligent BOLA ownership patcher
100
100
  patcher = BOLAOwnershipPatcher()
101
- patched_content, result = patcher.patch_file(target_file, start_line + 1, finding)
101
+ patched_content, result = patcher.patch_file(target_file, start_line + 1, finding, content=content)
102
102
  if result.changed:
103
103
  return patched_content, result.explanation
104
104
 
105
105
  # Do NOT inject comment-only fake fixes. Require genuine code rewrite or manual fix.
106
- return content, "No automated BOLA ownership rewrite applicable; requires manual remediation."
106
+ return content, result.explanation or "No automated BOLA ownership rewrite applicable; requires manual remediation."
107
107
 
108
108
 
109
109
  class DependencyVulnerabilityRemediationStrategy(BaseRemediationStrategy):