@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,250 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""komagu leak-check: planted secrets are found and masked, clean output passes (runbook §9, #367)."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import base64
|
|
7
|
+
import contextlib
|
|
8
|
+
import io
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import tempfile
|
|
12
|
+
import unittest
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import List
|
|
15
|
+
|
|
16
|
+
from operate.cli.main import main
|
|
17
|
+
from operate.devsecops.leak_check import load_canaries, CanaryFileError, scan_paths, scan_text, shannon_entropy
|
|
18
|
+
|
|
19
|
+
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
20
|
+
# Assembled at run time so repository secret scanners do not flag the test itself
|
|
21
|
+
AWS_KEY = "AKIA" + "ABCDEFGHIJKLMNOP"
|
|
22
|
+
SLACK_TOKEN = "xoxb-" + "1234567890-abcdefghijklmnop"
|
|
23
|
+
JWT = "eyJhbGciOiJIUzI1NiJ9" + ".eyJzdWIiOiIxMjM0NTY3ODkwIn0" + ".dozjgNryP4J3jVmNHl0w5N_XgL0n3I9P"
|
|
24
|
+
RANDOM_TOKEN = "kX9mQ2vL7pR4" + "tZ8wB3nY6cF1"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def detectors(text: str) -> List[str]:
|
|
28
|
+
return [hit.detector for hit in scan_text("f", text)]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class TestDetection(unittest.TestCase):
|
|
32
|
+
def test_redaction_pattern_catches_unmasked_password(self) -> None:
|
|
33
|
+
self.assertIn("redaction-pattern", detectors('String password = "hunter2hunter2";'))
|
|
34
|
+
|
|
35
|
+
def test_token_patterns_beyond_the_redaction_list(self) -> None:
|
|
36
|
+
for planted in (SLACK_TOKEN, JWT):
|
|
37
|
+
self.assertIn("token-pattern", detectors(f"seen in reply: {planted}"), planted)
|
|
38
|
+
|
|
39
|
+
def test_aws_key_is_found(self) -> None:
|
|
40
|
+
self.assertTrue(detectors(f"key {AWS_KEY}"))
|
|
41
|
+
|
|
42
|
+
def test_entropy_finds_a_bare_token_no_pattern_knows(self) -> None:
|
|
43
|
+
self.assertEqual(detectors(f"the upstream expects {RANDOM_TOKEN} here"), ["entropy"])
|
|
44
|
+
|
|
45
|
+
def test_japanese_label_is_found(self) -> None:
|
|
46
|
+
self.assertIn("token-pattern", detectors("初期パスワード:Abcd1234"))
|
|
47
|
+
|
|
48
|
+
def test_secret_inside_json_string_is_found(self) -> None:
|
|
49
|
+
raw = json.dumps({"snippet": 'db.setPassword("hunter2hunter2");'})
|
|
50
|
+
self.assertIn("redaction-pattern", detectors(raw))
|
|
51
|
+
|
|
52
|
+
def test_secret_inside_double_encoded_json_is_found(self) -> None:
|
|
53
|
+
raw = json.dumps(json.dumps({"snippet": 'password = "hunter2hunter2"'}))
|
|
54
|
+
self.assertIn("redaction-pattern", detectors(raw))
|
|
55
|
+
|
|
56
|
+
def test_secret_inside_html_is_found(self) -> None:
|
|
57
|
+
self.assertIn("redaction-pattern", detectors("<code>password = "hunter2hunter2"</code>"))
|
|
58
|
+
|
|
59
|
+
def test_unmasked_multiline_private_key_is_found(self) -> None:
|
|
60
|
+
text = "-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEA7\n-----END RSA PRIVATE KEY-----"
|
|
61
|
+
self.assertIn("token-pattern", detectors(text))
|
|
62
|
+
|
|
63
|
+
def test_secret_at_end_of_very_long_line_is_found(self) -> None:
|
|
64
|
+
text = "x " * 5000 + 'password = "hunter2hunter2"'
|
|
65
|
+
self.assertIn("redaction-pattern", detectors(text))
|
|
66
|
+
|
|
67
|
+
def test_value_split_over_lines_is_found(self) -> None:
|
|
68
|
+
self.assertIn("redaction-pattern", detectors('<property name="password"\n value="hunter2hunter2"/>'))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class TestCleanOutput(unittest.TestCase):
|
|
72
|
+
def test_masked_values_are_clean(self) -> None:
|
|
73
|
+
for line in (
|
|
74
|
+
'password = "[REDACTED]"',
|
|
75
|
+
"jdbc:mysql://h/db?user=a&password=[REDACTED]",
|
|
76
|
+
"-----BEGIN PRIVATE KEY-----[REDACTED]-----END PRIVATE KEY-----",
|
|
77
|
+
"パスワード: [REDACTED]",
|
|
78
|
+
):
|
|
79
|
+
self.assertEqual(detectors(line), [], line)
|
|
80
|
+
|
|
81
|
+
def test_multiline_masked_key_is_clean(self) -> None:
|
|
82
|
+
text = "-----BEGIN PRIVATE KEY-----\n[REDACTED]\n-----END PRIVATE KEY-----"
|
|
83
|
+
self.assertEqual(detectors(text), [])
|
|
84
|
+
|
|
85
|
+
def test_ordinary_audit_content_is_clean(self) -> None:
|
|
86
|
+
for line in (
|
|
87
|
+
'"fingerprint": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"',
|
|
88
|
+
'"sha1": "da39a3ee5e6b4b0d3255bfef95601890afd80709"',
|
|
89
|
+
'"id": "123e4567-e89b-12d3-a456-426614174000"',
|
|
90
|
+
'"file": "src/main/java/com/acme/order/OrderManagementController.java"',
|
|
91
|
+
"org.springframework.web.servlet.DispatcherServlet",
|
|
92
|
+
"commons-collections4-4.4.jar and struts2-core-2.5.33.jar",
|
|
93
|
+
"パスワードはハードコードされています",
|
|
94
|
+
):
|
|
95
|
+
self.assertEqual(detectors(line), [], line)
|
|
96
|
+
|
|
97
|
+
def test_tracked_fallback_audit_output_is_clean(self) -> None:
|
|
98
|
+
result = scan_paths([str(REPO_ROOT / "delivery" / "pilot" / "audit-out")])
|
|
99
|
+
self.assertEqual([(h.path, h.line, h.rule) for h in result.hits], [])
|
|
100
|
+
self.assertEqual(result.exit_code, 0)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class TestCanaries(unittest.TestCase):
|
|
104
|
+
CANARY = "orange-falcon-db"
|
|
105
|
+
|
|
106
|
+
def run_with(self, text: str) -> int:
|
|
107
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
108
|
+
(Path(tmp) / "reply.txt").write_text(text, encoding="utf-8")
|
|
109
|
+
return len(scan_paths([tmp], [self.CANARY]).hits)
|
|
110
|
+
|
|
111
|
+
def test_canary_forms(self) -> None:
|
|
112
|
+
encoded = base64.b64encode(self.CANARY.encode()).decode()
|
|
113
|
+
for text in (
|
|
114
|
+
f"host {self.CANARY} found",
|
|
115
|
+
f"host {self.CANARY.upper()} found",
|
|
116
|
+
f"host {encoded} found",
|
|
117
|
+
json.dumps({"host": self.CANARY}),
|
|
118
|
+
):
|
|
119
|
+
self.assertGreaterEqual(self.run_with(text), 1, text)
|
|
120
|
+
|
|
121
|
+
def test_japanese_canary_in_ascii_escaped_json(self) -> None:
|
|
122
|
+
canary = "社内基幹システム"
|
|
123
|
+
hits = scan_text("f", json.dumps({"name": canary}), [])
|
|
124
|
+
self.assertEqual(hits, [])
|
|
125
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
126
|
+
(Path(tmp) / "r.json").write_text(json.dumps({"name": canary}), encoding="utf-8")
|
|
127
|
+
self.assertEqual([h.detector for h in scan_paths([tmp], [canary]).hits], ["canary"])
|
|
128
|
+
|
|
129
|
+
def test_canary_in_shift_jis_file(self) -> None:
|
|
130
|
+
canary = "社内基幹システム"
|
|
131
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
132
|
+
(Path(tmp) / "note.txt").write_bytes(f"対象: {canary}\n".encode("cp932"))
|
|
133
|
+
self.assertEqual(len(scan_paths([tmp], [canary]).hits), 1)
|
|
134
|
+
|
|
135
|
+
def test_canary_inside_encoded_payloads(self) -> None:
|
|
136
|
+
canary = "p@ss/w0rd-Zq9"
|
|
137
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
138
|
+
for offset in range(3):
|
|
139
|
+
payload = base64.b64encode(b"xy"[:offset] + canary.encode() + b"tail").decode()
|
|
140
|
+
(Path(tmp) / f"b{offset}.txt").write_text(payload, encoding="utf-8")
|
|
141
|
+
(Path(tmp) / "url.txt").write_text("p%40ss%2fw0rd-Zq9", encoding="utf-8")
|
|
142
|
+
(Path(tmp) / "wide.txt").write_text("p@ss/w0rd-Zq9", encoding="utf-8")
|
|
143
|
+
(Path(tmp) / "zw.txt").write_text("p@ss/\u200bw0rd-Zq9", encoding="utf-8")
|
|
144
|
+
hit_files = {Path(h.path).name for h in scan_paths([tmp], [canary]).hits}
|
|
145
|
+
self.assertEqual(hit_files, {"b0.txt", "b1.txt", "b2.txt", "url.txt", "wide.txt", "zw.txt"})
|
|
146
|
+
|
|
147
|
+
def test_no_canary_no_hit(self) -> None:
|
|
148
|
+
self.assertEqual(self.run_with("nothing to see"), 0)
|
|
149
|
+
|
|
150
|
+
def _load(self, content: str) -> tuple:
|
|
151
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
152
|
+
path = Path(tmp) / "c.txt"
|
|
153
|
+
path.write_text(content, encoding="utf-8")
|
|
154
|
+
return load_canaries(str(path))
|
|
155
|
+
|
|
156
|
+
def test_every_nonblank_line_is_a_canary(self) -> None:
|
|
157
|
+
self.assertEqual(self._load("#hash-start\n\n spaced-value \n"), ("#hash-start", "spaced-value"))
|
|
158
|
+
|
|
159
|
+
def test_empty_or_short_canary_file_is_rejected(self) -> None:
|
|
160
|
+
for content in ("\n\n", "abc\n"):
|
|
161
|
+
with self.assertRaises(CanaryFileError):
|
|
162
|
+
self._load(content)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class TestCommand(unittest.TestCase):
|
|
166
|
+
def setUp(self) -> None:
|
|
167
|
+
self.tmp = tempfile.TemporaryDirectory()
|
|
168
|
+
self.addCleanup(self.tmp.cleanup)
|
|
169
|
+
self.root = Path(self.tmp.name)
|
|
170
|
+
self.out = self.root / "out"
|
|
171
|
+
(self.out / "ai-raw").mkdir(parents=True)
|
|
172
|
+
|
|
173
|
+
def invoke(self, *args: str) -> "tuple[int, str]":
|
|
174
|
+
stdout = io.StringIO()
|
|
175
|
+
with contextlib.redirect_stdout(stdout):
|
|
176
|
+
code = main(["--no-banner", "leak-check", *args])
|
|
177
|
+
return code, stdout.getvalue()
|
|
178
|
+
|
|
179
|
+
def test_clean_output_exits_zero(self) -> None:
|
|
180
|
+
(self.out / "report.md").write_text('password = "[REDACTED]"\n', encoding="utf-8")
|
|
181
|
+
self.assertEqual(self.invoke(str(self.out))[0], 0)
|
|
182
|
+
|
|
183
|
+
def test_hit_exits_one_with_file_line_and_masked_value(self) -> None:
|
|
184
|
+
planted_value = "hunter2hunter2"
|
|
185
|
+
(self.out / "ai-raw" / "4a.txt").write_text(f'ok\npassword = "{planted_value}"\n', encoding="utf-8")
|
|
186
|
+
canaries = self.root / "canaries.txt"
|
|
187
|
+
canaries.write_text("orange-falcon-db\n", encoding="utf-8")
|
|
188
|
+
(self.out / "report.md").write_text("connects to orange-falcon-db\n", encoding="utf-8")
|
|
189
|
+
for fmt in ("text", "json"):
|
|
190
|
+
code, output = self.invoke(str(self.out), "--canary-file", str(canaries), "--format", fmt)
|
|
191
|
+
self.assertEqual(code, 1)
|
|
192
|
+
self.assertIn("4a.txt", output)
|
|
193
|
+
self.assertNotIn(planted_value, output)
|
|
194
|
+
self.assertNotIn("orange-falcon-db", output)
|
|
195
|
+
self.assertIn("4a.txt:2:", self.invoke(str(self.out))[1])
|
|
196
|
+
|
|
197
|
+
def test_hidden_cache_and_test_named_paths_are_scanned(self) -> None:
|
|
198
|
+
cache = self.out / ".cache" / "test-example"
|
|
199
|
+
cache.mkdir(parents=True)
|
|
200
|
+
(cache / "excerpt.txt").write_text(f"{RANDOM_TOKEN}\n", encoding="utf-8")
|
|
201
|
+
self.assertEqual(self.invoke(str(self.out))[0], 1)
|
|
202
|
+
|
|
203
|
+
def test_binary_file_makes_the_scan_incomplete(self) -> None:
|
|
204
|
+
(self.out / "report.md").write_text("fine\n", encoding="utf-8")
|
|
205
|
+
(self.out / "bundle.zip").write_bytes(b"PK\0\0payload")
|
|
206
|
+
code, output = self.invoke(str(self.out))
|
|
207
|
+
self.assertEqual(code, 2)
|
|
208
|
+
self.assertIn("NOT SCANNED", output)
|
|
209
|
+
|
|
210
|
+
def test_missing_or_empty_target_is_not_clean(self) -> None:
|
|
211
|
+
self.assertEqual(self.invoke(str(self.root / "absent"))[0], 2)
|
|
212
|
+
self.assertEqual(self.invoke(str(self.out))[0], 2)
|
|
213
|
+
|
|
214
|
+
def test_unusable_canary_file_is_not_clean(self) -> None:
|
|
215
|
+
(self.out / "report.md").write_text("fine\n", encoding="utf-8")
|
|
216
|
+
for value in (str(self.root / "absent.txt"), ""):
|
|
217
|
+
stderr = io.StringIO()
|
|
218
|
+
with contextlib.redirect_stderr(stderr):
|
|
219
|
+
code, _ = self.invoke(str(self.out), "--canary-file", value)
|
|
220
|
+
self.assertEqual(code, 2, value)
|
|
221
|
+
|
|
222
|
+
def test_symlinked_directory_makes_the_scan_incomplete(self) -> None:
|
|
223
|
+
(self.out / "report.md").write_text("fine\n", encoding="utf-8")
|
|
224
|
+
real = self.root / "real"
|
|
225
|
+
real.mkdir()
|
|
226
|
+
(self.out / "linked").symlink_to(real, target_is_directory=True)
|
|
227
|
+
self.assertEqual(self.invoke(str(self.out))[0], 2)
|
|
228
|
+
|
|
229
|
+
@unittest.skipIf(os.name == "nt" or os.geteuid() == 0, "needs enforced directory permissions")
|
|
230
|
+
def test_unreadable_directory_makes_the_scan_incomplete(self) -> None:
|
|
231
|
+
(self.out / "report.md").write_text("fine\n", encoding="utf-8")
|
|
232
|
+
locked = self.out / "locked"
|
|
233
|
+
locked.mkdir()
|
|
234
|
+
locked.chmod(0)
|
|
235
|
+
self.addCleanup(locked.chmod, 0o700)
|
|
236
|
+
self.assertEqual(self.invoke(str(self.out))[0], 2)
|
|
237
|
+
|
|
238
|
+
def test_utf32_file_is_not_treated_as_clean_text(self) -> None:
|
|
239
|
+
(self.out / "report.txt").write_bytes('password = "hunter2hunter2"'.encode("utf-32"))
|
|
240
|
+
self.assertEqual(self.invoke(str(self.out))[0], 2)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
class TestEntropy(unittest.TestCase):
|
|
244
|
+
def test_entropy_ordering(self) -> None:
|
|
245
|
+
self.assertEqual(shannon_entropy("aaaa"), 0)
|
|
246
|
+
self.assertGreater(shannon_entropy(RANDOM_TOKEN), shannon_entropy("passwordpasswordpassword"))
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
if __name__ == "__main__":
|
|
250
|
+
unittest.main()
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Gaps found rehearsing the pilot runbook on a running Struts / JSESSIONID application (#374)."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import json
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
import tempfile
|
|
11
|
+
import unittest
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from unittest.mock import MagicMock, patch
|
|
14
|
+
|
|
15
|
+
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
16
|
+
sys.path.insert(0, str(REPO_ROOT))
|
|
17
|
+
|
|
18
|
+
from operate.ai.runner import HeadlessAIRunner # noqa: E402
|
|
19
|
+
from operate.cli.commands import protect # noqa: E402
|
|
20
|
+
from operate.cross_user_tester.runner import generate_test_cases_from_findings # noqa: E402
|
|
21
|
+
from remediate.edge.coraza.virtual_patcher import VirtualPatcher # noqa: E402
|
|
22
|
+
|
|
23
|
+
EDGE = REPO_ROOT / "remediate" / "edge"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def protect_args(**overrides) -> argparse.Namespace:
|
|
27
|
+
values = dict(target=None, config=None, up=False, down=False, status=False,
|
|
28
|
+
virtual_patch=None, patch_out=None, dry_run=False)
|
|
29
|
+
return argparse.Namespace(**{**values, **overrides})
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ProtectWiringTests(unittest.TestCase):
|
|
33
|
+
def run_up(self, **overrides):
|
|
34
|
+
with patch.object(protect.shutil, "which", return_value="/usr/bin/docker"), \
|
|
35
|
+
patch.object(protect.subprocess, "run", return_value=MagicMock(returncode=0)) as run:
|
|
36
|
+
code = protect.execute_protect(protect_args(up=True, **overrides))
|
|
37
|
+
return code, run
|
|
38
|
+
|
|
39
|
+
def test_target_reaches_compose(self) -> None:
|
|
40
|
+
code, run = self.run_up(target="http://host.docker.internal:18080")
|
|
41
|
+
self.assertEqual(code, 0)
|
|
42
|
+
self.assertEqual(run.call_args.kwargs["env"]["KOMAGU_UPSTREAM"], "http://host.docker.internal:18080")
|
|
43
|
+
|
|
44
|
+
def test_custom_kong_config_reaches_compose(self) -> None:
|
|
45
|
+
config = EDGE / "kong" / "kong-legacy-session.yaml"
|
|
46
|
+
_, run = self.run_up(config=str(config))
|
|
47
|
+
self.assertEqual(run.call_args.kwargs["env"]["KOMAGU_KONG_CONFIG"], str(config.resolve()))
|
|
48
|
+
|
|
49
|
+
def test_patch_out_file_is_loaded_by_the_stack(self) -> None:
|
|
50
|
+
with tempfile.TemporaryDirectory() as td:
|
|
51
|
+
conf = Path(td) / "virtual-patches.conf"
|
|
52
|
+
conf.write_text("# none\n", encoding="utf-8")
|
|
53
|
+
_, run = self.run_up(patch_out=str(conf))
|
|
54
|
+
self.assertEqual(run.call_args.kwargs["env"]["KOMAGU_VIRTUAL_PATCH_FILE"], str(conf.resolve()))
|
|
55
|
+
|
|
56
|
+
def test_missing_patch_file_stops_before_docker(self) -> None:
|
|
57
|
+
with tempfile.TemporaryDirectory() as td:
|
|
58
|
+
code, run = self.run_up(patch_out=str(Path(td) / "absent.conf"))
|
|
59
|
+
self.assertEqual(code, 2)
|
|
60
|
+
run.assert_not_called()
|
|
61
|
+
|
|
62
|
+
def test_invalid_target_stops_before_docker(self) -> None:
|
|
63
|
+
for bad in ("ftp://host:1", "host:8080", "http://"):
|
|
64
|
+
code, run = self.run_up(target=bad)
|
|
65
|
+
self.assertEqual(code, 2, bad)
|
|
66
|
+
run.assert_not_called()
|
|
67
|
+
|
|
68
|
+
def test_stack_reads_the_settings(self) -> None:
|
|
69
|
+
compose = (EDGE / "docker-compose.yml").read_text(encoding="utf-8")
|
|
70
|
+
caddy = (EDGE / "coraza" / "Caddyfile").read_text(encoding="utf-8")
|
|
71
|
+
self.assertIn("{$KOMAGU_UPSTREAM:http://mock-backend:5000}", caddy)
|
|
72
|
+
self.assertIn("KOMAGU_UPSTREAM: ${KOMAGU_UPSTREAM:-http://mock-backend:5000}", compose)
|
|
73
|
+
self.assertIn("${KOMAGU_KONG_CONFIG:-./kong/kong.yaml}:/kong/declarative/kong.yaml:ro", compose)
|
|
74
|
+
self.assertIn("${KOMAGU_VIRTUAL_PATCH_FILE:-./coraza/rules/virtual-patches.conf}:/etc/coraza/rules/virtual-patches.conf:ro", compose)
|
|
75
|
+
self.assertIn('"${KOMAGU_EDGE_BIND:-0.0.0.0}:8000:8000"', compose)
|
|
76
|
+
self.assertIn("host.docker.internal:host-gateway", compose)
|
|
77
|
+
|
|
78
|
+
def test_legacy_kong_profile_routes_everything_without_jwt(self) -> None:
|
|
79
|
+
text = (EDGE / "kong" / "kong-legacy-session.yaml").read_text(encoding="utf-8")
|
|
80
|
+
self.assertIn("- /\n", text)
|
|
81
|
+
self.assertIn("strip_path: false", text)
|
|
82
|
+
self.assertIn("name: rate-limiting", text)
|
|
83
|
+
self.assertIn("limit_by: ip", text)
|
|
84
|
+
self.assertNotIn("name: jwt", text)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class VirtualPatchSessionAppTests(unittest.TestCase):
|
|
88
|
+
def test_bola_on_session_route_gets_no_authorization_rule(self) -> None:
|
|
89
|
+
"""A missing Authorization header says nothing on a JSESSIONID app: the rule would block every user."""
|
|
90
|
+
text, manifest = VirtualPatcher().generate_patches_from_findings([
|
|
91
|
+
{"finding_id": "BOLA-R-1", "category": "bola", "endpoint": {"method": "ANY", "path": "/order/detail.do"}},
|
|
92
|
+
])
|
|
93
|
+
self.assertEqual(manifest, [])
|
|
94
|
+
self.assertNotIn("REQUEST_HEADERS:Authorization", text)
|
|
95
|
+
self.assertIn("BOLA-R-1", text)
|
|
96
|
+
|
|
97
|
+
def test_bola_on_api_route_is_unchanged(self) -> None:
|
|
98
|
+
text, manifest = VirtualPatcher().generate_patches_from_findings([
|
|
99
|
+
{"finding_id": "BOLA-R-2", "category": "bola", "endpoint": {"method": "GET", "path": "/api/orders/{id}"}},
|
|
100
|
+
])
|
|
101
|
+
self.assertEqual(len(manifest), 1)
|
|
102
|
+
self.assertIn("REQUEST_HEADERS:Authorization", text)
|
|
103
|
+
|
|
104
|
+
def test_injection_without_a_route_gets_no_rule_on_an_invented_path(self) -> None:
|
|
105
|
+
text, manifest = VirtualPatcher().generate_patches_from_findings([
|
|
106
|
+
{"finding_id": "INJ-I-1", "category": "sql_injection", "location": {"file": "A.java", "start_line": 3}},
|
|
107
|
+
])
|
|
108
|
+
self.assertEqual(manifest, [])
|
|
109
|
+
self.assertNotIn('"@rx ^/api"', text)
|
|
110
|
+
self.assertIn("INJ-I-1", text)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class FromScanLegacyRequestTests(unittest.TestCase):
|
|
114
|
+
def cases(self, path: str, method: str = "ANY", **attack_vector):
|
|
115
|
+
finding = {"finding_id": "BOLA-R-1", "category": "bola", "status": "confirmed", "title": "t",
|
|
116
|
+
"endpoint": {"method": method, "path": path}}
|
|
117
|
+
if attack_vector:
|
|
118
|
+
finding["attack_vector"] = attack_vector
|
|
119
|
+
with tempfile.TemporaryDirectory() as td:
|
|
120
|
+
scan = Path(td) / "findings.json"
|
|
121
|
+
scan.write_text(json.dumps({"findings": [finding]}), encoding="utf-8")
|
|
122
|
+
return generate_test_cases_from_findings(str(scan), "101", "token")
|
|
123
|
+
|
|
124
|
+
def test_any_on_a_read_action_is_get_with_the_id_parameter(self) -> None:
|
|
125
|
+
case = self.cases("/order/detail.do")[0]
|
|
126
|
+
self.assertEqual((case.method, case.path), ("GET", "/order/detail.do?id=101"))
|
|
127
|
+
|
|
128
|
+
def test_any_on_a_write_action_is_a_form_post(self) -> None:
|
|
129
|
+
case = self.cases("/order/update.do")[0]
|
|
130
|
+
self.assertEqual(case.method, "POST")
|
|
131
|
+
self.assertEqual(case.payload["id"], "101")
|
|
132
|
+
|
|
133
|
+
def test_write_word_must_be_a_whole_word(self) -> None:
|
|
134
|
+
for path in ("/order/loadDetail.do", "/order/addressList.do", "/order/additionalInfo.do"):
|
|
135
|
+
self.assertEqual(self.cases(path)[0].method, "GET", path)
|
|
136
|
+
for path in ("/order/saveOrder.do", "/order/addItem.do", "/order/doUpdate.do", "/order/order_delete.do"):
|
|
137
|
+
self.assertEqual(self.cases(path)[0].method, "POST", path)
|
|
138
|
+
|
|
139
|
+
def test_target_param_names_the_id(self) -> None:
|
|
140
|
+
case = self.cases("/invoice/view.do", target_param="invoiceId")[0]
|
|
141
|
+
self.assertEqual(case.path, "/invoice/view.do?invoiceId=101")
|
|
142
|
+
|
|
143
|
+
def test_explicit_method_wins(self) -> None:
|
|
144
|
+
self.assertEqual(self.cases("/order/update.do", method="GET")[0].method, "GET")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class AttackVectorFromRouteTests(unittest.TestCase):
|
|
148
|
+
def test_id_like_input_becomes_target_param(self) -> None:
|
|
149
|
+
route = {"id_inputs": [{"name": "page", "source": "any"}, {"name": "invoiceId", "source": "any"}]}
|
|
150
|
+
finding = HeadlessAIRunner._with_route_target({"finding_id": "B"}, route)
|
|
151
|
+
self.assertEqual(finding["attack_vector"]["target_param"], "invoiceId")
|
|
152
|
+
|
|
153
|
+
def test_model_value_is_kept(self) -> None:
|
|
154
|
+
route = {"id_inputs": [{"name": "invoiceId", "source": "any"}]}
|
|
155
|
+
finding = {"finding_id": "B", "attack_vector": {"target_param": "orderNo"}}
|
|
156
|
+
self.assertEqual(HeadlessAIRunner._with_route_target(finding, route)["attack_vector"]["target_param"], "orderNo")
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class FixDiffFormatTests(unittest.TestCase):
|
|
160
|
+
def test_manual_fix_findings_are_listed_when_no_diff_exists(self) -> None:
|
|
161
|
+
"""All Java legacy findings are manual, so --format diff used to print nothing and exit 0 (#374)."""
|
|
162
|
+
with tempfile.TemporaryDirectory() as td:
|
|
163
|
+
root = Path(td)
|
|
164
|
+
(root / "src").mkdir()
|
|
165
|
+
(root / "src" / "detail.jsp").write_text("<% out.print(request.getParameter(\"id\")); %>\n", encoding="utf-8")
|
|
166
|
+
findings = root / "findings.json"
|
|
167
|
+
findings.write_text(json.dumps({"findings": [{
|
|
168
|
+
"finding_id": "BOLA-R-7", "category": "bola", "status": "confirmed", "severity": "high",
|
|
169
|
+
"subtype": "missing_ownership_check", "title": "t",
|
|
170
|
+
"location": {"file": "src/detail.jsp", "start_line": 1},
|
|
171
|
+
"endpoint": {"method": "GET", "path": "/detail.jsp"}}]}), encoding="utf-8")
|
|
172
|
+
res = subprocess.run([sys.executable, str(REPO_ROOT / "bin" / "komagu"), "fix", str(root), "--findings",
|
|
173
|
+
str(findings), "--dry-run", "--format", "diff"], capture_output=True, text=True)
|
|
174
|
+
self.assertEqual(res.returncode, 0, res.stderr)
|
|
175
|
+
self.assertIn("BOLA-R-7", res.stderr)
|
|
176
|
+
self.assertIn("src/detail.jsp", res.stderr)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class MergeReportsDroppedFindingsTests(unittest.TestCase):
|
|
180
|
+
def test_validation_errors_reach_stderr(self) -> None:
|
|
181
|
+
with tempfile.TemporaryDirectory() as td:
|
|
182
|
+
out = Path(td)
|
|
183
|
+
(out / "findings").mkdir()
|
|
184
|
+
(out / "findings" / "bad.json").write_text('[{"some": "item"}]', encoding="utf-8")
|
|
185
|
+
res = subprocess.run([sys.executable, str(REPO_ROOT / "assess/lsa/scripts/merge_findings.py"), str(out)],
|
|
186
|
+
capture_output=True, text=True)
|
|
187
|
+
self.assertEqual(res.returncode, 1)
|
|
188
|
+
self.assertIn("bad.json", res.stderr)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
if __name__ == "__main__":
|
|
192
|
+
unittest.main()
|