@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.
- package/CHANGELOG.md +36 -0
- package/LICENSE +202 -0
- package/README.md +1 -0
- package/assess/lsa/CHANGELOG.md +15 -0
- package/assess/lsa/scripts/detect_stack.py +15 -6
- 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 +73 -7
- package/assess/lsa/scripts/map_routes.py +36 -160
- package/assess/lsa/scripts/merge_findings.py +2 -0
- package/assess/lsa/scripts/redaction.py +118 -0
- package/assess/lsa/tests/test_java_legacy_realistic.py +453 -0
- package/assess/lsa/tests/test_modern_stacks.py +13 -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/context.py +22 -2
- 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_ai_redaction.py +181 -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 +22 -1
- 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,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
|
-
|
|
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):
|