@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.
- package/CHANGELOG.md +23 -0
- package/README.md +1 -0
- package/assess/lsa/knowledge/path-ssrf-patterns.md +19 -0
- package/assess/lsa/knowledge/severity-and-cwe.md +3 -1
- package/assess/lsa/scripts/find_path_ssrf.py +176 -0
- package/assess/lsa/scripts/findings_to_sarif.py +29 -0
- package/assess/lsa/scripts/merge_findings.py +1 -1
- package/assess/lsa/scripts/run_prescan.py +4 -0
- package/assess/lsa/tests/run_tests.py +4 -0
- package/assess/lsa/tests/test_path_ssrf.py +94 -0
- package/contracts/schemas/finding.schema.json +4 -2
- package/examples/member-portal/backend-dotnet/Auth/DemoAuthenticationHandler.cs +75 -0
- package/examples/member-portal/backend-dotnet/Program.cs +1 -72
- package/examples/member-portal/backend-spring/src/main/kotlin/io/komagu/portal/Application.kt +4 -0
- package/examples/member-portal/backend-spring/src/main/kotlin/io/komagu/portal/controller/Controllers.kt +3 -0
- package/examples/member-portal/backend-spring/src/main/kotlin/io/komagu/portal/model/Entities.kt +4 -0
- package/examples/member-portal/tests/test_d6_demo_app.py +4 -4
- package/examples/member-portal/tests/test_d6_remediation_verification.py +6 -6
- package/operate/README.md +6 -1
- package/operate/ai/client.py +5 -1
- package/operate/cli/__init__.py +1 -1
- package/operate/cli/commands/fix.py +47 -0
- package/operate/cli/commands/mcp.py +54 -0
- package/operate/cli/main.py +42 -4
- package/operate/cross_user_tester/runner.py +4 -1
- package/operate/devsecops/sbom.py +12 -4
- package/operate/mcp/__init__.py +11 -0
- package/operate/mcp/server.py +206 -0
- package/operate/mcp/tools.py +464 -0
- package/operate/remediator/ai_fixer.py +285 -0
- package/operate/remediator/runner.py +11 -0
- package/operate/remediator/strategies.py +64 -0
- package/operate/simulate/scenarios.py +11 -2
- package/operate/tests/test_ai_remediation.py +288 -0
- package/operate/tests/test_audit_minimum_versions.py +89 -0
- package/operate/tests/test_cli.py +1 -1
- package/operate/tests/test_mcp_server.py +380 -0
- package/operate/tests/test_scheme_and_xml_guards.py +67 -0
- package/package.json +1 -1
- package/remediate/authz/bfla_patcher.py +319 -0
- package/remediate/authz/dotnet/src/Komagu.Security.Authorization/LoggingSecurityAuditSink.cs +19 -3
- package/remediate/authz/golang/authz_test.go +5 -2
- package/remediate/authz/mass_assignment_patcher.py +188 -0
- package/remediate/authz/patcher.py +8 -0
- package/remediate/authz/spring/src/main/kotlin/io/komagu/security/authorization/DefaultKomaguSecurityManager.kt +74 -78
- package/remediate/authz/spring/src/main/kotlin/io/komagu/security/spring/OwnershipAspect.kt +129 -158
- package/remediate/authz/tests/test_bfla_and_mass_assignment_patcher.py +213 -0
- package/remediate/deps/patcher.py +2 -1
- package/remediate/edge/mock-backend/app.py +2 -1
- package/remediate/edge/tests/test_edge_security.py +5 -5
- package/remediate/ekyc/requirements.txt +1 -1
- package/remediate/identity/dotnet/src/Komagu.Security.Identity/OidcServiceCollectionExtensions.cs +19 -6
- package/remediate/identity/python/requirements.txt +2 -2
- package/remediate/identity/spring/src/main/kotlin/io/komagu/security/identity/KomaguIdentityAutoConfiguration.kt +31 -38
- package/remediate/legacy_shims/tests/test_legacy_shims.py +8 -8
- package/remediate/query/dotnet/src/Komagu.Security.Query/SafeSortAllowList.cs +5 -2
- package/remediate/query/spring/src/main/kotlin/io/komagu/security/query/SafeSortAllowList.kt +10 -11
- 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.
|
|
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",
|