@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,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,89 @@
1
+ #!/usr/bin/env python3
2
+ """`.github/scripts/audit_minimum_versions.py`: audits the lowest version each requirement allows (#390)."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import importlib.util
7
+ import os
8
+ import tempfile
9
+ import unittest
10
+ from pathlib import Path
11
+ from unittest import mock
12
+
13
+ SCRIPT = Path(__file__).resolve().parents[2] / ".github" / "scripts" / "audit_minimum_versions.py"
14
+ _spec = importlib.util.spec_from_file_location("audit_minimum_versions", SCRIPT)
15
+ amv = importlib.util.module_from_spec(_spec)
16
+ _spec.loader.exec_module(amv)
17
+
18
+
19
+ def _report(*vulns: tuple) -> dict:
20
+ deps = {}
21
+ for name, version, vuln_id, fixes in vulns:
22
+ deps.setdefault((name, version), []).append({"id": vuln_id, "fix_versions": fixes})
23
+ return {"dependencies": [{"name": n, "version": v, "vulns": vs} for (n, v), vs in deps.items()]}
24
+
25
+
26
+ class TestMinimumPins(unittest.TestCase):
27
+ def test_lower_bounds_and_exact_pins_become_pins(self) -> None:
28
+ text = "# comment\npyjwt>=2.15.0\ncryptography[ssh] >= 50.0.0 # note\nrequests==2.32.3\n"
29
+ self.assertEqual(amv.minimum_pins(text), ["pyjwt==2.15.0", "cryptography==50.0.0", "requests==2.32.3"])
30
+
31
+ def test_unversioned_and_upper_bounds_are_ignored(self) -> None:
32
+ self.assertEqual(amv.minimum_pins("flask\nurllib3<3\n-r other.txt\n"), [])
33
+
34
+
35
+ class TestClassify(unittest.TestCase):
36
+ def test_fixable_and_unfixable_are_split_and_deduplicated(self) -> None:
37
+ report = _report(
38
+ ("pyjwt", "2.8.0", "PYSEC-1", ["2.12.0"]),
39
+ ("pyjwt", "2.8.0", "PYSEC-1", ["2.12.0"]),
40
+ ("pyjwt", "2.8.0", "PYSEC-2", []),
41
+ )
42
+ fixable, unfixable = amv.classify(report)
43
+ self.assertEqual(fixable, ["pyjwt 2.8.0: PYSEC-1 (fixed in 2.12.0)"])
44
+ self.assertEqual(unfixable, ["pyjwt 2.8.0: PYSEC-2 (no fixed version)"])
45
+
46
+
47
+ class TestMain(unittest.TestCase):
48
+ def setUp(self) -> None:
49
+ self.tmp = tempfile.mkdtemp(prefix="komagu_amv_")
50
+ self.req = os.path.join(self.tmp, "requirements.txt")
51
+ self.summary = os.path.join(self.tmp, "summary.md")
52
+ with open(self.req, "w", encoding="utf-8") as handle:
53
+ handle.write("pyjwt>=2.8.0\n")
54
+
55
+ def _run(self, report: dict) -> int:
56
+ with mock.patch.object(amv, "audit", return_value=report) as audit, \
57
+ mock.patch("builtins.print"):
58
+ code = amv.main(["--summary", self.summary, self.req])
59
+ audit.assert_called_once_with(["pyjwt==2.8.0"])
60
+ return code
61
+
62
+ def test_fixable_vulnerability_at_the_minimum_fails(self) -> None:
63
+ self.assertEqual(self._run(_report(("pyjwt", "2.8.0", "PYSEC-1", ["2.12.0"]))), 1)
64
+ with open(self.summary, encoding="utf-8") as handle:
65
+ self.assertIn("FAIL pyjwt 2.8.0: PYSEC-1", handle.read())
66
+
67
+ def test_vulnerability_without_a_fix_is_only_reported(self) -> None:
68
+ self.assertEqual(self._run(_report(("pyjwt", "2.8.0", "PYSEC-2", []))), 0)
69
+ with open(self.summary, encoding="utf-8") as handle:
70
+ self.assertIn("note pyjwt 2.8.0: PYSEC-2", handle.read())
71
+
72
+ def test_clean_minimum_passes(self) -> None:
73
+ self.assertEqual(self._run({"dependencies": [{"name": "pyjwt", "version": "2.8.0", "vulns": []}]}), 0)
74
+
75
+
76
+ class TestRepositoryMinimums(unittest.TestCase):
77
+ """Minimums raised in #390: these versions had fixable advisories."""
78
+
79
+ def test_raised_minimums(self) -> None:
80
+ root = SCRIPT.parents[2]
81
+ ekyc = amv.minimum_pins((root / "remediate/ekyc/requirements.txt").read_text(encoding="utf-8"))
82
+ identity = amv.minimum_pins((root / "remediate/identity/python/requirements.txt").read_text(encoding="utf-8"))
83
+ self.assertIn("cryptography==50.0.0", ekyc)
84
+ self.assertIn("cryptography==50.0.0", identity)
85
+ self.assertIn("pyjwt==2.15.0", identity)
86
+
87
+
88
+ if __name__ == "__main__":
89
+ unittest.main()
@@ -61,7 +61,7 @@ class TestKomaguCLI(unittest.TestCase):
61
61
  def test_subcommand_parser_builds_cleanly(self) -> None:
62
62
  """In-process parser must parse subcommands without throwing."""
63
63
  parser = build_parser()
64
- args_scan = parser.parse_args(["scan", "/tmp", "--format", "html"])
64
+ args_scan = parser.parse_args(["scan", "./src", "--format", "html"])
65
65
  self.assertEqual(args_scan.subcommand, "scan")
66
66
  self.assertEqual(args_scan.format, "html")
67
67