@hoanghainh/komagu 2.6.0 → 2.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +1 -0
  3. package/assess/lsa/knowledge/path-ssrf-patterns.md +19 -0
  4. package/assess/lsa/knowledge/severity-and-cwe.md +3 -1
  5. package/assess/lsa/scripts/find_path_ssrf.py +176 -0
  6. package/assess/lsa/scripts/findings_to_sarif.py +29 -0
  7. package/assess/lsa/scripts/merge_findings.py +1 -1
  8. package/assess/lsa/scripts/run_prescan.py +4 -0
  9. package/assess/lsa/tests/run_tests.py +4 -0
  10. package/assess/lsa/tests/test_path_ssrf.py +94 -0
  11. package/contracts/schemas/finding.schema.json +4 -2
  12. package/examples/member-portal/backend-dotnet/Auth/DemoAuthenticationHandler.cs +75 -0
  13. package/examples/member-portal/backend-dotnet/Program.cs +1 -72
  14. package/examples/member-portal/backend-spring/src/main/kotlin/io/komagu/portal/Application.kt +4 -0
  15. package/examples/member-portal/backend-spring/src/main/kotlin/io/komagu/portal/controller/Controllers.kt +3 -0
  16. package/examples/member-portal/backend-spring/src/main/kotlin/io/komagu/portal/model/Entities.kt +4 -0
  17. package/examples/member-portal/tests/test_d6_demo_app.py +4 -4
  18. package/examples/member-portal/tests/test_d6_remediation_verification.py +6 -6
  19. package/operate/README.md +6 -1
  20. package/operate/ai/client.py +5 -1
  21. package/operate/cli/__init__.py +1 -1
  22. package/operate/cli/commands/fix.py +47 -0
  23. package/operate/cli/commands/mcp.py +54 -0
  24. package/operate/cli/main.py +42 -4
  25. package/operate/cross_user_tester/runner.py +4 -1
  26. package/operate/devsecops/sbom.py +12 -4
  27. package/operate/mcp/__init__.py +11 -0
  28. package/operate/mcp/server.py +206 -0
  29. package/operate/mcp/tools.py +464 -0
  30. package/operate/remediator/ai_fixer.py +285 -0
  31. package/operate/remediator/runner.py +11 -0
  32. package/operate/remediator/strategies.py +64 -0
  33. package/operate/simulate/scenarios.py +11 -2
  34. package/operate/tests/test_ai_remediation.py +288 -0
  35. package/operate/tests/test_audit_minimum_versions.py +89 -0
  36. package/operate/tests/test_cli.py +1 -1
  37. package/operate/tests/test_mcp_server.py +380 -0
  38. package/operate/tests/test_scheme_and_xml_guards.py +67 -0
  39. package/package.json +1 -1
  40. package/remediate/authz/bfla_patcher.py +319 -0
  41. package/remediate/authz/dotnet/src/Komagu.Security.Authorization/LoggingSecurityAuditSink.cs +19 -3
  42. package/remediate/authz/golang/authz_test.go +5 -2
  43. package/remediate/authz/mass_assignment_patcher.py +188 -0
  44. package/remediate/authz/patcher.py +8 -0
  45. package/remediate/authz/spring/src/main/kotlin/io/komagu/security/authorization/DefaultKomaguSecurityManager.kt +74 -78
  46. package/remediate/authz/spring/src/main/kotlin/io/komagu/security/spring/OwnershipAspect.kt +129 -158
  47. package/remediate/authz/tests/test_bfla_and_mass_assignment_patcher.py +213 -0
  48. package/remediate/deps/patcher.py +2 -1
  49. package/remediate/edge/mock-backend/app.py +2 -1
  50. package/remediate/edge/tests/test_edge_security.py +5 -5
  51. package/remediate/ekyc/requirements.txt +1 -1
  52. package/remediate/identity/dotnet/src/Komagu.Security.Identity/OidcServiceCollectionExtensions.cs +19 -6
  53. package/remediate/identity/python/requirements.txt +2 -2
  54. package/remediate/identity/spring/src/main/kotlin/io/komagu/security/identity/KomaguIdentityAutoConfiguration.kt +31 -38
  55. package/remediate/legacy_shims/tests/test_legacy_shims.py +8 -8
  56. package/remediate/query/dotnet/src/Komagu.Security.Query/SafeSortAllowList.cs +5 -2
  57. package/remediate/query/spring/src/main/kotlin/io/komagu/security/query/SafeSortAllowList.kt +10 -11
  58. package/remediate/query/spring/src/main/kotlin/io/komagu/security/query/detekt/StringBuiltSqlQueryRule.kt +19 -24
