@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
|
@@ -0,0 +1,565 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""komagu test on session-based Java legacy apps (#368)."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import contextlib
|
|
7
|
+
import io
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
import tempfile
|
|
12
|
+
import unittest
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, Dict, List, Tuple
|
|
15
|
+
from unittest import mock
|
|
16
|
+
|
|
17
|
+
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
|
18
|
+
if str(PROJECT_ROOT) not in sys.path:
|
|
19
|
+
sys.path.insert(0, str(PROJECT_ROOT))
|
|
20
|
+
|
|
21
|
+
from operate.cli.main import main as cli_main
|
|
22
|
+
from operate.cross_user_tester.mock_test_server import start_mock_server
|
|
23
|
+
from operate.cross_user_tester.runner import CrossUserTestRunner, TestCase, generate_test_cases_from_findings
|
|
24
|
+
from operate.cross_user_tester.session_config import SessionConfigError, read_cookie
|
|
25
|
+
from operate.cross_user_tester.session_http import (
|
|
26
|
+
ALLOWED,
|
|
27
|
+
DENIED,
|
|
28
|
+
INCONCLUSIVE,
|
|
29
|
+
DenialSignal,
|
|
30
|
+
classify,
|
|
31
|
+
decode_body,
|
|
32
|
+
encode_form,
|
|
33
|
+
extract_form_token,
|
|
34
|
+
safe_location,
|
|
35
|
+
)
|
|
36
|
+
from operate.tests.mock_legacy_session_app import (
|
|
37
|
+
ALICE_COOKIE,
|
|
38
|
+
BOB_COOKIE,
|
|
39
|
+
DENIED_TEXT,
|
|
40
|
+
EXPIRED_COOKIE,
|
|
41
|
+
TOKEN_FIELD,
|
|
42
|
+
start_legacy_app,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
READ_FINDING: Dict[str, Any] = {
|
|
46
|
+
"finding_id": "BOLA-READ", "category": "bola", "title": "Order view", "status": "confirmed",
|
|
47
|
+
"endpoint": {"method": "GET", "path": "/order/view.do"},
|
|
48
|
+
"attack_vector": {"method": "GET", "path": "/order/view.do", "target_param": "id"},
|
|
49
|
+
}
|
|
50
|
+
WRITE_FINDING: Dict[str, Any] = {
|
|
51
|
+
"finding_id": "BOLA-WRITE", "category": "bola", "title": "Order update", "status": "confirmed",
|
|
52
|
+
"endpoint": {"method": "POST", "path": "/order/update.do"},
|
|
53
|
+
"attack_vector": {"method": "POST", "path": "/order/update.do", "target_param": "id",
|
|
54
|
+
"suggested_payload": "id={id}&status=tampered"},
|
|
55
|
+
}
|
|
56
|
+
SIGNAL = DenialSignal(deny_status=(404,), deny_redirects=("/login.do",), deny_markers=("denied",),
|
|
57
|
+
allow_redirects=("/list.do",), allow_markers=())
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class TestClassify(unittest.TestCase):
|
|
61
|
+
def verdict(self, signal: DenialSignal, status: int, location: str = "", body: str = "") -> str:
|
|
62
|
+
return classify(signal, status, location, body)[0]
|
|
63
|
+
|
|
64
|
+
def test_declared_status_is_denied(self) -> None:
|
|
65
|
+
self.assertEqual(self.verdict(SIGNAL, 404), DENIED)
|
|
66
|
+
|
|
67
|
+
def test_undeclared_error_status_is_never_a_pass_or_breach(self) -> None:
|
|
68
|
+
for status in (0, 400, 403, 405, 500):
|
|
69
|
+
self.assertEqual(self.verdict(SIGNAL, status), INCONCLUSIVE, status)
|
|
70
|
+
|
|
71
|
+
def test_redirects(self) -> None:
|
|
72
|
+
self.assertEqual(self.verdict(SIGNAL, 302, "http://h/login.do;x=1"), DENIED)
|
|
73
|
+
self.assertEqual(self.verdict(SIGNAL, 302, "/order/list.do"), ALLOWED)
|
|
74
|
+
self.assertEqual(self.verdict(SIGNAL, 302, "/somewhere.do"), INCONCLUSIVE)
|
|
75
|
+
|
|
76
|
+
def test_error_page_with_200_is_denied(self) -> None:
|
|
77
|
+
self.assertEqual(self.verdict(SIGNAL, 200, body="access denied"), DENIED)
|
|
78
|
+
|
|
79
|
+
def test_200_without_marker_is_allowed(self) -> None:
|
|
80
|
+
self.assertEqual(self.verdict(SIGNAL, 200, body="order data"), ALLOWED)
|
|
81
|
+
|
|
82
|
+
def test_allow_marker_required_when_declared(self) -> None:
|
|
83
|
+
signal = DenialSignal(deny_markers=("denied",), allow_markers=("Order detail",))
|
|
84
|
+
self.assertEqual(self.verdict(signal, 200, body="Order detail"), ALLOWED)
|
|
85
|
+
self.assertEqual(self.verdict(signal, 200, body="validation failed"), INCONCLUSIVE)
|
|
86
|
+
|
|
87
|
+
def test_page_matching_both_signals_is_ambiguous(self) -> None:
|
|
88
|
+
signal = DenialSignal(deny_markers=("denied",), allow_markers=("Order detail",))
|
|
89
|
+
self.assertEqual(self.verdict(signal, 200, body="Order detail denied"), INCONCLUSIVE)
|
|
90
|
+
|
|
91
|
+
def test_reason_does_not_carry_session_id_from_url_rewriting(self) -> None:
|
|
92
|
+
_, reason = classify(SIGNAL, 302, "/x.do;jsessionid=SECRET123?token=SECRET456", "")
|
|
93
|
+
self.assertNotIn("SECRET", reason)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class TestHelpers(unittest.TestCase):
|
|
97
|
+
def test_safe_location_strips_session_and_query(self) -> None:
|
|
98
|
+
self.assertEqual(safe_location("http://h/a.do;jsessionid=abc?x=1"), "http://h/a.do")
|
|
99
|
+
|
|
100
|
+
def test_decode_windows_31j_header(self) -> None:
|
|
101
|
+
raw = DENIED_TEXT.encode("cp932")
|
|
102
|
+
self.assertIn(DENIED_TEXT, decode_body(raw, "text/html; charset=Windows-31J"))
|
|
103
|
+
|
|
104
|
+
def test_decode_meta_charset(self) -> None:
|
|
105
|
+
raw = f'<meta charset="Shift_JIS">{DENIED_TEXT}'.encode("cp932")
|
|
106
|
+
self.assertIn(DENIED_TEXT, decode_body(raw, "text/html"))
|
|
107
|
+
|
|
108
|
+
def test_decode_undeclared_shift_jis(self) -> None:
|
|
109
|
+
self.assertIn(DENIED_TEXT, decode_body(DENIED_TEXT.encode("cp932"), "text/html"))
|
|
110
|
+
|
|
111
|
+
def test_decode_unknown_charset_falls_back(self) -> None:
|
|
112
|
+
self.assertEqual(decode_body(b"abc", "text/html; charset=nope"), "abc")
|
|
113
|
+
|
|
114
|
+
def test_extract_token_any_attribute_order(self) -> None:
|
|
115
|
+
self.assertEqual(extract_form_token('<input value="T1" type="hidden" name="f">', "f"), {"f": "T1"})
|
|
116
|
+
|
|
117
|
+
def test_extract_token_from_meta(self) -> None:
|
|
118
|
+
self.assertEqual(extract_form_token('<meta name="_csrf" content="T2">', "_csrf"), {"_csrf": "T2"})
|
|
119
|
+
|
|
120
|
+
def test_extract_struts2_sends_companion_field(self) -> None:
|
|
121
|
+
html = '<input name="struts.token.name" value="token"><input name="token" value="T3">'
|
|
122
|
+
self.assertEqual(extract_form_token(html, "token"), {"struts.token.name": "token", "token": "T3"})
|
|
123
|
+
|
|
124
|
+
def test_extract_missing_token(self) -> None:
|
|
125
|
+
self.assertIsNone(extract_form_token("<form></form>", "f"))
|
|
126
|
+
|
|
127
|
+
def test_encode_form(self) -> None:
|
|
128
|
+
self.assertEqual(encode_form({"a": "1 2", "b": 3}), b"a=1+2&b=3")
|
|
129
|
+
|
|
130
|
+
def test_encode_form_rejects_nested_values(self) -> None:
|
|
131
|
+
with self.assertRaises(ValueError):
|
|
132
|
+
encode_form({"a": {"b": 1}})
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class TestReadCookie(unittest.TestCase):
|
|
136
|
+
def test_env(self) -> None:
|
|
137
|
+
self.assertEqual(read_cookie("C", None, {"C": " JSESSIONID=a \n"}, "attacker"), "JSESSIONID=a")
|
|
138
|
+
|
|
139
|
+
def test_file(self) -> None:
|
|
140
|
+
with tempfile.TemporaryDirectory() as td:
|
|
141
|
+
path = Path(td) / "c.txt"
|
|
142
|
+
path.write_text("JSESSIONID=b\n", encoding="utf-8")
|
|
143
|
+
os.chmod(path, 0o600)
|
|
144
|
+
self.assertEqual(read_cookie(None, str(path), {}, "owner"), "JSESSIONID=b")
|
|
145
|
+
|
|
146
|
+
def test_both_sources_rejected(self) -> None:
|
|
147
|
+
with self.assertRaises(SessionConfigError):
|
|
148
|
+
read_cookie("C", "/f", {"C": "a=b"}, "attacker")
|
|
149
|
+
|
|
150
|
+
def test_unset_env_names_variable_only(self) -> None:
|
|
151
|
+
with self.assertRaises(SessionConfigError) as ctx:
|
|
152
|
+
read_cookie("NOPE", None, {}, "attacker")
|
|
153
|
+
self.assertIn("NOPE", str(ctx.exception))
|
|
154
|
+
|
|
155
|
+
def test_bad_values_are_rejected_without_echo(self) -> None:
|
|
156
|
+
for bad in ("JSESSIONID=SECRETVALUE\r\nX: y", "SECRETVALUE"):
|
|
157
|
+
with self.assertRaises(SessionConfigError) as ctx:
|
|
158
|
+
read_cookie("C", None, {"C": bad}, "attacker")
|
|
159
|
+
self.assertNotIn("SECRETVALUE", str(ctx.exception))
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class TestFromScanRequests(unittest.TestCase):
|
|
163
|
+
def test_form_action_read_puts_id_in_query(self) -> None:
|
|
164
|
+
case = generate_test_cases_from_findings([READ_FINDING], default_resource_id="100")[0]
|
|
165
|
+
self.assertEqual(case.path, "/order/view.do?id=100")
|
|
166
|
+
self.assertEqual(case.body_format, "form")
|
|
167
|
+
|
|
168
|
+
def test_form_action_write_puts_id_in_body(self) -> None:
|
|
169
|
+
case = generate_test_cases_from_findings([WRITE_FINDING], default_resource_id="100")[0]
|
|
170
|
+
self.assertEqual(case.path, "/order/update.do")
|
|
171
|
+
self.assertEqual(case.body_format, "form")
|
|
172
|
+
self.assertEqual(case.payload, {"id": "100", "status": "tampered"})
|
|
173
|
+
|
|
174
|
+
def test_existing_query_is_extended(self) -> None:
|
|
175
|
+
finding = {**READ_FINDING, "attack_vector": {"path": "/order.do?method=view", "target_param": "orderId"}}
|
|
176
|
+
case = generate_test_cases_from_findings([finding], default_resource_id="7")[0]
|
|
177
|
+
self.assertEqual(case.path, "/order.do?method=view&orderId=7")
|
|
178
|
+
|
|
179
|
+
def test_placeholder_in_query_is_filled(self) -> None:
|
|
180
|
+
finding = {**READ_FINDING, "attack_vector": {"path": "/order/view.do?id={id}"}}
|
|
181
|
+
case = generate_test_cases_from_findings([finding], default_resource_id="7")[0]
|
|
182
|
+
self.assertEqual(case.path, "/order/view.do?id=7")
|
|
183
|
+
|
|
184
|
+
def test_rest_route_stays_json(self) -> None:
|
|
185
|
+
finding = {"finding_id": "BOLA-API", "category": "bola", "endpoint": {"method": "PUT", "path": "/api/orders"}}
|
|
186
|
+
case = generate_test_cases_from_findings([finding], default_resource_id="7")[0]
|
|
187
|
+
self.assertEqual(case.path, "/api/orders/7")
|
|
188
|
+
self.assertEqual(case.body_format, "json")
|
|
189
|
+
|
|
190
|
+
def test_forced_json_keeps_rest_shape_for_do_route(self) -> None:
|
|
191
|
+
case = generate_test_cases_from_findings([WRITE_FINDING], default_resource_id="7", body_format="json")[0]
|
|
192
|
+
self.assertEqual(case.body_format, "json")
|
|
193
|
+
self.assertEqual(case.path, "/order/update.do/7")
|
|
194
|
+
|
|
195
|
+
def test_sarif_form_action(self) -> None:
|
|
196
|
+
sarif = {"runs": [{"results": [{"ruleId": "bola.x", "message": {"text": "m"}, "properties": {
|
|
197
|
+
"finding_id": "S1", "endpoint": {"method": "GET", "path": "/order/view.do"},
|
|
198
|
+
"attack_vector": {"target_param": "id"}}}]}]}
|
|
199
|
+
case = generate_test_cases_from_findings(sarif, default_resource_id="9")[0]
|
|
200
|
+
self.assertEqual(case.path, "/order/view.do?id=9")
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
class SessionAppCase(unittest.TestCase):
|
|
204
|
+
def setUp(self) -> None:
|
|
205
|
+
self.server, self.base_url = start_legacy_app()
|
|
206
|
+
self.addCleanup(self.server.server_close)
|
|
207
|
+
self.addCleanup(self.server.shutdown)
|
|
208
|
+
tmp = tempfile.TemporaryDirectory()
|
|
209
|
+
self.addCleanup(tmp.cleanup)
|
|
210
|
+
self.dir = Path(tmp.name)
|
|
211
|
+
self.scan = self.dir / "findings.json"
|
|
212
|
+
self.scan.write_text(json.dumps({"findings": [READ_FINDING, WRITE_FINDING]}), encoding="utf-8")
|
|
213
|
+
self.out = self.dir / "out.json"
|
|
214
|
+
self.env = {"ATT_COOKIE": BOB_COOKIE, "OWN_COOKIE": ALICE_COOKIE}
|
|
215
|
+
|
|
216
|
+
def base_args(self) -> List[str]:
|
|
217
|
+
return [
|
|
218
|
+
"--no-banner", "test", "--url", self.base_url, "--from-scan", str(self.scan),
|
|
219
|
+
"--resource-id", "100", "--output", str(self.out),
|
|
220
|
+
"--attacker-cookie-env", "ATT_COOKIE", "--owner-cookie-env", "OWN_COOKIE",
|
|
221
|
+
]
|
|
222
|
+
|
|
223
|
+
def strict_args(self) -> List[str]:
|
|
224
|
+
return self.base_args() + [
|
|
225
|
+
"--strict", "--session-check", "/home.do", "--session-marker", "Welcome",
|
|
226
|
+
"--deny-status", "404", "--deny-redirect", "/login.do", "--deny-marker", DENIED_TEXT,
|
|
227
|
+
"--allow-redirect", "/order/list.do",
|
|
228
|
+
"--csrf-page", "/order/edit.do?id=200", "--csrf-field", TOKEN_FIELD,
|
|
229
|
+
"--owner-control-writes",
|
|
230
|
+
]
|
|
231
|
+
|
|
232
|
+
def run_cli(self, args: List[str]) -> Tuple[int, str]:
|
|
233
|
+
out, err = io.StringIO(), io.StringIO()
|
|
234
|
+
with mock.patch.dict(os.environ, self.env), contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
|
|
235
|
+
try:
|
|
236
|
+
code = cli_main(args)
|
|
237
|
+
except SystemExit as stop:
|
|
238
|
+
code = int(stop.code or 0)
|
|
239
|
+
return code, out.getvalue() + err.getvalue()
|
|
240
|
+
|
|
241
|
+
def report(self) -> Dict[str, Any]:
|
|
242
|
+
return json.loads(self.out.read_text(encoding="utf-8"))
|
|
243
|
+
|
|
244
|
+
def by_finding(self) -> Dict[str, Dict[str, Any]]:
|
|
245
|
+
return {r["finding_id"]: r for r in self.report()["results"]}
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
class TestRehearsal(SessionAppCase):
|
|
249
|
+
"""A vulnerable route must fail before the fix and pass after it."""
|
|
250
|
+
|
|
251
|
+
def test_fails_before_fix(self) -> None:
|
|
252
|
+
code, _ = self.run_cli(self.strict_args())
|
|
253
|
+
self.assertEqual(code, 1)
|
|
254
|
+
results = self.by_finding()
|
|
255
|
+
self.assertTrue(results["BOLA-READ"]["is_breach"])
|
|
256
|
+
self.assertTrue(results["BOLA-WRITE"]["is_breach"])
|
|
257
|
+
self.assertIn(("bob", "100"), self.server.updates)
|
|
258
|
+
|
|
259
|
+
def assert_passes_after_fix(self, denial: str) -> None:
|
|
260
|
+
self.server.fixed = True
|
|
261
|
+
self.server.denial = denial
|
|
262
|
+
code, output = self.run_cli(self.strict_args())
|
|
263
|
+
self.assertEqual(code, 0, output)
|
|
264
|
+
results = self.by_finding()
|
|
265
|
+
self.assertTrue(results["BOLA-READ"]["is_secure"])
|
|
266
|
+
self.assertEqual(results["BOLA-READ"]["owner_control"], "passed")
|
|
267
|
+
self.assertTrue(results["BOLA-WRITE"]["is_secure"])
|
|
268
|
+
self.assertEqual(results["BOLA-WRITE"]["owner_control"], "passed")
|
|
269
|
+
self.assertEqual(self.server.updates, [("alice", "100")])
|
|
270
|
+
|
|
271
|
+
def test_passes_after_fix_with_200_error_page(self) -> None:
|
|
272
|
+
self.assert_passes_after_fix("error_page")
|
|
273
|
+
|
|
274
|
+
def test_passes_after_fix_with_login_redirect(self) -> None:
|
|
275
|
+
self.assert_passes_after_fix("login_redirect")
|
|
276
|
+
|
|
277
|
+
def test_passes_after_fix_with_404(self) -> None:
|
|
278
|
+
self.assert_passes_after_fix("not_found")
|
|
279
|
+
|
|
280
|
+
def test_trailing_slash_in_url(self) -> None:
|
|
281
|
+
args = self.strict_args()
|
|
282
|
+
args[args.index("--url") + 1] = self.base_url + "/"
|
|
283
|
+
self.server.fixed = True
|
|
284
|
+
code, output = self.run_cli(args)
|
|
285
|
+
self.assertEqual(code, 0, output)
|
|
286
|
+
|
|
287
|
+
def test_requests_are_session_and_form_shaped(self) -> None:
|
|
288
|
+
self.run_cli(self.strict_args())
|
|
289
|
+
post = next(s for s in self.server.seen if s.method == "POST")
|
|
290
|
+
self.assertEqual(post.cookie, BOB_COOKIE)
|
|
291
|
+
self.assertIsNone(post.authorization)
|
|
292
|
+
self.assertEqual(post.content_type, "application/x-www-form-urlencoded")
|
|
293
|
+
self.assertIn("id=100", post.body)
|
|
294
|
+
self.assertIn("status=tampered", post.body)
|
|
295
|
+
|
|
296
|
+
def test_login_redirect_is_not_followed(self) -> None:
|
|
297
|
+
self.server.fixed = True
|
|
298
|
+
self.server.denial = "login_redirect"
|
|
299
|
+
self.run_cli(self.strict_args())
|
|
300
|
+
self.assertFalse([s for s in self.server.seen if s.path.startswith("/login.do")])
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
class TestNoFalseVerdicts(SessionAppCase):
|
|
304
|
+
def test_expired_attacker_session_stops_the_run(self) -> None:
|
|
305
|
+
self.server.fixed = True
|
|
306
|
+
self.env["ATT_COOKIE"] = EXPIRED_COOKIE
|
|
307
|
+
code, output = self.run_cli(self.strict_args())
|
|
308
|
+
self.assertEqual(code, 2, output)
|
|
309
|
+
self.assertFalse([s for s in self.server.seen if s.path.startswith("/order/")])
|
|
310
|
+
|
|
311
|
+
def test_expired_owner_session_stops_the_run(self) -> None:
|
|
312
|
+
self.env["OWN_COOKIE"] = EXPIRED_COOKIE
|
|
313
|
+
code, _ = self.run_cli(self.strict_args())
|
|
314
|
+
self.assertEqual(code, 2)
|
|
315
|
+
|
|
316
|
+
def test_session_lost_during_run_invalidates_result(self) -> None:
|
|
317
|
+
self.server.fixed = True
|
|
318
|
+
original = self.server.orders
|
|
319
|
+
|
|
320
|
+
class DropsSessionAfterFirstUse(dict):
|
|
321
|
+
def __init__(inner, server: Any) -> None: # noqa: N805
|
|
322
|
+
super().__init__(original)
|
|
323
|
+
inner.server = server
|
|
324
|
+
|
|
325
|
+
def get(inner, key: str, default: Any = None) -> Any: # noqa: N805
|
|
326
|
+
inner.server.sessions.pop("sess-bob-0002", None)
|
|
327
|
+
return super().get(key, default)
|
|
328
|
+
|
|
329
|
+
self.server.orders = DropsSessionAfterFirstUse(self.server)
|
|
330
|
+
code, output = self.run_cli(self.strict_args())
|
|
331
|
+
self.assertEqual(code, 2, output)
|
|
332
|
+
self.assertFalse(self.report()["success"])
|
|
333
|
+
|
|
334
|
+
def test_missing_record_is_not_a_pass(self) -> None:
|
|
335
|
+
self.server.fixed = True
|
|
336
|
+
args = self.strict_args()
|
|
337
|
+
args[args.index("--resource-id") + 1] = "999"
|
|
338
|
+
code, _ = self.run_cli(args)
|
|
339
|
+
self.assertEqual(code, 1)
|
|
340
|
+
read = self.by_finding()["BOLA-READ"]
|
|
341
|
+
self.assertFalse(read["is_secure"])
|
|
342
|
+
self.assertFalse(read["is_breach"])
|
|
343
|
+
self.assertEqual(read["owner_control"], "failed")
|
|
344
|
+
|
|
345
|
+
def test_missing_csrf_token_is_not_a_pass(self) -> None:
|
|
346
|
+
self.server.fixed = True
|
|
347
|
+
self.server.hide_token = True
|
|
348
|
+
code, _ = self.run_cli(self.strict_args())
|
|
349
|
+
self.assertEqual(code, 1)
|
|
350
|
+
write = self.by_finding()["BOLA-WRITE"]
|
|
351
|
+
self.assertFalse(write["is_secure"])
|
|
352
|
+
self.assertFalse(write["is_breach"])
|
|
353
|
+
self.assertIn("token", write["error_message"])
|
|
354
|
+
self.assertFalse([s for s in self.server.seen if s.method == "POST"])
|
|
355
|
+
|
|
356
|
+
def test_undeclared_redirect_is_inconclusive(self) -> None:
|
|
357
|
+
args = [a for a in self.strict_args()]
|
|
358
|
+
i = args.index("--allow-redirect")
|
|
359
|
+
del args[i:i + 2]
|
|
360
|
+
code, _ = self.run_cli(args)
|
|
361
|
+
self.assertEqual(code, 1)
|
|
362
|
+
write = self.by_finding()["BOLA-WRITE"]
|
|
363
|
+
self.assertFalse(write["is_breach"])
|
|
364
|
+
self.assertFalse(write["is_secure"])
|
|
365
|
+
|
|
366
|
+
def test_strict_session_needs_signal_check_and_owner(self) -> None:
|
|
367
|
+
full = self.strict_args()
|
|
368
|
+
|
|
369
|
+
def without(flag: str) -> List[str]:
|
|
370
|
+
i = full.index(flag)
|
|
371
|
+
return full[:i] + full[i + 2:]
|
|
372
|
+
|
|
373
|
+
no_signal = full
|
|
374
|
+
for flag in ("--deny-status", "--deny-redirect", "--deny-marker"):
|
|
375
|
+
i = no_signal.index(flag)
|
|
376
|
+
no_signal = no_signal[:i] + no_signal[i + 2:]
|
|
377
|
+
for args in (no_signal, without("--session-check"), without("--owner-cookie-env")):
|
|
378
|
+
code, _ = self.run_cli(args)
|
|
379
|
+
self.assertEqual(code, 2, args)
|
|
380
|
+
self.assertEqual(self.server.seen, [])
|
|
381
|
+
|
|
382
|
+
def test_strict_refused_write_without_owner_control_is_not_a_pass(self) -> None:
|
|
383
|
+
self.server.fixed = True
|
|
384
|
+
args = [a for a in self.strict_args() if a != "--owner-control-writes"]
|
|
385
|
+
code, _ = self.run_cli(args)
|
|
386
|
+
self.assertEqual(code, 1)
|
|
387
|
+
write = self.by_finding()["BOLA-WRITE"]
|
|
388
|
+
self.assertFalse(write["is_secure"])
|
|
389
|
+
self.assertFalse(write["is_breach"])
|
|
390
|
+
self.assertEqual(write["owner_control"], "skipped_write")
|
|
391
|
+
self.assertIn("--owner-control-writes", write["error_message"])
|
|
392
|
+
self.assertEqual(self.server.updates, [])
|
|
393
|
+
|
|
394
|
+
def test_non_strict_refused_write_keeps_owner_data_untouched(self) -> None:
|
|
395
|
+
self.server.fixed = True
|
|
396
|
+
args = [a for a in self.strict_args() if a not in ("--owner-control-writes", "--strict")]
|
|
397
|
+
code, output = self.run_cli(args)
|
|
398
|
+
self.assertEqual(code, 0, output)
|
|
399
|
+
self.assertEqual(self.by_finding()["BOLA-WRITE"]["owner_control"], "skipped_write")
|
|
400
|
+
self.assertEqual(self.server.updates, [])
|
|
401
|
+
|
|
402
|
+
def test_owner_control_uses_owner_csrf_page(self) -> None:
|
|
403
|
+
self.server.fixed = True
|
|
404
|
+
code, output = self.run_cli(self.strict_args() + ["--owner-csrf-page", "/order/edit.do?id=100"])
|
|
405
|
+
self.assertEqual(code, 0, output)
|
|
406
|
+
pages = [s for s in self.server.seen if s.path == "/order/edit.do?id=100"]
|
|
407
|
+
self.assertEqual([s.cookie for s in pages], [ALICE_COOKIE])
|
|
408
|
+
|
|
409
|
+
def test_empty_signal_value_is_config_error(self) -> None:
|
|
410
|
+
for flag in ("--deny-marker", "--deny-redirect", "--allow-marker", "--allow-redirect"):
|
|
411
|
+
code, _ = self.run_cli(self.strict_args() + [flag, ""])
|
|
412
|
+
self.assertEqual(code, 2, flag)
|
|
413
|
+
self.assertEqual(self.server.seen, [])
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
class TestSecrets(SessionAppCase):
|
|
417
|
+
def test_cookie_cannot_be_given_as_argument(self) -> None:
|
|
418
|
+
for flag in ("--attacker-cookie", "--owner-cookie"):
|
|
419
|
+
code, _ = self.run_cli(["--no-banner", "test", "--url", self.base_url, flag, BOB_COOKIE])
|
|
420
|
+
self.assertEqual(code, 2)
|
|
421
|
+
|
|
422
|
+
def test_no_cookie_or_session_id_in_output_or_report(self) -> None:
|
|
423
|
+
for fixed, denial in ((False, "error_page"), (True, "login_redirect")):
|
|
424
|
+
self.server.fixed = fixed
|
|
425
|
+
self.server.denial = denial
|
|
426
|
+
args = self.strict_args()
|
|
427
|
+
i = args.index("--deny-redirect")
|
|
428
|
+
del args[i:i + 2] # the redirect is reported as undeclared, with its target
|
|
429
|
+
_, output = self.run_cli(args)
|
|
430
|
+
text = output + self.out.read_text(encoding="utf-8")
|
|
431
|
+
for secret in ("sess-bob", "sess-alice", "sess-rewritten", "jsessionid"):
|
|
432
|
+
self.assertNotIn(secret, text)
|
|
433
|
+
|
|
434
|
+
def test_cookie_file_source(self) -> None:
|
|
435
|
+
path = self.dir / "bob.cookie"
|
|
436
|
+
path.write_text(BOB_COOKIE + "\n", encoding="utf-8")
|
|
437
|
+
os.chmod(path, 0o600)
|
|
438
|
+
args = self.strict_args()
|
|
439
|
+
i = args.index("--attacker-cookie-env")
|
|
440
|
+
args[i:i + 2] = ["--attacker-cookie-file", str(path)]
|
|
441
|
+
code, _ = self.run_cli(args)
|
|
442
|
+
self.assertEqual(code, 1)
|
|
443
|
+
self.assertTrue(self.by_finding()["BOLA-READ"]["is_breach"])
|
|
444
|
+
|
|
445
|
+
def test_unset_cookie_variable_is_config_error(self) -> None:
|
|
446
|
+
del self.env["ATT_COOKIE"]
|
|
447
|
+
with mock.patch.dict(os.environ, {}, clear=False):
|
|
448
|
+
os.environ.pop("ATT_COOKIE", None)
|
|
449
|
+
code, _ = self.run_cli(self.strict_args())
|
|
450
|
+
self.assertEqual(code, 2)
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
class TestSuites(SessionAppCase):
|
|
454
|
+
def write_suite(self, suite: Any) -> str:
|
|
455
|
+
path = self.dir / "suite.json"
|
|
456
|
+
path.write_text(json.dumps(suite), encoding="utf-8")
|
|
457
|
+
return str(path)
|
|
458
|
+
|
|
459
|
+
def suite_args(self, path: str) -> List[str]:
|
|
460
|
+
return [
|
|
461
|
+
"--no-banner", "test", "--url", self.base_url, "--suite", path, "--output", str(self.out),
|
|
462
|
+
"--deny-marker", DENIED_TEXT, "--deny-redirect", "/login.do", "--allow-redirect", "/order/list.do",
|
|
463
|
+
]
|
|
464
|
+
|
|
465
|
+
def test_v1_suite_form_and_csrf(self) -> None:
|
|
466
|
+
suite = {"tests": [{
|
|
467
|
+
"name": "update", "method": "POST", "path": "/order/update.do", "body_format": "form",
|
|
468
|
+
"payload": {"id": "100"}, "csrf": {"page": "/order/edit.do?id=200", "field": TOKEN_FIELD},
|
|
469
|
+
}]}
|
|
470
|
+
args = self.suite_args(self.write_suite(suite)) + ["--attacker-cookie-env", "ATT_COOKIE"]
|
|
471
|
+
code, _ = self.run_cli(args)
|
|
472
|
+
self.assertEqual(code, 1)
|
|
473
|
+
self.assertTrue(self.report()["results"][0]["is_breach"])
|
|
474
|
+
self.server.fixed = True
|
|
475
|
+
code, _ = self.run_cli(args)
|
|
476
|
+
self.assertEqual(code, 0)
|
|
477
|
+
|
|
478
|
+
def test_v1_suite_headers_are_sent(self) -> None:
|
|
479
|
+
suite = {"tests": [{"method": "GET", "path": "/order/view.do?id=100", "headers": {"X-Trace": "1", "Authorization": "Basic abc"}}]}
|
|
480
|
+
self.run_cli(self.suite_args(self.write_suite(suite)) + ["--attacker-cookie-env", "ATT_COOKIE"])
|
|
481
|
+
self.assertEqual(self.server.seen[-1].authorization, "Basic abc")
|
|
482
|
+
|
|
483
|
+
def test_v1_suite_bad_body_format_is_config_error(self) -> None:
|
|
484
|
+
suite = {"tests": [{"method": "POST", "path": "/x.do", "body_format": "xml"}]}
|
|
485
|
+
code, _ = self.run_cli(self.suite_args(self.write_suite(suite)))
|
|
486
|
+
self.assertEqual(code, 2)
|
|
487
|
+
self.assertEqual(self.server.seen, [])
|
|
488
|
+
|
|
489
|
+
def v2_suite(self) -> Dict[str, Any]:
|
|
490
|
+
return {
|
|
491
|
+
"version": 2,
|
|
492
|
+
"personas": {"owner": {"cookie_env": "OWN_COOKIE"}, "attacker": {"cookie_env": "ATT_COOKIE"}},
|
|
493
|
+
"routes": [
|
|
494
|
+
{"method": "GET", "path": "/order/view.do?id={id}", "params": {"id": "100"}, "allow": ["owner"]},
|
|
495
|
+
{"method": "POST", "path": "/order/update.do", "allow": ["owner"], "body_format": "form",
|
|
496
|
+
"payload": {"id": "100"}, "csrf": {"page": "/order/edit.do?id=200", "field": TOKEN_FIELD}},
|
|
497
|
+
],
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
def test_v2_suite_cookie_personas(self) -> None:
|
|
501
|
+
args = self.suite_args(self.write_suite(self.v2_suite()))
|
|
502
|
+
code, _ = self.run_cli(args)
|
|
503
|
+
self.assertEqual(code, 1)
|
|
504
|
+
self.assertTrue(all(r["is_breach"] for r in self.report()["matrix_results"]))
|
|
505
|
+
self.server.fixed = True
|
|
506
|
+
code, _ = self.run_cli(args)
|
|
507
|
+
self.assertEqual(code, 0)
|
|
508
|
+
|
|
509
|
+
def test_v2_session_check_covers_cookie_personas(self) -> None:
|
|
510
|
+
self.env["ATT_COOKIE"] = EXPIRED_COOKIE
|
|
511
|
+
args = self.suite_args(self.write_suite(self.v2_suite())) + ["--session-check", "/home.do"]
|
|
512
|
+
code, _ = self.run_cli(args)
|
|
513
|
+
self.assertEqual(code, 2)
|
|
514
|
+
|
|
515
|
+
def test_v2_rejects_token_and_cookie_together(self) -> None:
|
|
516
|
+
suite = self.v2_suite()
|
|
517
|
+
suite["personas"]["attacker"]["token_env"] = "T"
|
|
518
|
+
code, _ = self.run_cli(self.suite_args(self.write_suite(suite)))
|
|
519
|
+
self.assertEqual(code, 2)
|
|
520
|
+
|
|
521
|
+
def test_v2_rejects_inline_cookie(self) -> None:
|
|
522
|
+
suite = self.v2_suite()
|
|
523
|
+
suite["personas"]["attacker"] = {"cookie": "JSESSIONID=x"}
|
|
524
|
+
code, _ = self.run_cli(self.suite_args(self.write_suite(suite)))
|
|
525
|
+
self.assertEqual(code, 2)
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
class TestFormWithoutSession(unittest.TestCase):
|
|
529
|
+
def test_nested_form_value_is_an_error_not_a_crash(self) -> None:
|
|
530
|
+
server, base_url = start_legacy_app()
|
|
531
|
+
self.addCleanup(server.server_close)
|
|
532
|
+
self.addCleanup(server.shutdown)
|
|
533
|
+
case = TestCase("t", "/order/update.do", "POST", payload={"a": {"b": 1}}, body_format="form")
|
|
534
|
+
res = CrossUserTestRunner(base_url, default_attacker_token="t").execute_test(case)
|
|
535
|
+
self.assertFalse(res.is_secure or res.is_breach)
|
|
536
|
+
self.assertEqual(server.seen, [])
|
|
537
|
+
|
|
538
|
+
def test_only_http_urls_are_sent(self) -> None:
|
|
539
|
+
case = TestCase("t", "/etc/passwd", "GET", body_format="form")
|
|
540
|
+
res = CrossUserTestRunner("file://").execute_test(case)
|
|
541
|
+
self.assertFalse(res.is_secure or res.is_breach)
|
|
542
|
+
self.assertIn("http", res.error_message)
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
class TestBearerUnchanged(unittest.TestCase):
|
|
546
|
+
@classmethod
|
|
547
|
+
def setUpClass(cls) -> None:
|
|
548
|
+
cls.server, _, cls.base_url = start_mock_server(port=0)
|
|
549
|
+
|
|
550
|
+
@classmethod
|
|
551
|
+
def tearDownClass(cls) -> None:
|
|
552
|
+
cls.server.server_close()
|
|
553
|
+
|
|
554
|
+
def test_json_body_and_404_invariant(self) -> None:
|
|
555
|
+
runner = CrossUserTestRunner(self.base_url, default_attacker_token="persona-b")
|
|
556
|
+
res = runner.execute_test(TestCase("t", "/api/orders/1", "PUT", payload={"a": 1}))
|
|
557
|
+
self.assertTrue(res.is_secure)
|
|
558
|
+
self.assertEqual(set(res.to_dict()), {
|
|
559
|
+
"name", "finding_id", "consensus_score", "method", "path", "status_code",
|
|
560
|
+
"is_breach", "is_secure", "duration_ms", "error_message",
|
|
561
|
+
})
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
if __name__ == "__main__":
|
|
565
|
+
unittest.main()
|
|
@@ -16,12 +16,12 @@ class TestDockerPackaging(unittest.TestCase):
|
|
|
16
16
|
"""Verify Docker container definition and CI/CD workflow."""
|
|
17
17
|
|
|
18
18
|
def test_dockerfile_structure_and_security(self) -> None:
|
|
19
|
-
"""Dockerfile must exist, use python-
|
|
19
|
+
"""Dockerfile must exist, use a digest-pinned python-alpine base, configure non-root user, and set entrypoint."""
|
|
20
20
|
self.assertTrue(DOCKERFILE.is_file(), f"Dockerfile not found at {DOCKERFILE}")
|
|
21
21
|
content = DOCKERFILE.read_text(encoding="utf-8")
|
|
22
22
|
|
|
23
|
-
self.assertRegex(content, r"(?m)^FROM python:3\.\d+-
|
|
24
|
-
self.assertIn("
|
|
23
|
+
self.assertRegex(content, r"(?m)^FROM python:3\.\d+-alpine@sha256:[0-9a-f]{64}\b")
|
|
24
|
+
self.assertIn("adduser -u 1000", content)
|
|
25
25
|
self.assertIn("USER komagu", content)
|
|
26
26
|
self.assertIn("WORKDIR /workspace", content)
|
|
27
27
|
self.assertIn('ENTRYPOINT ["/app/bin/komagu"]', content)
|
|
@@ -98,6 +98,12 @@ class TestSanitizeFinding(unittest.TestCase):
|
|
|
98
98
|
self.assertEqual(f["evidence"][0]["line"], 7)
|
|
99
99
|
self.assertEqual(f["status"], "unverified")
|
|
100
100
|
|
|
101
|
+
def test_endpoint_method_list_becomes_string(self) -> None:
|
|
102
|
+
"""XML-declared Struts routes reach the model as "method": ["ANY"], and models echo it (#374)."""
|
|
103
|
+
f = HeadlessAIRunner._sanitize_finding({"category": "bola", "subtype": "missing_ownership_check",
|
|
104
|
+
"endpoint": {"method": ["GET", "POST"], "path": "/a.do"}})
|
|
105
|
+
self.assertEqual(f["endpoint"]["method"], "GET|POST")
|
|
106
|
+
|
|
101
107
|
def test_valid_subtype_is_kept(self) -> None:
|
|
102
108
|
f = HeadlessAIRunner._sanitize_finding({"category": "bfla", "subtype": "role_from_request"})
|
|
103
109
|
self.assertEqual(f["subtype"], "role_from_request")
|