@hoanghainh/komagu 2.6.1 → 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 +10 -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/operate/README.md +6 -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/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/tests/test_ai_remediation.py +288 -0
- package/operate/tests/test_mcp_server.py +380 -0
- package/package.json +1 -1
- package/remediate/authz/bfla_patcher.py +319 -0
- package/remediate/authz/mass_assignment_patcher.py +188 -0
- package/remediate/authz/patcher.py +8 -0
- package/remediate/authz/tests/test_bfla_and_mass_assignment_patcher.py +213 -0
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Tests for Tier 2 AI-assisted multi-line remediation and verification gate (#394)."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import shutil
|
|
8
|
+
import sys
|
|
9
|
+
import tempfile
|
|
10
|
+
import unittest
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
14
|
+
sys.path.insert(0, str(REPO_ROOT))
|
|
15
|
+
|
|
16
|
+
from operate.ai.mock_server import MockLLMHandler, start_mock_llm_server
|
|
17
|
+
from operate.cli.main import main as cli_main
|
|
18
|
+
from operate.remediator.ai_fixer import AITier2Fixer
|
|
19
|
+
from operate.remediator.runner import RemediationRunner
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class TestAITier2Fixer(unittest.TestCase):
|
|
23
|
+
"""Verify AI multi-line patching, redaction, and mandatory verification gates."""
|
|
24
|
+
|
|
25
|
+
@classmethod
|
|
26
|
+
def setUpClass(cls) -> None:
|
|
27
|
+
cls.server, cls.thread, cls.base_url = start_mock_llm_server()
|
|
28
|
+
|
|
29
|
+
@classmethod
|
|
30
|
+
def tearDownClass(cls) -> None:
|
|
31
|
+
cls.server.shutdown()
|
|
32
|
+
cls.server.server_close()
|
|
33
|
+
|
|
34
|
+
def setUp(self) -> None:
|
|
35
|
+
self.temp_dir = Path(tempfile.mkdtemp(prefix="komagu_ai_fix_test_")).resolve()
|
|
36
|
+
MockLLMHandler.response_generator = None
|
|
37
|
+
MockLLMHandler.received_requests = []
|
|
38
|
+
|
|
39
|
+
def tearDown(self) -> None:
|
|
40
|
+
shutil.rmtree(self.temp_dir, ignore_errors=True)
|
|
41
|
+
|
|
42
|
+
def test_java_legacy_multiline_sqli_fix_and_secret_redaction(self) -> None:
|
|
43
|
+
java_file = self.temp_dir / "OrderDao.java"
|
|
44
|
+
java_file.write_text(
|
|
45
|
+
"import java.sql.*;\n"
|
|
46
|
+
"public class OrderDao {\n"
|
|
47
|
+
' private String dbPassword = "SuperSecretPassword99";\n'
|
|
48
|
+
" public ResultSet find(Connection conn, String id) throws SQLException {\n"
|
|
49
|
+
" Statement st = conn.createStatement();\n"
|
|
50
|
+
' return st.executeQuery("SELECT * FROM orders WHERE id = \'" + id + "\'");\n'
|
|
51
|
+
" }\n"
|
|
52
|
+
"}\n",
|
|
53
|
+
encoding="utf-8",
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
def reply(messages, model):
|
|
57
|
+
return json.dumps(
|
|
58
|
+
{
|
|
59
|
+
"start_line": 5,
|
|
60
|
+
"end_line": 6,
|
|
61
|
+
"patched_snippet": (
|
|
62
|
+
' PreparedStatement ps = conn.prepareStatement("SELECT * FROM orders WHERE id = ?");\n'
|
|
63
|
+
" ps.setString(1, id);\n"
|
|
64
|
+
" return ps.executeQuery();\n"
|
|
65
|
+
),
|
|
66
|
+
"explanation": "Replaced Statement concatenation with PreparedStatement.",
|
|
67
|
+
}
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
MockLLMHandler.response_generator = reply
|
|
71
|
+
|
|
72
|
+
findings_path = self.temp_dir / "findings.json"
|
|
73
|
+
findings_path.write_text(
|
|
74
|
+
json.dumps(
|
|
75
|
+
{
|
|
76
|
+
"findings": [
|
|
77
|
+
{
|
|
78
|
+
"finding_id": "INJ-JAVA-01",
|
|
79
|
+
"category": "sql_injection",
|
|
80
|
+
"cwe": "CWE-89",
|
|
81
|
+
"title": "Dynamic SQL in OrderDao",
|
|
82
|
+
"location": {"file": "OrderDao.java", "start_line": 6},
|
|
83
|
+
}
|
|
84
|
+
]
|
|
85
|
+
}
|
|
86
|
+
),
|
|
87
|
+
encoding="utf-8",
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
fixer = AITier2Fixer(endpoint=self.base_url, model="mock-model")
|
|
91
|
+
runner = RemediationRunner(
|
|
92
|
+
target_dir=self.temp_dir,
|
|
93
|
+
findings_path=findings_path,
|
|
94
|
+
dry_run=True,
|
|
95
|
+
ai_fixer=fixer,
|
|
96
|
+
)
|
|
97
|
+
report = runner.run()
|
|
98
|
+
|
|
99
|
+
self.assertEqual(report.planned_count, 1)
|
|
100
|
+
plan = report.plans[0]
|
|
101
|
+
self.assertIn("[AI-GENERATED — REQUIRES PERSON B REVIEW]", plan.reason)
|
|
102
|
+
self.assertIn("PreparedStatement ps = conn.prepareStatement", plan.patched_content)
|
|
103
|
+
|
|
104
|
+
# Ensure secret was redacted before leaving the host
|
|
105
|
+
sent_user_msg = MockLLMHandler.received_requests[-1]["messages"][1]["content"]
|
|
106
|
+
self.assertNotIn("SuperSecretPassword99", sent_user_msg)
|
|
107
|
+
self.assertIn("[REDACTED]", sent_user_msg)
|
|
108
|
+
|
|
109
|
+
def test_php_legacy_multiline_sqli_fix(self) -> None:
|
|
110
|
+
php_file = self.temp_dir / "orders.php"
|
|
111
|
+
php_file.write_text(
|
|
112
|
+
"<?php\n"
|
|
113
|
+
"$id = $_GET['id'];\n"
|
|
114
|
+
'$res = mysql_query("SELECT * FROM orders WHERE id = " . $id);\n',
|
|
115
|
+
encoding="utf-8",
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
def reply(messages, model):
|
|
119
|
+
return json.dumps(
|
|
120
|
+
{
|
|
121
|
+
"start_line": 3,
|
|
122
|
+
"end_line": 3,
|
|
123
|
+
"patched_snippet": (
|
|
124
|
+
'$stmt = $pdo->prepare("SELECT * FROM orders WHERE id = ?");\n'
|
|
125
|
+
"$stmt->execute([$id]);\n"
|
|
126
|
+
"$res = $stmt->fetchAll();\n"
|
|
127
|
+
),
|
|
128
|
+
"explanation": "Migrated mysql_query concatenation to PDO prepared statement.",
|
|
129
|
+
}
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
MockLLMHandler.response_generator = reply
|
|
133
|
+
fixer = AITier2Fixer(endpoint=self.base_url, model="mock-model")
|
|
134
|
+
res = fixer.generate_verified_patch(
|
|
135
|
+
php_file,
|
|
136
|
+
php_file.read_text(encoding="utf-8"),
|
|
137
|
+
{"category": "sql_injection", "cwe": "CWE-89", "location": {"start_line": 3}},
|
|
138
|
+
)
|
|
139
|
+
self.assertTrue(res.changed)
|
|
140
|
+
self.assertTrue(res.verified)
|
|
141
|
+
self.assertIn("$pdo->prepare", res.patched_content)
|
|
142
|
+
|
|
143
|
+
def test_rejects_comment_only_patch(self) -> None:
|
|
144
|
+
java_file = self.temp_dir / "Legacy.java"
|
|
145
|
+
content = (
|
|
146
|
+
"public class Legacy {\n"
|
|
147
|
+
' String q = "SELECT * FROM users WHERE name = \'" + name + "\'";\n'
|
|
148
|
+
"}\n"
|
|
149
|
+
)
|
|
150
|
+
java_file.write_text(content, encoding="utf-8")
|
|
151
|
+
|
|
152
|
+
MockLLMHandler.response_generator = lambda m, mdl: json.dumps(
|
|
153
|
+
{
|
|
154
|
+
"start_line": 2,
|
|
155
|
+
"end_line": 2,
|
|
156
|
+
"patched_snippet": (
|
|
157
|
+
" // TODO: parameterize query\n"
|
|
158
|
+
' String q = "SELECT * FROM users WHERE name = \'" + name + "\'";\n'
|
|
159
|
+
),
|
|
160
|
+
"explanation": "Added comment.",
|
|
161
|
+
}
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
fixer = AITier2Fixer(endpoint=self.base_url, model="mock-model")
|
|
165
|
+
res = fixer.generate_verified_patch(
|
|
166
|
+
java_file,
|
|
167
|
+
content,
|
|
168
|
+
{"category": "sql_injection", "location": {"start_line": 2}},
|
|
169
|
+
)
|
|
170
|
+
self.assertFalse(res.changed)
|
|
171
|
+
self.assertIn("comment-only", res.explanation)
|
|
172
|
+
|
|
173
|
+
def test_rejects_python_syntax_error_patch(self) -> None:
|
|
174
|
+
py_file = self.temp_dir / "bad.py"
|
|
175
|
+
content = "def get_user(cur, uid):\n cur.execute(f'SELECT * FROM users WHERE id = {uid}')\n"
|
|
176
|
+
py_file.write_text(content, encoding="utf-8")
|
|
177
|
+
|
|
178
|
+
MockLLMHandler.response_generator = lambda m, mdl: json.dumps(
|
|
179
|
+
{
|
|
180
|
+
"start_line": 2,
|
|
181
|
+
"end_line": 2,
|
|
182
|
+
"patched_snippet": " cur.execute('SELECT * FROM users WHERE id = %s', (uid,)\n", # missing ')'
|
|
183
|
+
"explanation": "Broken syntax.",
|
|
184
|
+
}
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
fixer = AITier2Fixer(endpoint=self.base_url, model="mock-model")
|
|
188
|
+
res = fixer.generate_verified_patch(
|
|
189
|
+
py_file,
|
|
190
|
+
content,
|
|
191
|
+
{"category": "sql_injection", "location": {"start_line": 2}},
|
|
192
|
+
)
|
|
193
|
+
self.assertFalse(res.changed)
|
|
194
|
+
self.assertIn("py_compile failed", res.explanation)
|
|
195
|
+
|
|
196
|
+
def test_rejects_patch_that_leaves_injection_sink_intact(self) -> None:
|
|
197
|
+
java_file = self.temp_dir / "StillBad.java"
|
|
198
|
+
content = (
|
|
199
|
+
"public class StillBad {\n"
|
|
200
|
+
" void run(Statement st, String id) {\n"
|
|
201
|
+
' st.executeQuery("SELECT * FROM t WHERE id = " + id);\n'
|
|
202
|
+
" }\n"
|
|
203
|
+
"}\n"
|
|
204
|
+
)
|
|
205
|
+
java_file.write_text(content, encoding="utf-8")
|
|
206
|
+
|
|
207
|
+
MockLLMHandler.response_generator = lambda m, mdl: json.dumps(
|
|
208
|
+
{
|
|
209
|
+
"start_line": 3,
|
|
210
|
+
"end_line": 3,
|
|
211
|
+
"patched_snippet": ' st.executeQuery("SELECT * FROM t WHERE id = " + id.trim());\n',
|
|
212
|
+
"explanation": "Trimmed input but still concatenated.",
|
|
213
|
+
}
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
fixer = AITier2Fixer(endpoint=self.base_url, model="mock-model")
|
|
217
|
+
res = fixer.generate_verified_patch(
|
|
218
|
+
java_file,
|
|
219
|
+
content,
|
|
220
|
+
{"category": "sql_injection", "location": {"start_line": 3}},
|
|
221
|
+
)
|
|
222
|
+
self.assertFalse(res.changed)
|
|
223
|
+
self.assertIn("Tier 1 re-scan still flags", res.explanation)
|
|
224
|
+
|
|
225
|
+
def test_cli_fix_with_ai_flag_end_to_end(self) -> None:
|
|
226
|
+
java_file = self.temp_dir / "App.java"
|
|
227
|
+
java_file.write_text(
|
|
228
|
+
"import java.sql.*;\n"
|
|
229
|
+
"public class App {\n"
|
|
230
|
+
" void run(Connection c, String id) throws SQLException {\n"
|
|
231
|
+
" Statement st = c.createStatement();\n"
|
|
232
|
+
' st.executeQuery("SELECT * FROM items WHERE id = " + id);\n'
|
|
233
|
+
" }\n"
|
|
234
|
+
"}\n",
|
|
235
|
+
encoding="utf-8",
|
|
236
|
+
)
|
|
237
|
+
findings_path = self.temp_dir / "findings.json"
|
|
238
|
+
findings_path.write_text(
|
|
239
|
+
json.dumps(
|
|
240
|
+
{
|
|
241
|
+
"findings": [
|
|
242
|
+
{
|
|
243
|
+
"finding_id": "INJ-01",
|
|
244
|
+
"category": "sql_injection",
|
|
245
|
+
"cwe": "CWE-89",
|
|
246
|
+
"location": {"file": "App.java", "start_line": 5},
|
|
247
|
+
}
|
|
248
|
+
]
|
|
249
|
+
}
|
|
250
|
+
),
|
|
251
|
+
encoding="utf-8",
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
MockLLMHandler.response_generator = lambda m, mdl: json.dumps(
|
|
255
|
+
{
|
|
256
|
+
"start_line": 4,
|
|
257
|
+
"end_line": 5,
|
|
258
|
+
"patched_snippet": (
|
|
259
|
+
' PreparedStatement ps = c.prepareStatement("SELECT * FROM items WHERE id = ?");\n'
|
|
260
|
+
" ps.setString(1, id);\n"
|
|
261
|
+
" ps.executeQuery();\n"
|
|
262
|
+
),
|
|
263
|
+
"explanation": "Use PreparedStatement.",
|
|
264
|
+
}
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
exit_code = cli_main(
|
|
268
|
+
[
|
|
269
|
+
"--no-banner",
|
|
270
|
+
"fix",
|
|
271
|
+
str(self.temp_dir),
|
|
272
|
+
"--findings",
|
|
273
|
+
str(findings_path),
|
|
274
|
+
"--dry-run",
|
|
275
|
+
"--ai",
|
|
276
|
+
"--ai-endpoint",
|
|
277
|
+
self.base_url,
|
|
278
|
+
"--ai-model",
|
|
279
|
+
"mock-model",
|
|
280
|
+
"--format",
|
|
281
|
+
"json",
|
|
282
|
+
]
|
|
283
|
+
)
|
|
284
|
+
self.assertEqual(exit_code, 0)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
if __name__ == "__main__":
|
|
288
|
+
unittest.main()
|
|
@@ -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()
|
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",
|