@@ -0,0 +1,380 @@
1
+ #!/usr/bin/env python3
2
+ """Stdio MCP server: handshake, tool catalog, and the local analysis tools (issue #393)."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import io
7
+ import json
8
+ import select
9
+ import subprocess
10
+ import sys
11
+ import tempfile
12
+ import unittest
13
+ from pathlib import Path
14
+ from typing import Any, Dict, List
15
+
16
+ from operate.cli import __version__
17
+ from operate.cli.main import main
18
+ from operate.mcp.server import LATEST_PROTOCOL, PROTOCOL_VERSIONS, Session, serve
19
+ from operate.mcp.tools import INSTRUCTIONS, build_registry
20
+
21
+ REPO_ROOT = Path(__file__).resolve().parents[2]
22
+ BIN_KOMAGU = REPO_ROOT / "bin" / "komagu"
23
+ FIXTURE = REPO_ROOT / "assess" / "lsa" / "tests" / "fixtures" / "php-plain"
24
+ TOOL_NAMES = ["fix_preview", "inventory", "leak_check", "read_audit", "scan"]
25
+
26
+
27
+ def _initialize(protocol: str = "2025-06-18", request_id: int = 1) -> Dict[str, Any]:
28
+ return {
29
+ "jsonrpc": "2.0",
30
+ "id": request_id,
31
+ "method": "initialize",
32
+ "params": {
33
+ "protocolVersion": protocol,
34
+ "capabilities": {},
35
+ "clientInfo": {"name": "komagu-test", "version": "0"},
36
+ },
37
+ }
38
+
39
+
40
+ def exchange(messages: List[Dict[str, Any]], registry=None) -> List[Dict[str, Any]]:
41
+ """Run a session to EOF and return every JSON response."""
42
+ stdin = io.StringIO("".join(json.dumps(message) + "\n" for message in messages))
43
+ stdout = io.StringIO()
44
+ session = Session(registry or build_registry(), __version__, INSTRUCTIONS)
45
+ serve(session, stdin, stdout)
46
+ lines = [line for line in stdout.getvalue().splitlines() if line.strip()]
47
+ for line in lines:
48
+ json.loads(line) # each response is one JSON value, with no raw newline inside it
49
+ if "\n" in line:
50
+ raise AssertionError(line)
51
+ return [json.loads(line) for line in lines]
52
+
53
+
54
+ def ready_messages() -> List[Dict[str, Any]]:
55
+ """initialize plus the initialized notification."""
56
+ return [
57
+ _initialize(),
58
+ {"jsonrpc": "2.0", "method": "notifications/initialized"},
59
+ ]
60
+
61
+
62
+ class TestHandshake(unittest.TestCase):
63
+ def test_supported_protocol_is_echoed(self) -> None:
64
+ for protocol in PROTOCOL_VERSIONS:
65
+ responses = exchange([_initialize(protocol)])
66
+ self.assertEqual(responses[0]["result"]["protocolVersion"], protocol)
67
+ self.assertEqual(responses[0]["result"]["serverInfo"]["name"], "komagu")
68
+ self.assertEqual(responses[0]["result"]["serverInfo"]["version"], __version__)
69
+ self.assertIn("fix_preview", responses[0]["result"]["instructions"])
70
+ self.assertFalse(responses[0]["result"]["capabilities"]["tools"]["listChanged"])
71
+
72
+ def test_unknown_protocol_falls_back_to_latest(self) -> None:
73
+ responses = exchange([_initialize("1999-01-01")])
74
+ self.assertEqual(responses[0]["result"]["protocolVersion"], LATEST_PROTOCOL)
75
+
76
+ def test_second_initialize_is_rejected(self) -> None:
77
+ responses = exchange([_initialize(request_id=1), _initialize(request_id=2)])
78
+ self.assertEqual(responses[1]["error"]["code"], -32600)
79
+
80
+ def test_tools_wait_until_initialized(self) -> None:
81
+ responses = exchange(
82
+ [
83
+ _initialize(),
84
+ {"jsonrpc": "2.0", "id": 2, "method": "tools/list"},
85
+ ]
86
+ )
87
+ self.assertEqual(responses[1]["error"]["message"], "Server not initialized")
88
+
89
+ def test_initialized_notification_has_no_response(self) -> None:
90
+ responses = exchange(ready_messages())
91
+ self.assertEqual(len(responses), 1)
92
+ self.assertEqual(responses[0]["id"], 1)
93
+
94
+ def test_tools_list_after_initialized(self) -> None:
95
+ responses = exchange(
96
+ ready_messages()
97
+ + [{"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}}]
98
+ )
99
+ tools = responses[1]["result"]["tools"]
100
+ self.assertEqual([tool["name"] for tool in tools], TOOL_NAMES)
101
+ scan = next(tool for tool in tools if tool["name"] == "scan")
102
+ self.assertIn("target", scan["inputSchema"]["properties"])
103
+ self.assertFalse(scan["annotations"]["destructiveHint"])
104
+ self.assertTrue(next(tool for tool in tools if tool["name"] == "read_audit")["annotations"]["readOnlyHint"])
105
+
106
+ def test_unknown_tool_is_an_invalid_params_error(self) -> None:
107
+ responses = exchange(
108
+ ready_messages()
109
+ + [
110
+ {
111
+ "jsonrpc": "2.0",
112
+ "id": 3,
113
+ "method": "tools/call",
114
+ "params": {"name": "exploit", "arguments": {}},
115
+ }
116
+ ]
117
+ )
118
+ self.assertEqual(responses[1]["error"]["code"], -32602)
119
+ self.assertIn("exploit", responses[1]["error"]["message"])
120
+
121
+ def test_parse_error_and_unknown_method(self) -> None:
122
+ stdin = io.StringIO(
123
+ "\n"
124
+ + "not-json\n"
125
+ + json.dumps(_initialize())
126
+ + "\n"
127
+ + json.dumps({"jsonrpc": "2.0", "method": "notifications/initialized"})
128
+ + "\n"
129
+ + json.dumps({"jsonrpc": "2.0", "id": 9, "method": "resources/list"})
130
+ + "\n"
131
+ )
132
+ stdout = io.StringIO()
133
+ serve(Session(build_registry(), __version__, INSTRUCTIONS), stdin, stdout)
134
+ responses = [json.loads(line) for line in stdout.getvalue().splitlines() if line.strip()]
135
+ self.assertEqual(responses[0]["error"]["code"], -32700)
136
+ self.assertIsNone(responses[0]["id"])
137
+ self.assertEqual(responses[2]["error"]["code"], -32601)
138
+
139
+ def test_ping_and_bad_arguments_are_tool_errors(self) -> None:
140
+ responses = exchange(
141
+ ready_messages()
142
+ + [
143
+ {"jsonrpc": "2.0", "id": "ping-1", "method": "ping"},
144
+ {
145
+ "jsonrpc": "2.0",
146
+ "id": 4,
147
+ "method": "tools/call",
148
+ "params": {"name": "read_audit", "arguments": {}},
149
+ },
150
+ ]
151
+ )
152
+ self.assertEqual(responses[1]["id"], "ping-1")
153
+ self.assertEqual(responses[1]["result"], {})
154
+ self.assertTrue(responses[2]["result"]["isError"])
155
+ self.assertIn("audit_dir", responses[2]["result"]["content"][0]["text"])
156
+
157
+
158
+ class TestTools(unittest.TestCase):
159
+ def _call(self, name: str, arguments: Dict[str, Any]) -> Dict[str, Any]:
160
+ responses = exchange(
161
+ ready_messages()
162
+ + [
163
+ {
164
+ "jsonrpc": "2.0",
165
+ "id": 5,
166
+ "method": "tools/call",
167
+ "params": {"name": name, "arguments": arguments},
168
+ }
169
+ ]
170
+ )
171
+ self.assertNotIn("error", responses[-1])
172
+ return responses[-1]["result"]
173
+
174
+ def test_read_audit_lists_finding_metadata_without_source(self) -> None:
175
+ with tempfile.TemporaryDirectory() as tmp:
176
+ audit = Path(tmp)
177
+ (audit / "stack-profile.json").write_text(
178
+ json.dumps({"stacks": [{"id": "php-plain"}]}),
179
+ encoding="utf-8",
180
+ )
181
+ (audit / "findings.json").write_text(
182
+ json.dumps(
183
+ {
184
+ "summary": {"total": 1, "by_severity": {"high": 1}},
185
+ "findings": [
186
+ {
187
+ "finding_id": "BOLA-1",
188
+ "category": "bola",
189
+ "severity": "high",
190
+ "title": "missing owner check",
191
+ "status": "new",
192
+ "location": {"file": "app.php", "start_line": 12},
193
+ "evidence": [{"snippet": "EVIDENCE-MARKER"}],
194
+ }
195
+ ],
196
+ }
197
+ ),
198
+ encoding="utf-8",
199
+ )
200
+ result = self._call("read_audit", {"audit_dir": str(audit)})
201
+ self.assertFalse(result["isError"])
202
+ body = result["structuredContent"]
203
+ self.assertEqual(body["stacks"], ["php-plain"])
204
+ self.assertEqual(body["findings"][0]["finding_id"], "BOLA-1")
205
+ self.assertEqual(body["findings"][0]["start_line"], 12)
206
+ self.assertNotIn("evidence", body["findings"][0])
207
+ self.assertNotIn("EVIDENCE-MARKER", result["content"][0]["text"])
208
+
209
+ def test_leak_check_masks_a_planted_canary(self) -> None:
210
+ with tempfile.TemporaryDirectory() as tmp:
211
+ root = Path(tmp)
212
+ report = root / "report.md"
213
+ marker = "komagu-mcp-" + "canary-marker"
214
+ canaries = root / "canaries.txt"
215
+ canaries.write_text(marker + "\n", encoding="utf-8")
216
+ report.write_text("client sent " + marker + "\n", encoding="utf-8")
217
+ result = self._call("leak_check", {"paths": [str(report)], "canary_file": str(canaries)})
218
+ body = result["structuredContent"]
219
+ self.assertEqual(body["exitCode"], 1)
220
+ self.assertEqual(body["canariesLoaded"], 1)
221
+ self.assertIn("canary", [hit["detector"] for hit in body["hits"]])
222
+ self.assertTrue(all(marker not in hit["masked"] for hit in body["hits"]))
223
+ self.assertNotIn(marker, result["content"][0]["text"])
224
+
225
+ def test_inventory_reports_a_shadow_route(self) -> None:
226
+ with tempfile.TemporaryDirectory() as tmp:
227
+ root = Path(tmp)
228
+ routes = root / "routes.json"
229
+ spec = root / "openapi.json"
230
+ routes.write_text(
231
+ json.dumps(
232
+ {
233
+ "routes": [
234
+ {"method": "GET", "path": "/api/users/{id}", "location": "app.py:10"},
235
+ {"method": "POST", "path": "/api/shadow-login", "location": "auth.py:4"},
236
+ ]
237
+ }
238
+ ),
239
+ encoding="utf-8",
240
+ )
241
+ spec.write_text(
242
+ json.dumps({"openapi": "3.0.0", "paths": {"/api/users/{id}": {"get": {"summary": "Get user"}}}}),
243
+ encoding="utf-8",
244
+ )
245
+ result = self._call("inventory", {"routes": str(routes), "openapi": str(spec)})
246
+ body = result["structuredContent"]
247
+ self.assertGreaterEqual(body["summary"]["shadow_count"], 1)
248
+ self.assertTrue(any(item["path"] == "/api/shadow-login" for item in body["shadow_apis"]))
249
+
250
+ def test_fix_preview_does_not_modify_source(self) -> None:
251
+ with tempfile.TemporaryDirectory() as tmp:
252
+ root = Path(tmp)
253
+ source = root / "app.php"
254
+ original = "<?php echo $_GET['id'];\n"
255
+ source.write_text(original, encoding="utf-8")
256
+ findings = root / "findings.json"
257
+ findings.write_text(
258
+ json.dumps(
259
+ {
260
+ "findings": [
261
+ {
262
+ "finding_id": "BOLA-1",
263
+ "category": "bola",
264
+ "title": "missing owner check",
265
+ "location": {"file": "app.php", "start_line": 1, "end_line": 1},
266
+ "severity": "high",
267
+ "status": "new",
268
+ }
269
+ ]
270
+ }
271
+ ),
272
+ encoding="utf-8",
273
+ )
274
+ result = self._call("fix_preview", {"target": str(root), "findings": str(findings)})
275
+ body = result["structuredContent"]
276
+ self.assertTrue(body["dry_run"])
277
+ self.assertEqual(body["applied_count"], 0)
278
+ self.assertFalse(body["modifiedSource"])
279
+ self.assertEqual(source.read_text(encoding="utf-8"), original)
280
+ self.assertFalse(any(root.glob("*.patch")))
281
+
282
+ def test_fix_preview_rejects_an_apply_flag(self) -> None:
283
+ result = self._call("fix_preview", {"target": ".", "apply": True})
284
+ self.assertTrue(result["isError"])
285
+
286
+ def test_scan_writes_an_audit_and_keeps_protocol_stdout_clean(self) -> None:
287
+ with tempfile.TemporaryDirectory() as tmp:
288
+ out = Path(tmp) / "out"
289
+ result = self._call(
290
+ "scan",
291
+ {"target": str(FIXTURE), "out": str(out), "format": "sarif", "lang": "en"},
292
+ )
293
+ body = result["structuredContent"]
294
+ self.assertEqual(body["exitCode"], 0, body.get("stderrTail"))
295
+ self.assertTrue((out / "findings.json").is_file())
296
+ self.assertTrue((out / "findings.sarif").is_file())
297
+ self.assertIsInstance(body["audit"]["findingsTotal"], int)
298
+ self.assertNotIn("Komagu Security Audit Summary", result["content"][0]["text"])
299
+
300
+
301
+ class TestCli(unittest.TestCase):
302
+ def test_mcp_without_stdio_is_a_usage_error_and_writes_nothing(self) -> None:
303
+ stdout = io.StringIO()
304
+ stderr = io.StringIO()
305
+ real_out, real_err = sys.stdout, sys.stderr
306
+ try:
307
+ sys.stdout, sys.stderr = stdout, stderr
308
+ code = main(["mcp"])
309
+ finally:
310
+ sys.stdout, sys.stderr = real_out, real_err
311
+ self.assertEqual(code, 2)
312
+ self.assertEqual(stdout.getvalue(), "")
313
+ self.assertIn("--stdio", stderr.getvalue())
314
+
315
+ def test_help_documents_stdio(self) -> None:
316
+ completed = subprocess.run(
317
+ [sys.executable, str(BIN_KOMAGU), "mcp", "--help"],
318
+ capture_output=True,
319
+ text=True,
320
+ check=False,
321
+ )
322
+ self.assertEqual(completed.returncode, 0)
323
+ self.assertIn("--stdio", completed.stdout)
324
+ self.assertIn("dry-run", completed.stdout)
325
+
326
+ def test_stdio_process_speaks_json_only(self) -> None:
327
+ proc = subprocess.Popen(
328
+ [sys.executable, str(BIN_KOMAGU), "mcp", "--stdio"],
329
+ stdin=subprocess.PIPE,
330
+ stdout=subprocess.PIPE,
331
+ stderr=subprocess.PIPE,
332
+ text=True,
333
+ )
334
+ assert proc.stdin is not None and proc.stdout is not None and proc.stderr is not None
335
+ err = ""
336
+ try:
337
+ self._send(proc, _initialize("2024-11-05"))
338
+ first = self._recv(proc)
339
+ self.assertTrue(first.startswith("{"), first)
340
+ hello = json.loads(first)
341
+ self.assertEqual(hello["result"]["protocolVersion"], "2024-11-05")
342
+ self.assertEqual(hello["result"]["serverInfo"]["version"], __version__)
343
+ self._send(proc, {"jsonrpc": "2.0", "method": "notifications/initialized"})
344
+ self._send(proc, {"jsonrpc": "2.0", "id": 2, "method": "tools/list"})
345
+ listed = json.loads(self._recv(proc))
346
+ names = [tool["name"] for tool in listed["result"]["tools"]]
347
+ self.assertEqual(names, TOOL_NAMES)
348
+ proc.stdin.close()
349
+ proc.wait(timeout=5)
350
+ err = proc.stderr.read()
351
+ finally:
352
+ if proc.poll() is None:
353
+ proc.kill()
354
+ proc.wait(timeout=5)
355
+ proc.stdout.close()
356
+ proc.stderr.close()
357
+ if proc.stdin and not proc.stdin.closed:
358
+ proc.stdin.close()
359
+ self.assertEqual(proc.returncode, 0)
360
+ self.assertIn("stdio", err)
361
+
362
+ def _send(self, proc: subprocess.Popen, message: Dict[str, Any]) -> None:
363
+ assert proc.stdin is not None
364
+ proc.stdin.write(json.dumps(message) + "\n")
365
+ proc.stdin.flush()
366
+
367
+ def _recv(self, proc: subprocess.Popen, timeout: float = 10) -> str:
368
+ assert proc.stdout is not None
369
+ ready, _, _ = select.select([proc.stdout], [], [], timeout)
370
+ if not ready:
371
+ proc.kill()
372
+ err = proc.stderr.read() if proc.stderr is not None else ""
373
+ self.fail(f"timed out waiting for MCP output: {err}")
374
+ line = proc.stdout.readline()
375
+ self.assertTrue(line, "stdout closed")
376
+ return line
377
+
378
+
379
+ if __name__ == "__main__":
380
+ unittest.main()
@@ -0,0 +1,67 @@
1
+ #!/usr/bin/env python3
2
+ """Guards behind the Bandit B310 / B314 suppressions (#385).
3
+
4
+ Each `# nosec` added for #385 relies on a check: outbound requests only use
5
+ http(s) URLs, and SBOM manifests with a DTD are not parsed.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import shutil
12
+ import tempfile
13
+ import unittest
14
+
15
+ from operate.ai.client import LLMClient
16
+ from operate.cross_user_tester.runner import CrossUserTestRunner, TestCase
17
+ from operate.devsecops.sbom import SBOMGenerator
18
+ from operate.simulate.scenarios import MetabaseZeroDayScenario, TimesCarBreachScenario
19
+
20
+ CSPROJ = '<Project Sdk="Microsoft.NET.Sdk"><ItemGroup><PackageReference Include="Dapper" Version="2.1.28" /></ItemGroup></Project>'
21
+ CSPROJ_WITH_DTD = (
22
+ '<?xml version="1.0"?>\n<!DOCTYPE Project [<!ENTITY a "aaaaaaaaaa">]>\n'
23
+ '<Project Sdk="Microsoft.NET.Sdk"><ItemGroup><PackageReference Include="&a;" Version="1.0.0" /></ItemGroup></Project>'
24
+ )
25
+
26
+
27
+ class TestHttpOnlyUrls(unittest.TestCase):
28
+ def test_llm_client_accepts_http_and_https(self) -> None:
29
+ self.assertEqual(LLMClient("http://127.0.0.1:11434/v1").endpoint, "http://127.0.0.1:11434/v1/chat/completions")
30
+ self.assertEqual(LLMClient("https://llm.example/v1/chat/completions").endpoint, "https://llm.example/v1/chat/completions")
31
+
32
+ def test_llm_client_rejects_other_schemes(self) -> None:
33
+ for endpoint in ("file:///etc/passwd", "ftp://host/v1", "127.0.0.1:11434/v1"):
34
+ with self.subTest(endpoint=endpoint), self.assertRaises(ValueError):
35
+ LLMClient(endpoint)
36
+
37
+ def test_scenarios_reject_other_schemes(self) -> None:
38
+ for scenario in (TimesCarBreachScenario, MetabaseZeroDayScenario):
39
+ with self.subTest(scenario=scenario.__name__), self.assertRaises(ValueError):
40
+ scenario("file:///etc")
41
+
42
+ def test_bearer_runner_does_not_open_other_schemes(self) -> None:
43
+ res = CrossUserTestRunner("file://").execute_test(TestCase("t", "/etc/passwd", "GET"))
44
+ self.assertFalse(res.is_secure or res.is_breach)
45
+ self.assertEqual(res.status_code, 0)
46
+ self.assertIn("http", res.error_message or "")
47
+
48
+
49
+ class TestSbomRefusesDtd(unittest.TestCase):
50
+ def setUp(self) -> None:
51
+ self.root = tempfile.mkdtemp(prefix="komagu_sbom_dtd_")
52
+ self.addCleanup(shutil.rmtree, self.root, True)
53
+
54
+ def _scan(self, content: str) -> list:
55
+ with open(os.path.join(self.root, "Service.csproj"), "w", encoding="utf-8") as handle:
56
+ handle.write(content)
57
+ return [c.name for c in SBOMGenerator(root_dir=self.root).scan()]
58
+
59
+ def test_plain_manifest_is_parsed(self) -> None:
60
+ self.assertIn("Dapper", self._scan(CSPROJ))
61
+
62
+ def test_manifest_with_dtd_is_skipped(self) -> None:
63
+ self.assertEqual(self._scan(CSPROJ_WITH_DTD), [])
64
+
65
+
66
+ if __name__ == "__main__":
67
+ unittest.main()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hoanghainh/komagu",
3
- "version": "2.6.0",
3
+ "version": "2.7.0",
4
4
  "description": "Security-First Legacy Modernization Platform CLI (LSA Kit, IAM Perimeter, Cross-User Testing)",
5
5
  "main": "remediate/authz/nodejs/src/index.js",
6
6
  "types": "remediate/authz/nodejs/src/index.d.ts",