@hoanghainh/komagu 2.4.0 → 2.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +24 -0
- package/LICENSE +202 -0
- package/README.md +1 -0
- package/assess/lsa/scripts/detect_stack.py +13 -5
- package/assess/lsa/scripts/find_injection.py +35 -2
- package/assess/lsa/scripts/java_routes.py +323 -0
- package/assess/lsa/scripts/lsa_common.py +72 -2
- package/assess/lsa/scripts/map_routes.py +36 -160
- package/assess/lsa/scripts/merge_findings.py +2 -0
- package/assess/lsa/tests/test_java_legacy_realistic.py +453 -0
- package/contracts/schemas/persona-suite.schema.json +38 -2
- package/examples/member-portal/backend-dotnet/Dockerfile +2 -1
- package/examples/member-portal/backend-spring/Dockerfile +2 -0
- package/examples/member-portal/docker-compose.yml +1 -1
- package/examples/member-portal/frontend/Dockerfile +3 -2
- package/examples/member-portal/frontend/package-lock.json +24 -656
- package/examples/member-portal/frontend/package.json +1 -1
- package/operate/ai/runner.py +20 -2
- package/operate/cli/__init__.py +1 -1
- package/operate/cli/commands/fix.py +4 -0
- package/operate/cli/commands/leak_check.py +75 -0
- package/operate/cli/commands/protect.py +32 -3
- package/operate/cli/commands/test.py +131 -15
- package/operate/cli/main.py +5 -3
- package/operate/cross_user_tester/persona_matrix.py +118 -25
- package/operate/cross_user_tester/runner.py +190 -7
- package/operate/cross_user_tester/session_config.py +180 -0
- package/operate/cross_user_tester/session_http.py +288 -0
- package/operate/devsecops/leak_check.py +314 -0
- package/operate/remediator/runner.py +18 -1
- package/operate/remediator/strategies.py +2 -2
- package/operate/simulate/scenarios.py +2 -2
- package/operate/tests/mock_legacy_session_app.py +160 -0
- package/operate/tests/test_cross_user_session.py +565 -0
- package/operate/tests/test_docker_packaging.py +3 -3
- package/operate/tests/test_headless_ai.py +6 -0
- package/operate/tests/test_leak_check.py +250 -0
- package/operate/tests/test_legacy_session_edge.py +192 -0
- package/operate/tests/test_pilot_safeguards.py +245 -0
- package/operate/tests/test_remediator_java_legacy.py +330 -0
- package/package.json +2 -1
- package/remediate/authz/patcher.py +120 -11
- package/remediate/authz/tests/test_bola_patcher.py +4 -1
- package/remediate/edge/coraza/Caddyfile +1 -1
- package/remediate/edge/coraza/Dockerfile +9 -0
- package/remediate/edge/coraza/rules/coraza-crs-custom.conf +12 -0
- package/remediate/edge/coraza/virtual_patcher.py +24 -0
- package/remediate/edge/docker-compose.yml +7 -2
- package/remediate/edge/kong/kong-legacy-session.yaml +51 -0
- package/remediate/edge/mock-backend/Dockerfile +2 -0
- package/remediate/edge/tests/test_edge_security.py +21 -1
- package/remediate/resilience/terraform/backup_worm.tf +39 -1
- package/remediate/resilience/terraform/main.tf +15 -3
- package/remediate/resilience/terraform/variables.tf +11 -0
- package/remediate/resilience/tests/test_resilience.py +16 -0
|
@@ -23,10 +23,35 @@ import sys
|
|
|
23
23
|
import re
|
|
24
24
|
import time
|
|
25
25
|
import urllib.error
|
|
26
|
+
import urllib.parse
|
|
26
27
|
import urllib.request
|
|
28
|
+
from dataclasses import replace
|
|
27
29
|
from pathlib import Path
|
|
28
30
|
from typing import Any, Dict, List, Optional, Tuple, Union
|
|
29
31
|
|
|
32
|
+
if not __package__: # run directly as a script: the repository root is not on sys.path
|
|
33
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
|
34
|
+
|
|
35
|
+
from operate.cross_user_tester.session_config import SessionOptions # noqa: E402
|
|
36
|
+
from operate.cross_user_tester.session_http import ( # noqa: E402
|
|
37
|
+
ALLOWED,
|
|
38
|
+
DENIED,
|
|
39
|
+
INCONCLUSIVE,
|
|
40
|
+
WRITE_METHODS,
|
|
41
|
+
CsrfConfig,
|
|
42
|
+
DenialSignal,
|
|
43
|
+
HttpReply,
|
|
44
|
+
SessionRequest,
|
|
45
|
+
classify,
|
|
46
|
+
deliver,
|
|
47
|
+
is_form_action,
|
|
48
|
+
parse_body_format,
|
|
49
|
+
parse_csrf,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
_ID_PLACEHOLDER = re.compile(r"\{[a-zA-Z0-9_-]*(?:id|ID)[a-zA-Z0-9_-]*\}")
|
|
53
|
+
_TAMPER_PAYLOAD = {"status": "tampered_by_attacker", "note": "Komagu BOLA exploit verification"}
|
|
54
|
+
|
|
30
55
|
|
|
31
56
|
class TestCase:
|
|
32
57
|
"""Specification of a cross-user test case."""
|
|
@@ -44,6 +69,8 @@ class TestCase:
|
|
|
44
69
|
insecure_status: Optional[List[int]] = None,
|
|
45
70
|
finding_id: Optional[str] = None,
|
|
46
71
|
consensus_score: Optional[float] = None,
|
|
72
|
+
body_format: str = "json",
|
|
73
|
+
csrf: Optional[CsrfConfig] = None,
|
|
47
74
|
) -> None:
|
|
48
75
|
self.name = name
|
|
49
76
|
self.finding_id = finding_id
|
|
@@ -63,6 +90,8 @@ class TestCase:
|
|
|
63
90
|
self.headers = headers or {}
|
|
64
91
|
self.expected_status = expected_status or [404]
|
|
65
92
|
self.insecure_status = insecure_status or [200, 201, 202, 204]
|
|
93
|
+
self.body_format = body_format
|
|
94
|
+
self.csrf = csrf
|
|
66
95
|
|
|
67
96
|
|
|
68
97
|
class TestResult:
|
|
@@ -77,6 +106,7 @@ class TestResult:
|
|
|
77
106
|
is_secure: bool,
|
|
78
107
|
duration_ms: float,
|
|
79
108
|
error_message: Optional[str] = None,
|
|
109
|
+
owner_control: Optional[str] = None,
|
|
80
110
|
) -> None:
|
|
81
111
|
self.test_case = test_case
|
|
82
112
|
self.status_code = status_code
|
|
@@ -85,9 +115,10 @@ class TestResult:
|
|
|
85
115
|
self.is_secure = is_secure
|
|
86
116
|
self.duration_ms = duration_ms
|
|
87
117
|
self.error_message = error_message
|
|
118
|
+
self.owner_control = owner_control
|
|
88
119
|
|
|
89
120
|
def to_dict(self) -> Dict[str, Any]:
|
|
90
|
-
|
|
121
|
+
data: Dict[str, Any] = {
|
|
91
122
|
"name": self.test_case.name,
|
|
92
123
|
"finding_id": self.test_case.finding_id,
|
|
93
124
|
"consensus_score": self.test_case.consensus_score,
|
|
@@ -99,6 +130,9 @@ class TestResult:
|
|
|
99
130
|
"duration_ms": round(self.duration_ms, 2),
|
|
100
131
|
"error_message": self.error_message,
|
|
101
132
|
}
|
|
133
|
+
if self.owner_control is not None:
|
|
134
|
+
data["owner_control"] = self.owner_control
|
|
135
|
+
return data
|
|
102
136
|
|
|
103
137
|
|
|
104
138
|
|
|
@@ -112,15 +146,76 @@ class CrossUserTestRunner:
|
|
|
112
146
|
auth_header: str = "Authorization",
|
|
113
147
|
token_prefix: str = "Bearer ",
|
|
114
148
|
timeout: float = 10.0,
|
|
149
|
+
session: Optional[SessionOptions] = None,
|
|
115
150
|
) -> None:
|
|
116
151
|
self.base_url = base_url.rstrip("/")
|
|
117
152
|
self.default_attacker_token = default_attacker_token
|
|
118
153
|
self.auth_header = auth_header
|
|
119
154
|
self.token_prefix = token_prefix
|
|
120
155
|
self.timeout = timeout
|
|
156
|
+
self.session = session
|
|
157
|
+
|
|
158
|
+
def _auth_header(self, test: TestCase) -> Optional[Tuple[str, str]]:
|
|
159
|
+
token = test.attacker_token or self.default_attacker_token
|
|
160
|
+
return (self.auth_header, f"{self.token_prefix}{token}".strip()) if token else None
|
|
161
|
+
|
|
162
|
+
def _deliver(
|
|
163
|
+
self,
|
|
164
|
+
test: TestCase,
|
|
165
|
+
cookie: Optional[str],
|
|
166
|
+
auth: Optional[Tuple[str, str]],
|
|
167
|
+
csrf_page: Optional[str] = None,
|
|
168
|
+
) -> HttpReply:
|
|
169
|
+
csrf = replace(test.csrf, page=csrf_page) if test.csrf and csrf_page else test.csrf
|
|
170
|
+
request = SessionRequest(
|
|
171
|
+
method=test.method, path=test.path, cookie=cookie, auth_header=auth,
|
|
172
|
+
payload=test.payload, body_format=test.body_format, csrf=csrf, headers=test.headers,
|
|
173
|
+
)
|
|
174
|
+
return deliver(self.base_url, request, self.timeout)
|
|
175
|
+
|
|
176
|
+
def _owner_control(self, test: TestCase, options: SessionOptions, signal: DenialSignal) -> Tuple[str, Optional[str]]:
|
|
177
|
+
"""Replay a refused request as the owner: it must work, or the refusal proves nothing."""
|
|
178
|
+
if not options.owner_cookie:
|
|
179
|
+
return "not_run", None
|
|
180
|
+
if test.method in WRITE_METHODS and not options.owner_control_writes:
|
|
181
|
+
if options.strict:
|
|
182
|
+
return "skipped_write", "--strict cannot pass a refused write without --owner-control-writes"
|
|
183
|
+
return "skipped_write", None
|
|
184
|
+
reply = self._deliver(test, options.owner_cookie, None, options.owner_csrf_page)
|
|
185
|
+
verdict, reason = classify(signal, reply.status, reply.location, reply.body) if not reply.error else (INCONCLUSIVE, reply.error)
|
|
186
|
+
if verdict == ALLOWED:
|
|
187
|
+
return "passed", None
|
|
188
|
+
return "failed", f"owner control failed ({reason}): the route or resource ID cannot prove a refusal"
|
|
189
|
+
|
|
190
|
+
def _execute_session_test(self, test: TestCase) -> TestResult:
|
|
191
|
+
"""Legacy path: no redirects followed, verdict from the denial signal."""
|
|
192
|
+
options = self.session or SessionOptions()
|
|
193
|
+
signal = options.denial or DenialSignal(deny_status=tuple(test.expected_status))
|
|
194
|
+
auth = None if options.attacker_cookie else self._auth_header(test)
|
|
195
|
+
start_time = time.time()
|
|
196
|
+
reply = self._deliver(test, options.attacker_cookie, auth)
|
|
197
|
+
verdict, reason = classify(signal, reply.status, reply.location, reply.body) if not reply.error else (INCONCLUSIVE, reply.error)
|
|
198
|
+
control: Optional[str] = None
|
|
199
|
+
if verdict == DENIED:
|
|
200
|
+
control, problem = self._owner_control(test, options, signal)
|
|
201
|
+
if problem:
|
|
202
|
+
verdict, reason = INCONCLUSIVE, problem
|
|
203
|
+
return TestResult(
|
|
204
|
+
test_case=test,
|
|
205
|
+
status_code=reply.status,
|
|
206
|
+
response_body=reply.body[:1000],
|
|
207
|
+
is_breach=verdict == ALLOWED,
|
|
208
|
+
is_secure=verdict == DENIED,
|
|
209
|
+
duration_ms=(time.time() - start_time) * 1000.0,
|
|
210
|
+
error_message=reason if verdict == INCONCLUSIVE else None,
|
|
211
|
+
owner_control=control,
|
|
212
|
+
)
|
|
121
213
|
|
|
122
214
|
def execute_test(self, test: TestCase) -> TestResult:
|
|
123
215
|
"""Execute a single test case against the target API."""
|
|
216
|
+
if self.session is not None or test.csrf is not None or test.body_format == "form":
|
|
217
|
+
return self._execute_session_test(test)
|
|
218
|
+
|
|
124
219
|
url = f"{self.base_url}{test.path}"
|
|
125
220
|
headers = dict(test.headers)
|
|
126
221
|
|
|
@@ -183,6 +278,8 @@ def generate_crud_test_cases(
|
|
|
183
278
|
name_prefix: str = "Resource",
|
|
184
279
|
attacker_token: Optional[str] = None,
|
|
185
280
|
sample_payload: Optional[Dict[str, Any]] = None,
|
|
281
|
+
body_format: str = "json",
|
|
282
|
+
csrf: Optional[CsrfConfig] = None,
|
|
186
283
|
) -> List[TestCase]:
|
|
187
284
|
"""Generate the standard 3-operation (GET, PUT, DELETE) cross-user test cases."""
|
|
188
285
|
return [
|
|
@@ -200,6 +297,8 @@ def generate_crud_test_cases(
|
|
|
200
297
|
resource_id=resource_id,
|
|
201
298
|
attacker_token=attacker_token,
|
|
202
299
|
payload=sample_payload or {"status": "tampered_by_attacker"},
|
|
300
|
+
body_format=body_format,
|
|
301
|
+
csrf=csrf,
|
|
203
302
|
),
|
|
204
303
|
TestCase(
|
|
205
304
|
name=f"{name_prefix} - Cross-User Destruction (DELETE)",
|
|
@@ -207,11 +306,16 @@ def generate_crud_test_cases(
|
|
|
207
306
|
method="DELETE",
|
|
208
307
|
resource_id=resource_id,
|
|
209
308
|
attacker_token=attacker_token,
|
|
309
|
+
csrf=csrf,
|
|
210
310
|
),
|
|
211
311
|
]
|
|
212
312
|
|
|
213
313
|
|
|
214
|
-
def load_suite_from_file(
|
|
314
|
+
def load_suite_from_file(
|
|
315
|
+
file_path: str,
|
|
316
|
+
default_body_format: str = "json",
|
|
317
|
+
default_csrf: Optional[CsrfConfig] = None,
|
|
318
|
+
) -> List[TestCase]:
|
|
215
319
|
"""Load test cases from a JSON configuration file."""
|
|
216
320
|
with open(file_path, "r", encoding="utf-8") as f:
|
|
217
321
|
data = json.load(f)
|
|
@@ -220,6 +324,9 @@ def load_suite_from_file(file_path: str) -> List[TestCase]:
|
|
|
220
324
|
items = data if isinstance(data, list) else data.get("tests", [])
|
|
221
325
|
|
|
222
326
|
for item in items:
|
|
327
|
+
where = f"test '{item.get('name', item.get('path'))}'"
|
|
328
|
+
body_format = parse_body_format(item.get("body_format"), where, default_body_format)
|
|
329
|
+
csrf = parse_csrf(item.get("csrf"), where, default_csrf)
|
|
223
330
|
# If item specifies auto_crud, generate 3 CRUD operations
|
|
224
331
|
if item.get("auto_crud"):
|
|
225
332
|
cases = generate_crud_test_cases(
|
|
@@ -228,6 +335,8 @@ def load_suite_from_file(file_path: str) -> List[TestCase]:
|
|
|
228
335
|
name_prefix=item.get("name", item["path"]),
|
|
229
336
|
attacker_token=item.get("attacker_token"),
|
|
230
337
|
sample_payload=item.get("payload"),
|
|
338
|
+
body_format=body_format,
|
|
339
|
+
csrf=csrf,
|
|
231
340
|
)
|
|
232
341
|
test_cases.extend(cases)
|
|
233
342
|
else:
|
|
@@ -242,17 +351,70 @@ def load_suite_from_file(file_path: str) -> List[TestCase]:
|
|
|
242
351
|
headers=item.get("headers"),
|
|
243
352
|
expected_status=item.get("expected_status"),
|
|
244
353
|
insecure_status=item.get("insecure_status"),
|
|
354
|
+
body_format=body_format,
|
|
355
|
+
csrf=csrf,
|
|
245
356
|
)
|
|
246
357
|
)
|
|
247
358
|
return test_cases
|
|
248
359
|
|
|
249
360
|
|
|
361
|
+
def _has_id_placeholder(path: str) -> bool:
|
|
362
|
+
return "{id}" in path or ":id" in path or bool(_ID_PLACEHOLDER.search(path))
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
_WRITE_WORDS = {"update", "save", "delete", "remove", "create", "add", "insert", "submit", "regist", "register",
|
|
366
|
+
"cancel", "approve", "modify", "change"}
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _concrete_method(method: str, path: str) -> str:
|
|
370
|
+
"""Routes declared in XML accept any verb; a write action needs POST to do anything."""
|
|
371
|
+
if method.strip().upper() != "ANY":
|
|
372
|
+
return method
|
|
373
|
+
action = path.split("?", 1)[0].rsplit("/", 1)[-1].rsplit(".", 1)[0]
|
|
374
|
+
words = {word.lower() for word in re.findall(r"[A-Z]?[a-z]+", action)}
|
|
375
|
+
return "POST" if words & _WRITE_WORDS else "GET"
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _uses_form(path: str, body_format: str) -> bool:
|
|
379
|
+
return body_format == "form" or (body_format == "auto" and is_form_action(path))
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _suggested_form(attack_vector: Dict[str, Any], resource_id: str) -> Dict[str, Any]:
|
|
383
|
+
"""Form fields from attack_vector.suggested_payload ('id={id}&status=x'), if it has that shape."""
|
|
384
|
+
raw = attack_vector.get("suggested_payload")
|
|
385
|
+
if not isinstance(raw, str) or "=" not in raw:
|
|
386
|
+
return {}
|
|
387
|
+
return dict(urllib.parse.parse_qsl(raw.replace("{id}", resource_id), keep_blank_values=True))
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _form_action_request(
|
|
391
|
+
path: str,
|
|
392
|
+
method: str,
|
|
393
|
+
attack_vector: Dict[str, Any],
|
|
394
|
+
resource_id: str,
|
|
395
|
+
) -> Tuple[str, Optional[Dict[str, Any]]]:
|
|
396
|
+
"""A legacy action takes the id as a parameter, not as a path segment."""
|
|
397
|
+
param = str(attack_vector.get("target_param") or "id")
|
|
398
|
+
in_path = _has_id_placeholder(path)
|
|
399
|
+
if method in ("POST", "PUT", "PATCH"):
|
|
400
|
+
payload: Dict[str, Any] = _suggested_form(attack_vector, resource_id) or dict(_TAMPER_PAYLOAD)
|
|
401
|
+
if not in_path:
|
|
402
|
+
payload.setdefault(param, resource_id)
|
|
403
|
+
return path, payload
|
|
404
|
+
if in_path:
|
|
405
|
+
return path, None
|
|
406
|
+
separator = "&" if "?" in path else "?"
|
|
407
|
+
return f"{path}{separator}{param}={urllib.parse.quote(resource_id, safe='')}", None
|
|
408
|
+
|
|
409
|
+
|
|
250
410
|
def generate_test_cases_from_findings(
|
|
251
411
|
scan_data_or_path: Union[str, Path, Dict[str, Any], List[Dict[str, Any]]],
|
|
252
412
|
default_resource_id: str = "12345",
|
|
253
413
|
attacker_token: Optional[str] = None,
|
|
254
414
|
min_consensus_score: float = 0.0,
|
|
255
415
|
include_likely: bool = True,
|
|
416
|
+
body_format: str = "auto",
|
|
417
|
+
csrf: Optional[CsrfConfig] = None,
|
|
256
418
|
) -> List[TestCase]:
|
|
257
419
|
"""Synthesize active BOLA verification test cases from LSA findings.json or SARIF data.
|
|
258
420
|
|
|
@@ -298,6 +460,7 @@ def generate_test_cases_from_findings(
|
|
|
298
460
|
ep = item.get("endpoint") or {}
|
|
299
461
|
path = av.get("path") or ep.get("path")
|
|
300
462
|
method = av.get("method") or ep.get("method") or "GET"
|
|
463
|
+
method = _concrete_method(method, av.get("path") or ep.get("path") or "")
|
|
301
464
|
|
|
302
465
|
# Fallback: attempt to find path in evidence or title
|
|
303
466
|
if not path:
|
|
@@ -311,7 +474,7 @@ def generate_test_cases_from_findings(
|
|
|
311
474
|
|
|
312
475
|
# Determine resource_id to inject
|
|
313
476
|
target_id = default_resource_id
|
|
314
|
-
if
|
|
477
|
+
if not _has_id_placeholder(path):
|
|
315
478
|
if not path.endswith("/"):
|
|
316
479
|
target_path = f"{path}/{target_id}"
|
|
317
480
|
else:
|
|
@@ -324,19 +487,25 @@ def generate_test_cases_from_findings(
|
|
|
324
487
|
for m in methods:
|
|
325
488
|
payload = None
|
|
326
489
|
if m in ("POST", "PUT", "PATCH"):
|
|
327
|
-
payload =
|
|
490
|
+
payload = dict(_TAMPER_PAYLOAD)
|
|
491
|
+
is_form = _uses_form(path, body_format)
|
|
492
|
+
case_path = target_path
|
|
493
|
+
if is_form:
|
|
494
|
+
case_path, payload = _form_action_request(path, m, av, target_id)
|
|
328
495
|
|
|
329
496
|
tc_name = f"[{fid}] {item.get('title', target_path)} ({m})"
|
|
330
497
|
test_cases.append(
|
|
331
498
|
TestCase(
|
|
332
499
|
name=tc_name,
|
|
333
|
-
path=
|
|
500
|
+
path=case_path,
|
|
334
501
|
method=m,
|
|
335
502
|
resource_id=target_id,
|
|
336
503
|
attacker_token=attacker_token,
|
|
337
504
|
payload=payload,
|
|
338
505
|
finding_id=fid,
|
|
339
506
|
consensus_score=c_score,
|
|
507
|
+
body_format="form" if is_form else "json",
|
|
508
|
+
csrf=csrf,
|
|
340
509
|
)
|
|
341
510
|
)
|
|
342
511
|
|
|
@@ -360,7 +529,7 @@ def generate_test_cases_from_findings(
|
|
|
360
529
|
# Try to extract path and method from SARIF properties or message
|
|
361
530
|
ep = props.get("endpoint") or {}
|
|
362
531
|
path = ep.get("path")
|
|
363
|
-
method = ep.get("method") or "GET"
|
|
532
|
+
method = _concrete_method(ep.get("method") or "GET", path or "")
|
|
364
533
|
|
|
365
534
|
if not path:
|
|
366
535
|
m = re.search(r"(?:GET|POST|PUT|DELETE|PATCH)?\s*(/[a-zA-Z0-9_\-\./\{\}:]+)", msg)
|
|
@@ -371,11 +540,18 @@ def generate_test_cases_from_findings(
|
|
|
371
540
|
continue
|
|
372
541
|
|
|
373
542
|
target_id = default_resource_id
|
|
374
|
-
if
|
|
543
|
+
if not _has_id_placeholder(path):
|
|
375
544
|
target_path = f"{path}/{target_id}" if not path.endswith("/") else f"{path}{target_id}"
|
|
376
545
|
else:
|
|
377
546
|
target_path = path
|
|
378
547
|
|
|
548
|
+
is_form = _uses_form(path, body_format)
|
|
549
|
+
sarif_payload: Optional[Dict[str, Any]] = None
|
|
550
|
+
if is_form:
|
|
551
|
+
target_path, sarif_payload = _form_action_request(
|
|
552
|
+
path, str(method).upper(), props.get("attack_vector") or {}, target_id
|
|
553
|
+
)
|
|
554
|
+
|
|
379
555
|
fid = props.get("finding_id") or result.get("ruleId", "BOLA")
|
|
380
556
|
tc_name = f"[{fid}] SARIF {result.get('ruleId', 'BOLA')} ({method})"
|
|
381
557
|
test_cases.append(
|
|
@@ -385,8 +561,11 @@ def generate_test_cases_from_findings(
|
|
|
385
561
|
method=method,
|
|
386
562
|
resource_id=target_id,
|
|
387
563
|
attacker_token=attacker_token,
|
|
564
|
+
payload=sarif_payload,
|
|
388
565
|
finding_id=fid,
|
|
389
566
|
consensus_score=float(props.get("consensus_score", 1.0)) if "consensus_score" in props else None,
|
|
567
|
+
body_format="form" if is_form else "json",
|
|
568
|
+
csrf=csrf,
|
|
390
569
|
)
|
|
391
570
|
)
|
|
392
571
|
|
|
@@ -426,6 +605,10 @@ def format_report(results: List[TestResult], base_url: str) -> Tuple[str, bool]:
|
|
|
426
605
|
f"[PASS - SECURE] {t.method} {t.path} -> "
|
|
427
606
|
f"HTTP {res.status_code} ({res.duration_ms:.1f}ms)"
|
|
428
607
|
)
|
|
608
|
+
if res.owner_control == "skipped_write":
|
|
609
|
+
lines.append(" Note: owner control skipped for this write; the route was not proven to work.")
|
|
610
|
+
elif res.owner_control == "not_run":
|
|
611
|
+
lines.append(" Note: no owner session; the route and resource ID were not proven to work.")
|
|
429
612
|
else:
|
|
430
613
|
error_count += 1
|
|
431
614
|
lines.append(
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Operator configuration for testing session-based legacy apps.
|
|
2
|
+
|
|
3
|
+
Cookie values are read from environment variables or files only. There is
|
|
4
|
+
deliberately no flag that takes a cookie value, and no message repeats one.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import os
|
|
11
|
+
import stat
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from typing import List, Mapping, Optional
|
|
14
|
+
|
|
15
|
+
from operate.cross_user_tester.session_http import (
|
|
16
|
+
DEFAULT_DENY_STATUS,
|
|
17
|
+
STRICT_DENY_STATUS,
|
|
18
|
+
CsrfConfig,
|
|
19
|
+
DenialSignal,
|
|
20
|
+
SessionConfigError,
|
|
21
|
+
SessionRequest,
|
|
22
|
+
fetch_page,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
__all__ = ["SessionConfigError", "SessionOptions", "read_cookie"]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class SessionOptions:
|
|
30
|
+
attacker_cookie: Optional[str] = field(default=None, repr=False)
|
|
31
|
+
owner_cookie: Optional[str] = field(default=None, repr=False)
|
|
32
|
+
denial: Optional[DenialSignal] = None
|
|
33
|
+
owner_control_writes: bool = False
|
|
34
|
+
owner_csrf_page: Optional[str] = None
|
|
35
|
+
strict: bool = False
|
|
36
|
+
session_check: Optional[str] = None
|
|
37
|
+
session_marker: Optional[str] = None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _validated(value: str, label: str) -> str:
|
|
41
|
+
cookie = value.strip()
|
|
42
|
+
if not cookie:
|
|
43
|
+
raise SessionConfigError(f"{label}: the session cookie is empty")
|
|
44
|
+
if "=" not in cookie or not cookie.isascii() or any(ord(c) < 32 or ord(c) == 127 for c in cookie):
|
|
45
|
+
raise SessionConfigError(f"{label}: the session cookie must be one line such as NAME=VALUE; NAME2=VALUE2")
|
|
46
|
+
return cookie
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def read_cookie(env_name: Optional[str], file_path: Optional[str], env: Mapping[str, str], label: str) -> Optional[str]:
|
|
50
|
+
"""Read a persona's Cookie header value. None when no source is configured."""
|
|
51
|
+
if env_name and file_path:
|
|
52
|
+
raise SessionConfigError(f"{label}: use either a cookie environment variable or a cookie file, not both")
|
|
53
|
+
if env_name:
|
|
54
|
+
if not env.get(env_name):
|
|
55
|
+
raise SessionConfigError(f"{label}: environment variable {env_name} is not set")
|
|
56
|
+
return _validated(env[env_name], label)
|
|
57
|
+
if file_path:
|
|
58
|
+
try:
|
|
59
|
+
with open(file_path, "r", encoding="utf-8") as handle:
|
|
60
|
+
return _validated(handle.read(), label)
|
|
61
|
+
except (OSError, UnicodeDecodeError) as err:
|
|
62
|
+
raise SessionConfigError(f"{label}: cannot read the cookie file ({type(err).__name__})") from err
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def cookie_file_is_open(file_path: Optional[str]) -> bool:
|
|
67
|
+
"""True when group or others can read a cookie file."""
|
|
68
|
+
if not file_path or os.name != "posix":
|
|
69
|
+
return False
|
|
70
|
+
try:
|
|
71
|
+
return bool(stat.S_IMODE(os.stat(file_path).st_mode) & 0o077)
|
|
72
|
+
except OSError:
|
|
73
|
+
return False
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def add_session_arguments(parser: argparse.ArgumentParser) -> None:
|
|
77
|
+
"""Flags for session-based apps. None of them takes a secret value."""
|
|
78
|
+
group = parser.add_argument_group("session-based apps (JSP / Struts / old Spring MVC)")
|
|
79
|
+
group.add_argument("--attacker-cookie-env", metavar="VAR", help="Environment variable holding the attacker's Cookie value, e.g. JSESSIONID=...")
|
|
80
|
+
group.add_argument("--attacker-cookie-file", metavar="PATH", help="File holding the attacker's Cookie value")
|
|
81
|
+
group.add_argument("--owner-cookie-env", metavar="VAR", help="Environment variable holding the resource owner's Cookie value")
|
|
82
|
+
group.add_argument("--owner-cookie-file", metavar="PATH", help="File holding the resource owner's Cookie value")
|
|
83
|
+
group.add_argument("--body-format", choices=["auto", "json", "form"], default="auto",
|
|
84
|
+
help="Request body encoding. auto: form for --from-scan routes ending in .do, .action, .jsp or .jspx; otherwise JSON")
|
|
85
|
+
group.add_argument("--csrf-page", metavar="PATH", help="Page the attacker can open that contains a CSRF or Struts token")
|
|
86
|
+
group.add_argument("--csrf-field", metavar="NAME", help="Token field name, e.g. org.apache.struts.taglib.html.TOKEN")
|
|
87
|
+
group.add_argument("--deny-status", type=int, action="append", metavar="CODE", help="Status that means refused (repeatable)")
|
|
88
|
+
group.add_argument("--deny-redirect", action="append", metavar="TEXT", help="Redirect target containing TEXT means refused (repeatable)")
|
|
89
|
+
group.add_argument("--deny-marker", action="append", metavar="TEXT", help="2xx body containing TEXT means refused (repeatable)")
|
|
90
|
+
group.add_argument("--allow-redirect", action="append", metavar="TEXT", help="Redirect target containing TEXT means the request succeeded (repeatable)")
|
|
91
|
+
group.add_argument("--allow-marker", action="append", metavar="TEXT", help="If set, a 2xx body must contain TEXT to count as served data (repeatable)")
|
|
92
|
+
group.add_argument("--session-check", metavar="PATH", help="Page every session persona can open; checked before and after the run")
|
|
93
|
+
group.add_argument("--session-marker", metavar="TEXT", help="Text that --session-check page must contain")
|
|
94
|
+
group.add_argument("--owner-control-writes", action="store_true",
|
|
95
|
+
help="Also replay writes as the owner after a denial. This changes the owner's data. Required for --strict to pass a refused write")
|
|
96
|
+
group.add_argument("--owner-csrf-page", metavar="PATH", help="Token page for the owner's replayed writes (default: --csrf-page)")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _signal(args: argparse.Namespace) -> DenialSignal:
|
|
100
|
+
fallback = STRICT_DENY_STATUS if args.strict else DEFAULT_DENY_STATUS
|
|
101
|
+
texts = (args.deny_redirect or []) + (args.deny_marker or []) + (args.allow_redirect or []) + (args.allow_marker or [])
|
|
102
|
+
if not all(texts):
|
|
103
|
+
raise SessionConfigError("--deny-redirect, --deny-marker, --allow-redirect and --allow-marker cannot be empty")
|
|
104
|
+
return DenialSignal(
|
|
105
|
+
deny_status=tuple(args.deny_status or fallback),
|
|
106
|
+
deny_redirects=tuple(args.deny_redirect or ()),
|
|
107
|
+
deny_markers=tuple(args.deny_marker or ()),
|
|
108
|
+
allow_redirects=tuple(args.allow_redirect or ()),
|
|
109
|
+
allow_markers=tuple(args.allow_marker or ()),
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def declared_denial(args: argparse.Namespace) -> bool:
|
|
114
|
+
return bool(args.deny_status or args.deny_redirect or args.deny_marker)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def strict_gaps(args: argparse.Namespace, cookie_attacker: bool, cookie_owner: bool) -> List[str]:
|
|
118
|
+
"""What --strict still needs to give a trustworthy verdict on a session app."""
|
|
119
|
+
gaps: List[str] = []
|
|
120
|
+
if not declared_denial(args):
|
|
121
|
+
gaps.append("a denial signal (--deny-status, --deny-redirect or --deny-marker)")
|
|
122
|
+
if not args.session_check:
|
|
123
|
+
gaps.append("--session-check, so an expired session is not read as a refusal")
|
|
124
|
+
if cookie_attacker and not cookie_owner:
|
|
125
|
+
gaps.append("the owner's cookie, so a missing record is not read as a refusal")
|
|
126
|
+
return gaps
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def build_session_options(args: argparse.Namespace, env: Mapping[str, str]) -> Optional[SessionOptions]:
|
|
130
|
+
"""Options for this run, or None when it is an ordinary Bearer run."""
|
|
131
|
+
attacker = read_cookie(args.attacker_cookie_env, args.attacker_cookie_file, env, "attacker")
|
|
132
|
+
owner = read_cookie(args.owner_cookie_env, args.owner_cookie_file, env, "owner")
|
|
133
|
+
if not (attacker or owner or declared_denial(args) or args.allow_redirect or args.allow_marker or args.session_check):
|
|
134
|
+
return None
|
|
135
|
+
if args.strict and attacker:
|
|
136
|
+
gaps = strict_gaps(args, True, bool(owner))
|
|
137
|
+
if gaps:
|
|
138
|
+
raise SessionConfigError("--strict on a session app needs " + "; ".join(gaps))
|
|
139
|
+
return SessionOptions(
|
|
140
|
+
attacker_cookie=attacker,
|
|
141
|
+
owner_cookie=owner,
|
|
142
|
+
denial=_signal(args),
|
|
143
|
+
owner_control_writes=args.owner_control_writes,
|
|
144
|
+
owner_csrf_page=args.owner_csrf_page,
|
|
145
|
+
strict=args.strict,
|
|
146
|
+
session_check=args.session_check,
|
|
147
|
+
session_marker=args.session_marker,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def global_csrf(args: argparse.Namespace) -> Optional[CsrfConfig]:
|
|
152
|
+
if bool(args.csrf_page) != bool(args.csrf_field):
|
|
153
|
+
raise SessionConfigError("--csrf-page and --csrf-field must be given together")
|
|
154
|
+
if not args.csrf_page:
|
|
155
|
+
return None
|
|
156
|
+
if not args.csrf_page.startswith("/"):
|
|
157
|
+
raise SessionConfigError("--csrf-page must start with '/'")
|
|
158
|
+
return CsrfConfig(page=args.csrf_page, field=args.csrf_field)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def check_session(
|
|
162
|
+
base_url: str,
|
|
163
|
+
label: str,
|
|
164
|
+
cookie: str,
|
|
165
|
+
path: str,
|
|
166
|
+
marker: Optional[str],
|
|
167
|
+
denial: Optional[DenialSignal],
|
|
168
|
+
timeout: float = 10.0,
|
|
169
|
+
) -> Optional[str]:
|
|
170
|
+
"""Return why this session is unusable, or None when it is alive."""
|
|
171
|
+
reply = fetch_page(base_url.rstrip("/"), path, SessionRequest("GET", path, cookie=cookie), timeout)
|
|
172
|
+
if reply.error:
|
|
173
|
+
return f"{label}: session check failed ({reply.error})"
|
|
174
|
+
if not 200 <= reply.status < 300:
|
|
175
|
+
return f"{label}: session check {path} returned HTTP {reply.status}; the session is expired or invalid"
|
|
176
|
+
if denial and any(m in reply.body for m in denial.deny_markers):
|
|
177
|
+
return f"{label}: session check {path} shows a denial page"
|
|
178
|
+
if marker and marker not in reply.body:
|
|
179
|
+
return f"{label}: session check {path} does not contain the expected text"
|
|
180
|
+
return None
|