@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
package/examples/member-portal/backend-spring/src/main/kotlin/io/komagu/portal/model/Entities.kt
CHANGED
|
@@ -8,6 +8,8 @@ import java.math.BigDecimal
|
|
|
8
8
|
import java.time.Instant
|
|
9
9
|
import java.util.UUID
|
|
10
10
|
|
|
11
|
+
// JPA entity: one constructor parameter per column.
|
|
12
|
+
@Suppress("LongParameterList")
|
|
11
13
|
@Entity
|
|
12
14
|
@Table(name = "portal_orders")
|
|
13
15
|
class OrderEntity(
|
|
@@ -21,6 +23,8 @@ class OrderEntity(
|
|
|
21
23
|
var createdAt: Instant = Instant.now()
|
|
22
24
|
) : OwnedResource<String>
|
|
23
25
|
|
|
26
|
+
// JPA entity: one constructor parameter per column.
|
|
27
|
+
@Suppress("LongParameterList")
|
|
24
28
|
@Entity
|
|
25
29
|
@Table(name = "portal_documents")
|
|
26
30
|
class DocumentEntity(
|
|
@@ -86,7 +86,7 @@ class TestDemoAppVerification(unittest.TestCase):
|
|
|
86
86
|
health_url = f"{cls.base_url}/api/v1/auth/personas"
|
|
87
87
|
for _ in range(50):
|
|
88
88
|
try:
|
|
89
|
-
with urllib.request.urlopen(health_url, timeout=1.0) as resp:
|
|
89
|
+
with urllib.request.urlopen(health_url, timeout=1.0) as resp: # nosec B310 # http://127.0.0.1 test server
|
|
90
90
|
if resp.status == 200:
|
|
91
91
|
ready = True
|
|
92
92
|
break
|
|
@@ -124,7 +124,7 @@ class TestDemoAppVerification(unittest.TestCase):
|
|
|
124
124
|
url,
|
|
125
125
|
headers={"Authorization": "Bearer token-user-a"},
|
|
126
126
|
)
|
|
127
|
-
with urllib.request.urlopen(req, timeout=5) as resp:
|
|
127
|
+
with urllib.request.urlopen(req, timeout=5) as resp: # nosec B310 # http://127.0.0.1 test server
|
|
128
128
|
self.assertEqual(resp.status, 200)
|
|
129
129
|
data = json.loads(resp.read().decode("utf-8"))
|
|
130
130
|
self.assertEqual(data["id"], "ord-001")
|
|
@@ -188,7 +188,7 @@ class TestDemoAppVerification(unittest.TestCase):
|
|
|
188
188
|
headers={"Authorization": "Bearer token-user-c"},
|
|
189
189
|
)
|
|
190
190
|
with self.assertRaises(urllib.error.HTTPError) as ctx:
|
|
191
|
-
urllib.request.urlopen(req, timeout=5)
|
|
191
|
+
urllib.request.urlopen(req, timeout=5) # nosec B310 # http://127.0.0.1 test server
|
|
192
192
|
self.assertEqual(ctx.exception.code, 404)
|
|
193
193
|
|
|
194
194
|
def test_05_safe_sort_protects_against_sqli(self) -> None:
|
|
@@ -199,7 +199,7 @@ class TestDemoAppVerification(unittest.TestCase):
|
|
|
199
199
|
headers={"Authorization": "Bearer token-user-a"},
|
|
200
200
|
)
|
|
201
201
|
with self.assertRaises(urllib.error.HTTPError) as ctx:
|
|
202
|
-
urllib.request.urlopen(req, timeout=5)
|
|
202
|
+
urllib.request.urlopen(req, timeout=5) # nosec B310 # http://127.0.0.1 test server
|
|
203
203
|
# Server must reject with 400 or 500 SecurityException, not 200
|
|
204
204
|
self.assertIn(ctx.exception.code, [400, 500])
|
|
205
205
|
|
|
@@ -88,7 +88,7 @@ class TestRemediationVerification(unittest.TestCase):
|
|
|
88
88
|
health_url = f"{cls.base_url}/api/v1/auth/personas"
|
|
89
89
|
for _ in range(50):
|
|
90
90
|
try:
|
|
91
|
-
with urllib.request.urlopen(health_url, timeout=1.0) as resp:
|
|
91
|
+
with urllib.request.urlopen(health_url, timeout=1.0) as resp: # nosec B310 # http://127.0.0.1 test server
|
|
92
92
|
if resp.status == 200:
|
|
93
93
|
ready = True
|
|
94
94
|
break
|
|
@@ -126,7 +126,7 @@ class TestRemediationVerification(unittest.TestCase):
|
|
|
126
126
|
url,
|
|
127
127
|
headers={"Authorization": "Bearer token-user-a"},
|
|
128
128
|
)
|
|
129
|
-
with urllib.request.urlopen(req, timeout=5) as resp:
|
|
129
|
+
with urllib.request.urlopen(req, timeout=5) as resp: # nosec B310 # http://127.0.0.1 test server
|
|
130
130
|
self.assertEqual(resp.status, 200)
|
|
131
131
|
data = json.loads(resp.read().decode("utf-8"))
|
|
132
132
|
self.assertEqual(data["id"], "ord-001")
|
|
@@ -169,7 +169,7 @@ class TestRemediationVerification(unittest.TestCase):
|
|
|
169
169
|
headers={"Authorization": "Bearer token-user-b"},
|
|
170
170
|
)
|
|
171
171
|
with self.assertRaises(urllib.error.HTTPError) as ctx:
|
|
172
|
-
urllib.request.urlopen(req, timeout=5)
|
|
172
|
+
urllib.request.urlopen(req, timeout=5) # nosec B310 # http://127.0.0.1 test server
|
|
173
173
|
self.assertEqual(ctx.exception.code, 404)
|
|
174
174
|
|
|
175
175
|
def test_04_attacker_direct_put_unauthorized_returns_404(self) -> None:
|
|
@@ -186,7 +186,7 @@ class TestRemediationVerification(unittest.TestCase):
|
|
|
186
186
|
method="PUT",
|
|
187
187
|
)
|
|
188
188
|
with self.assertRaises(urllib.error.HTTPError) as ctx:
|
|
189
|
-
urllib.request.urlopen(req, timeout=5)
|
|
189
|
+
urllib.request.urlopen(req, timeout=5) # nosec B310 # http://127.0.0.1 test server
|
|
190
190
|
self.assertEqual(ctx.exception.code, 404)
|
|
191
191
|
|
|
192
192
|
def test_05_attacker_direct_delete_unauthorized_returns_404(self) -> None:
|
|
@@ -198,7 +198,7 @@ class TestRemediationVerification(unittest.TestCase):
|
|
|
198
198
|
method="DELETE",
|
|
199
199
|
)
|
|
200
200
|
with self.assertRaises(urllib.error.HTTPError) as ctx:
|
|
201
|
-
urllib.request.urlopen(req, timeout=5)
|
|
201
|
+
urllib.request.urlopen(req, timeout=5) # nosec B310 # http://127.0.0.1 test server
|
|
202
202
|
self.assertEqual(ctx.exception.code, 404)
|
|
203
203
|
|
|
204
204
|
def test_06_parameterized_search_neutralizes_sqli(self) -> None:
|
|
@@ -210,7 +210,7 @@ class TestRemediationVerification(unittest.TestCase):
|
|
|
210
210
|
url,
|
|
211
211
|
headers={"Authorization": "Bearer token-user-a"},
|
|
212
212
|
)
|
|
213
|
-
with urllib.request.urlopen(req, timeout=5) as resp:
|
|
213
|
+
with urllib.request.urlopen(req, timeout=5) as resp: # nosec B310 # http://127.0.0.1 test server
|
|
214
214
|
self.assertEqual(resp.status, 200)
|
|
215
215
|
data = json.loads(resp.read().decode("utf-8"))
|
|
216
216
|
# When safely parameterized as LIKE '%...%', it searches for the literal string and matches nothing
|
package/operate/README.md
CHANGED
|
@@ -22,7 +22,12 @@ This directory provides CI/CD components for automated security verification, pr
|
|
|
22
22
|
- `github/cross-user-authz.yml`: Reusable GitHub Actions workflow.
|
|
23
23
|
- `gitlab/cross-user-authz.gitlab-ci.yml`: GitLab CI security template.
|
|
24
24
|
|
|
25
|
-
4. **
|
|
25
|
+
4. **MCP server (`operate/mcp/`, `komagu mcp --stdio`)**:
|
|
26
|
+
- Speaks the Model Context Protocol on stdin/stdout with the Python standard library only (no MCP SDK).
|
|
27
|
+
- Tools stay local: Tier 1 `scan`, `read_audit`, `leak_check`, `inventory`, and `fix_preview`.
|
|
28
|
+
- `fix_preview` is a dry run and does not modify source. Patch apply, the edge gateway, and live cross-user tests stay on the CLI.
|
|
29
|
+
|
|
30
|
+
5. **Automated Remediation Runner (`remediator/runner.py` / `komagu fix`)**:
|
|
26
31
|
- Parses audit findings (`findings.json` or SARIF) and generates automated code fixes for BOLA/IDOR and SQLi vulnerabilities.
|
|
27
32
|
- Supports dry-run simulation, unified patch export, and direct in-place remediation.
|
|
28
33
|
|
package/operate/ai/client.py
CHANGED
|
@@ -9,6 +9,7 @@ import json
|
|
|
9
9
|
import re
|
|
10
10
|
import time
|
|
11
11
|
import urllib.error
|
|
12
|
+
import urllib.parse
|
|
12
13
|
import urllib.request
|
|
13
14
|
from typing import Any, Dict, List, Optional, Tuple
|
|
14
15
|
|
|
@@ -29,6 +30,8 @@ class LLMClient:
|
|
|
29
30
|
) -> None:
|
|
30
31
|
if reasoning_effort is not None and reasoning_effort not in REASONING_EFFORTS:
|
|
31
32
|
raise ValueError(f"reasoning_effort must be one of {', '.join(REASONING_EFFORTS)}")
|
|
33
|
+
if urllib.parse.urlparse(endpoint).scheme.lower() not in ("http", "https"):
|
|
34
|
+
raise ValueError("LLM endpoint must be an http:// or https:// URL")
|
|
32
35
|
raw_endpoint = endpoint.rstrip("/")
|
|
33
36
|
if not raw_endpoint.endswith("/chat/completions"):
|
|
34
37
|
self.endpoint = f"{raw_endpoint}/chat/completions"
|
|
@@ -58,7 +61,8 @@ class LLMClient:
|
|
|
58
61
|
req = urllib.request.Request(self.endpoint, data=data, headers=headers, method="POST")
|
|
59
62
|
started = time.monotonic()
|
|
60
63
|
try:
|
|
61
|
-
|
|
64
|
+
# The endpoint scheme is checked to be http(s) in __init__.
|
|
65
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as resp: # nosec B310
|
|
62
66
|
body = resp.read().decode("utf-8")
|
|
63
67
|
res_json = json.loads(body)
|
|
64
68
|
self._record_usage(res_json.get("usage") or {}, time.monotonic() - started)
|
package/operate/cli/__init__.py
CHANGED
|
@@ -4,10 +4,14 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import argparse
|
|
6
6
|
import json
|
|
7
|
+
import os
|
|
7
8
|
import sys
|
|
8
9
|
from pathlib import Path
|
|
9
10
|
from typing import Any
|
|
10
11
|
|
|
12
|
+
from operate.ai.client import REASONING_EFFORTS
|
|
13
|
+
from operate.remediator.ai_fixer import AITier2Fixer
|
|
14
|
+
|
|
11
15
|
from operate.cli.ui import (
|
|
12
16
|
BOLD,
|
|
13
17
|
CYAN,
|
|
@@ -89,6 +93,38 @@ Examples:
|
|
|
89
93
|
default="summary",
|
|
90
94
|
help="Output format: summary table (default), json, or diff",
|
|
91
95
|
)
|
|
96
|
+
parser.add_argument(
|
|
97
|
+
"--ai",
|
|
98
|
+
action="store_true",
|
|
99
|
+
help="Enable Tier 2 AI-assisted multi-line fix fallback with verification gate when deterministic patchers cannot rewrite a finding",
|
|
100
|
+
)
|
|
101
|
+
parser.add_argument(
|
|
102
|
+
"--ai-endpoint",
|
|
103
|
+
default=os.environ.get("KOMAGU_AI_ENDPOINT", "http://localhost:11434/v1"),
|
|
104
|
+
help="OpenAI-compatible /v1 endpoint for --ai fixes (default: $KOMAGU_AI_ENDPOINT or http://localhost:11434/v1)",
|
|
105
|
+
)
|
|
106
|
+
parser.add_argument(
|
|
107
|
+
"--ai-model",
|
|
108
|
+
default=os.environ.get("KOMAGU_AI_MODEL", "llama3.1"),
|
|
109
|
+
help="Model name for --ai fixes (default: $KOMAGU_AI_MODEL or llama3.1)",
|
|
110
|
+
)
|
|
111
|
+
parser.add_argument(
|
|
112
|
+
"--ai-api-key",
|
|
113
|
+
default=os.environ.get("KOMAGU_AI_API_KEY") or os.environ.get("OPENAI_API_KEY"),
|
|
114
|
+
help="API key for --ai endpoint (default: $KOMAGU_AI_API_KEY or $OPENAI_API_KEY)",
|
|
115
|
+
)
|
|
116
|
+
parser.add_argument(
|
|
117
|
+
"--ai-timeout",
|
|
118
|
+
type=int,
|
|
119
|
+
default=120,
|
|
120
|
+
help="Timeout in seconds per --ai LLM request (default: 120)",
|
|
121
|
+
)
|
|
122
|
+
parser.add_argument(
|
|
123
|
+
"--ai-reasoning-effort",
|
|
124
|
+
choices=REASONING_EFFORTS,
|
|
125
|
+
default=None,
|
|
126
|
+
help="Reasoning effort passed to OpenAI-compatible endpoint (default: not sent)",
|
|
127
|
+
)
|
|
92
128
|
parser.set_defaults(func=run_fix)
|
|
93
129
|
|
|
94
130
|
|
|
@@ -100,6 +136,16 @@ def run_fix(args: argparse.Namespace) -> int:
|
|
|
100
136
|
step_fail(f"Target path does not exist: {target_path}")
|
|
101
137
|
return 1
|
|
102
138
|
|
|
139
|
+
ai_fixer = None
|
|
140
|
+
if getattr(args, "ai", False):
|
|
141
|
+
ai_fixer = AITier2Fixer(
|
|
142
|
+
endpoint=args.ai_endpoint,
|
|
143
|
+
model=args.ai_model,
|
|
144
|
+
api_key=args.ai_api_key,
|
|
145
|
+
timeout=args.ai_timeout,
|
|
146
|
+
reasoning_effort=args.ai_reasoning_effort,
|
|
147
|
+
)
|
|
148
|
+
|
|
103
149
|
runner = RemediationRunner(
|
|
104
150
|
target_dir=target_path,
|
|
105
151
|
findings_path=args.findings_path,
|
|
@@ -107,6 +153,7 @@ def run_fix(args: argparse.Namespace) -> int:
|
|
|
107
153
|
output_patch=args.output_patch,
|
|
108
154
|
scan_sca=getattr(args, "sca", False),
|
|
109
155
|
query_osv=getattr(args, "osv", False),
|
|
156
|
+
ai_fixer=ai_fixer,
|
|
110
157
|
)
|
|
111
158
|
|
|
112
159
|
mode_label = "DRY-RUN (Simulated)" if args.dry_run else "APPLY (In-place)"
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Command: komagu mcp - zero-dependency Model Context Protocol server."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def register_subcommand(subparsers: argparse._SubParsersAction) -> None:
|
|
10
|
+
"""Register the mcp subcommand parser."""
|
|
11
|
+
parser = subparsers.add_parser(
|
|
12
|
+
"mcp",
|
|
13
|
+
help="Serve Komagu analysis tools over the Model Context Protocol on stdio",
|
|
14
|
+
description=(
|
|
15
|
+
"Speaks MCP JSON-RPC on stdin and stdout using the Python standard library only. "
|
|
16
|
+
"Tools are local: Tier 1 scan, audit summary, leak check, API inventory, and a dry-run fix preview. "
|
|
17
|
+
"The preview never modifies source. Logging is written to stderr so stdout stays a protocol stream."
|
|
18
|
+
),
|
|
19
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
20
|
+
epilog="""
|
|
21
|
+
Examples:
|
|
22
|
+
# Cursor, Claude Code, or any MCP client that launches a stdio server
|
|
23
|
+
komagu mcp --stdio
|
|
24
|
+
|
|
25
|
+
Client configuration:
|
|
26
|
+
{
|
|
27
|
+
"mcpServers": {
|
|
28
|
+
"komagu": {
|
|
29
|
+
"command": "komagu",
|
|
30
|
+
"args": ["mcp", "--stdio"]
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
""",
|
|
35
|
+
)
|
|
36
|
+
parser.add_argument(
|
|
37
|
+
"--stdio",
|
|
38
|
+
action="store_true",
|
|
39
|
+
help="Speak newline-delimited MCP JSON-RPC on stdin/stdout (the only transport)",
|
|
40
|
+
)
|
|
41
|
+
parser.set_defaults(func=run_mcp)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def run_mcp(args: argparse.Namespace) -> int:
|
|
45
|
+
"""Start the stdio server, or reject the command when --stdio was omitted."""
|
|
46
|
+
if not getattr(args, "stdio", False):
|
|
47
|
+
print(
|
|
48
|
+
"komagu mcp: the only transport is stdio. Run: komagu mcp --stdio",
|
|
49
|
+
file=sys.stderr,
|
|
50
|
+
)
|
|
51
|
+
return 2
|
|
52
|
+
from operate.mcp.server import serve_stdio
|
|
53
|
+
|
|
54
|
+
return serve_stdio()
|
package/operate/cli/main.py
CHANGED
|
@@ -7,7 +7,20 @@ import sys
|
|
|
7
7
|
from typing import List, Optional
|
|
8
8
|
|
|
9
9
|
from operate.cli import __version__
|
|
10
|
-
from operate.cli.commands import
|
|
10
|
+
from operate.cli.commands import (
|
|
11
|
+
demo,
|
|
12
|
+
devsecops,
|
|
13
|
+
evidence,
|
|
14
|
+
fix,
|
|
15
|
+
init,
|
|
16
|
+
inventory,
|
|
17
|
+
leak_check,
|
|
18
|
+
mcp,
|
|
19
|
+
protect,
|
|
20
|
+
scan,
|
|
21
|
+
simulate,
|
|
22
|
+
test,
|
|
23
|
+
)
|
|
11
24
|
from operate.cli.ui import print_banner
|
|
12
25
|
|
|
13
26
|
|
|
@@ -30,6 +43,7 @@ Quick Start Workflow:
|
|
|
30
43
|
9. Reconcile API inventory: komagu inventory --routes OUT/routes.json --openapi openapi.json
|
|
31
44
|
10. Bundle METI SCS evidence: komagu evidence --audit-dir OUT/ --output-dir ./evidence-pack
|
|
32
45
|
11. Check outputs for leaks: komagu leak-check OUT/ --canary-file canaries.txt
|
|
46
|
+
12. Serve tools to an AI client: komagu mcp --stdio
|
|
33
47
|
|
|
34
48
|
Documentation & Guides:
|
|
35
49
|
Comprehensive User Guide: docs/cli-guide.md
|
|
@@ -68,6 +82,7 @@ Learn more about a specific command:
|
|
|
68
82
|
inventory.register_subcommand(subparsers)
|
|
69
83
|
evidence.register_subcommand(subparsers)
|
|
70
84
|
leak_check.register_subcommand(subparsers)
|
|
85
|
+
mcp.register_subcommand(subparsers)
|
|
71
86
|
|
|
72
87
|
help_parser = subparsers.add_parser(
|
|
73
88
|
"help",
|
|
@@ -77,8 +92,24 @@ Learn more about a specific command:
|
|
|
77
92
|
help_parser.add_argument(
|
|
78
93
|
"topic",
|
|
79
94
|
nargs="?",
|
|
80
|
-
choices=[
|
|
81
|
-
|
|
95
|
+
choices=[
|
|
96
|
+
"scan",
|
|
97
|
+
"protect",
|
|
98
|
+
"test",
|
|
99
|
+
"demo",
|
|
100
|
+
"devsecops",
|
|
101
|
+
"fix",
|
|
102
|
+
"init",
|
|
103
|
+
"simulate",
|
|
104
|
+
"inventory",
|
|
105
|
+
"evidence",
|
|
106
|
+
"leak-check",
|
|
107
|
+
"mcp",
|
|
108
|
+
],
|
|
109
|
+
help=(
|
|
110
|
+
"Subcommand topic to display help for "
|
|
111
|
+
"(scan, protect, test, demo, devsecops, fix, init, simulate, inventory, evidence, leak-check, mcp)"
|
|
112
|
+
),
|
|
82
113
|
)
|
|
83
114
|
|
|
84
115
|
return parser
|
|
@@ -90,7 +121,14 @@ def main(argv: Optional[List[str]] = None) -> int:
|
|
|
90
121
|
args = parser.parse_args(argv)
|
|
91
122
|
|
|
92
123
|
is_json = getattr(args, "format", "") == "json"
|
|
93
|
-
|
|
124
|
+
# The MCP server owns stdout. A banner would break the JSON-RPC stream.
|
|
125
|
+
suppress_banner = getattr(args, "subcommand", None) == "mcp"
|
|
126
|
+
if (
|
|
127
|
+
not getattr(args, "no_banner", False)
|
|
128
|
+
and getattr(args, "subcommand", None) is not None
|
|
129
|
+
and not is_json
|
|
130
|
+
and not suppress_banner
|
|
131
|
+
):
|
|
94
132
|
print_banner()
|
|
95
133
|
|
|
96
134
|
if getattr(args, "subcommand", None) is None:
|
|
@@ -242,7 +242,10 @@ class CrossUserTestRunner:
|
|
|
242
242
|
error_message = None
|
|
243
243
|
|
|
244
244
|
try:
|
|
245
|
-
|
|
245
|
+
if urllib.parse.urlparse(url).scheme.lower() not in ("http", "https"):
|
|
246
|
+
raise ValueError("base URL must be an http:// or https:// URL")
|
|
247
|
+
# The scheme is checked just above.
|
|
248
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as resp: # nosec B310
|
|
246
249
|
status_code = resp.status
|
|
247
250
|
raw = resp.read()
|
|
248
251
|
response_body = raw.decode("utf-8", errors="replace")[:1000]
|
|
@@ -24,6 +24,16 @@ import xml.etree.ElementTree as ET
|
|
|
24
24
|
from typing import Any, Dict, List, Optional
|
|
25
25
|
|
|
26
26
|
|
|
27
|
+
def _parse_xml_without_dtd(file_path: str) -> ET.Element:
|
|
28
|
+
"""Parse a manifest from the scanned project, refusing DTDs (entity expansion)."""
|
|
29
|
+
with open(file_path, "r", encoding="utf-8", errors="replace") as handle:
|
|
30
|
+
text = handle.read()
|
|
31
|
+
if "<!DOCTYPE" in text.upper() or "<!ENTITY" in text.upper():
|
|
32
|
+
raise ValueError(f"{file_path}: XML with a DTD is not parsed")
|
|
33
|
+
# DTDs are refused above, so no entity can be declared or expanded.
|
|
34
|
+
return ET.fromstring(text) # nosec B314
|
|
35
|
+
|
|
36
|
+
|
|
27
37
|
class Component:
|
|
28
38
|
"""Represents a software package or component in an SBOM."""
|
|
29
39
|
|
|
@@ -195,8 +205,7 @@ class SBOMGenerator:
|
|
|
195
205
|
def _parse_csproj(self, file_path: str) -> List[Component]:
|
|
196
206
|
components = []
|
|
197
207
|
try:
|
|
198
|
-
|
|
199
|
-
root = tree.getroot()
|
|
208
|
+
root = _parse_xml_without_dtd(file_path)
|
|
200
209
|
for pkg in root.findall(".//PackageReference"):
|
|
201
210
|
name = pkg.get("Include") or pkg.get("Update")
|
|
202
211
|
version = pkg.get("Version")
|
|
@@ -213,8 +222,7 @@ class SBOMGenerator:
|
|
|
213
222
|
def _parse_pom_xml(self, file_path: str) -> List[Component]:
|
|
214
223
|
components = []
|
|
215
224
|
try:
|
|
216
|
-
|
|
217
|
-
root = tree.getroot()
|
|
225
|
+
root = _parse_xml_without_dtd(file_path)
|
|
218
226
|
ns = {"m": "http://maven.apache.org/POM/4.0.0"}
|
|
219
227
|
# Search both with namespace and without
|
|
220
228
|
deps = root.findall(".//m:dependency", ns) or root.findall(".//dependency")
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Zero-dependency Model Context Protocol server for the Komagu CLI.
|
|
2
|
+
|
|
3
|
+
Python 3.8+ standard library only. Messages are newline-delimited JSON-RPC
|
|
4
|
+
on stdio. Nothing except protocol messages is written to stdout.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from operate.mcp.server import serve_stdio
|
|
10
|
+
|
|
11
|
+
__all__ = ["serve_stdio"]
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""Newline-delimited JSON-RPC session for the Model Context Protocol.
|
|
2
|
+
|
|
3
|
+
Supported protocol revisions: 2024-11-05, 2025-03-26, and 2025-06-18.
|
|
4
|
+
A requested revision we implement is echoed back. Any other revision is
|
|
5
|
+
answered with the newest one this server implements.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import sys
|
|
12
|
+
import traceback
|
|
13
|
+
from typing import Any, Dict, IO, Optional, TextIO
|
|
14
|
+
|
|
15
|
+
from operate.cli import __version__
|
|
16
|
+
from operate.mcp.tools import INSTRUCTIONS, ToolError, ToolRegistry, build_registry
|
|
17
|
+
|
|
18
|
+
JSONRPC = "2.0"
|
|
19
|
+
PROTOCOL_VERSIONS = ("2024-11-05", "2025-03-26", "2025-06-18")
|
|
20
|
+
LATEST_PROTOCOL = PROTOCOL_VERSIONS[-1]
|
|
21
|
+
SERVER_NAME = "komagu"
|
|
22
|
+
MAX_LINE_CHARS = 1_048_576
|
|
23
|
+
TEXT_CAP = 60_000
|
|
24
|
+
|
|
25
|
+
PARSE_ERROR = -32700
|
|
26
|
+
INVALID_REQUEST = -32600
|
|
27
|
+
METHOD_NOT_FOUND = -32601
|
|
28
|
+
INVALID_PARAMS = -32602
|
|
29
|
+
INTERNAL_ERROR = -32603
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def error_response(request_id: Any, code: int, message: str) -> Dict[str, Any]:
|
|
33
|
+
"""JSON-RPC error object. request_id is null when the request id could not be read."""
|
|
34
|
+
return {"jsonrpc": JSONRPC, "id": request_id, "error": {"code": code, "message": message}}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def success_response(request_id: Any, result: Dict[str, Any]) -> Dict[str, Any]:
|
|
38
|
+
"""JSON-RPC result object."""
|
|
39
|
+
return {"jsonrpc": JSONRPC, "id": request_id, "result": result}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def tool_result(payload: Dict[str, Any], is_error: bool) -> Dict[str, Any]:
|
|
43
|
+
"""MCP tools/call result. Text is the payload the client shows the model."""
|
|
44
|
+
if is_error:
|
|
45
|
+
text = payload.get("message", "tool error")
|
|
46
|
+
return {"content": [{"type": "text", "text": text}], "isError": True}
|
|
47
|
+
text = json.dumps(payload, indent=2, ensure_ascii=False, sort_keys=True)
|
|
48
|
+
if len(text) > TEXT_CAP:
|
|
49
|
+
text = text[:TEXT_CAP] + "\n…truncated"
|
|
50
|
+
return {
|
|
51
|
+
"content": [{"type": "text", "text": text}],
|
|
52
|
+
"structuredContent": payload,
|
|
53
|
+
"isError": False,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class Session:
|
|
58
|
+
"""One stdio client. Requests other than initialize and ping wait for initialized."""
|
|
59
|
+
|
|
60
|
+
def __init__(self, registry: ToolRegistry, version: str, instructions: str) -> None:
|
|
61
|
+
self.registry = registry
|
|
62
|
+
self.version = version
|
|
63
|
+
self.instructions = instructions
|
|
64
|
+
self.initialized = False
|
|
65
|
+
self.negotiated: Optional[str] = None
|
|
66
|
+
|
|
67
|
+
def handle(self, message: Any) -> Optional[Dict[str, Any]]:
|
|
68
|
+
"""Return a response, or None when the message is a notification."""
|
|
69
|
+
if not isinstance(message, dict):
|
|
70
|
+
return error_response(None, INVALID_REQUEST, "Invalid Request")
|
|
71
|
+
|
|
72
|
+
is_request = "id" in message
|
|
73
|
+
request_id = message.get("id") if is_request else None
|
|
74
|
+
if message.get("jsonrpc") != JSONRPC:
|
|
75
|
+
return error_response(request_id, INVALID_REQUEST, "Invalid Request") if is_request else None
|
|
76
|
+
|
|
77
|
+
method = message.get("method")
|
|
78
|
+
if not isinstance(method, str) or not method:
|
|
79
|
+
return error_response(request_id, INVALID_REQUEST, "Invalid Request") if is_request else None
|
|
80
|
+
|
|
81
|
+
params = message.get("params", {})
|
|
82
|
+
if params is None:
|
|
83
|
+
params = {}
|
|
84
|
+
if not isinstance(params, dict):
|
|
85
|
+
return error_response(request_id, INVALID_PARAMS, "Invalid params") if is_request else None
|
|
86
|
+
|
|
87
|
+
if method == "initialize":
|
|
88
|
+
if not is_request:
|
|
89
|
+
return None
|
|
90
|
+
return self._initialize(request_id, params)
|
|
91
|
+
|
|
92
|
+
if method in ("notifications/initialized", "initialized"):
|
|
93
|
+
if self.negotiated is not None:
|
|
94
|
+
self.initialized = True
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
if method == "ping":
|
|
98
|
+
return success_response(request_id, {}) if is_request else None
|
|
99
|
+
|
|
100
|
+
if method.startswith("notifications/"):
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
if not is_request:
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
if not self.initialized:
|
|
107
|
+
return error_response(request_id, INVALID_REQUEST, "Server not initialized")
|
|
108
|
+
|
|
109
|
+
if method == "tools/list":
|
|
110
|
+
cursor = params.get("cursor")
|
|
111
|
+
if cursor not in (None, ""):
|
|
112
|
+
return error_response(request_id, INVALID_PARAMS, "Unknown cursor")
|
|
113
|
+
return success_response(request_id, {"tools": self.registry.descriptors()})
|
|
114
|
+
|
|
115
|
+
if method == "tools/call":
|
|
116
|
+
return self._call_tool(request_id, params)
|
|
117
|
+
|
|
118
|
+
if method == "logging/setLevel":
|
|
119
|
+
return success_response(request_id, {})
|
|
120
|
+
|
|
121
|
+
return error_response(request_id, METHOD_NOT_FOUND, f"Method not found: {method}")
|
|
122
|
+
|
|
123
|
+
def _initialize(self, request_id: Any, params: Dict[str, Any]) -> Dict[str, Any]:
|
|
124
|
+
if self.negotiated is not None:
|
|
125
|
+
return error_response(request_id, INVALID_REQUEST, "Server already initialized")
|
|
126
|
+
version = params.get("protocolVersion")
|
|
127
|
+
if not isinstance(version, str) or not version:
|
|
128
|
+
return error_response(request_id, INVALID_PARAMS, "protocolVersion is required")
|
|
129
|
+
chosen = version if version in PROTOCOL_VERSIONS else LATEST_PROTOCOL
|
|
130
|
+
self.negotiated = chosen
|
|
131
|
+
return success_response(
|
|
132
|
+
request_id,
|
|
133
|
+
{
|
|
134
|
+
"protocolVersion": chosen,
|
|
135
|
+
"capabilities": {"tools": {"listChanged": False}},
|
|
136
|
+
"serverInfo": {"name": SERVER_NAME, "version": self.version},
|
|
137
|
+
"instructions": self.instructions,
|
|
138
|
+
},
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
def _call_tool(self, request_id: Any, params: Dict[str, Any]) -> Dict[str, Any]:
|
|
142
|
+
name = params.get("name")
|
|
143
|
+
if not isinstance(name, str) or not name:
|
|
144
|
+
return error_response(request_id, INVALID_PARAMS, "tool name is required")
|
|
145
|
+
arguments = params.get("arguments", {})
|
|
146
|
+
if arguments is None:
|
|
147
|
+
arguments = {}
|
|
148
|
+
if not isinstance(arguments, dict):
|
|
149
|
+
return error_response(request_id, INVALID_PARAMS, "arguments must be an object")
|
|
150
|
+
try:
|
|
151
|
+
payload = self.registry.call(name, arguments)
|
|
152
|
+
except KeyError:
|
|
153
|
+
return error_response(request_id, INVALID_PARAMS, f"Unknown tool: {name}")
|
|
154
|
+
except ToolError as exc:
|
|
155
|
+
return success_response(request_id, tool_result({"message": str(exc)}, is_error=True))
|
|
156
|
+
except Exception as exc:
|
|
157
|
+
traceback.print_exc(file=sys.stderr)
|
|
158
|
+
return success_response(request_id, tool_result({"message": f"Tool failed: {exc}"}, is_error=True))
|
|
159
|
+
return success_response(request_id, tool_result(payload, is_error=False))
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _write(stdout: TextIO, message: Dict[str, Any]) -> None:
|
|
163
|
+
"""One JSON object per line. ensure_ascii keeps the line free of raw separators."""
|
|
164
|
+
stdout.write(json.dumps(message, ensure_ascii=True, separators=(",", ":")) + "\n")
|
|
165
|
+
flush = getattr(stdout, "flush", None)
|
|
166
|
+
if flush is not None:
|
|
167
|
+
flush()
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def serve(session: Session, stdin: IO[str], stdout: TextIO) -> None:
|
|
171
|
+
"""Read messages until stdin closes. Blank lines are ignored."""
|
|
172
|
+
while True:
|
|
173
|
+
line = stdin.readline()
|
|
174
|
+
if line == "":
|
|
175
|
+
return
|
|
176
|
+
if len(line) > MAX_LINE_CHARS:
|
|
177
|
+
_write(stdout, error_response(None, INVALID_REQUEST, "Message too large"))
|
|
178
|
+
continue
|
|
179
|
+
if not line.strip():
|
|
180
|
+
continue
|
|
181
|
+
try:
|
|
182
|
+
message = json.loads(line)
|
|
183
|
+
except json.JSONDecodeError:
|
|
184
|
+
_write(stdout, error_response(None, PARSE_ERROR, "Parse error"))
|
|
185
|
+
continue
|
|
186
|
+
if isinstance(message, list):
|
|
187
|
+
_write(stdout, error_response(None, INVALID_REQUEST, "Batch requests are not supported"))
|
|
188
|
+
continue
|
|
189
|
+
response = session.handle(message)
|
|
190
|
+
if response is not None:
|
|
191
|
+
_write(stdout, response)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def serve_stdio() -> int:
|
|
195
|
+
"""Serve on process stdin and stdout. Logs go to stderr."""
|
|
196
|
+
if hasattr(sys.stdin, "reconfigure"):
|
|
197
|
+
sys.stdin.reconfigure(encoding="utf-8")
|
|
198
|
+
if hasattr(sys.stdout, "reconfigure"):
|
|
199
|
+
sys.stdout.reconfigure(encoding="utf-8", line_buffering=True)
|
|
200
|
+
print("komagu mcp: serving Model Context Protocol on stdio", file=sys.stderr, flush=True)
|
|
201
|
+
session = Session(build_registry(), __version__, INSTRUCTIONS)
|
|
202
|
+
try:
|
|
203
|
+
serve(session, sys.stdin, sys.stdout)
|
|
204
|
+
except BrokenPipeError:
|
|
205
|
+
return 0
|
|
206
|
+
return 0
|