@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,464 @@
|
|
|
1
|
+
"""MCP tools that wrap local Komagu analysis. None of them apply patches or call an LLM."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import dataclasses
|
|
6
|
+
import json
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Callable, Dict, List, Optional, Tuple
|
|
11
|
+
|
|
12
|
+
from operate.devsecops.leak_check import CanaryFileError, load_canaries, scan_paths
|
|
13
|
+
from operate.inventory.api_inventory import (
|
|
14
|
+
diff_api_inventory,
|
|
15
|
+
load_code_routes,
|
|
16
|
+
load_openapi_spec,
|
|
17
|
+
load_traffic_log,
|
|
18
|
+
)
|
|
19
|
+
from operate.remediator.runner import RemediationRunner
|
|
20
|
+
|
|
21
|
+
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
22
|
+
BIN_KOMAGU = REPO_ROOT / "bin" / "komagu"
|
|
23
|
+
|
|
24
|
+
LIST_CAP = 40
|
|
25
|
+
DIFF_CAP = 1200
|
|
26
|
+
STDERR_CAP = 2000
|
|
27
|
+
MAX_PATHS = 32
|
|
28
|
+
SCAN_TIMEOUT_MAX = 600
|
|
29
|
+
LANGS = ("en", "ja", "all")
|
|
30
|
+
FORMATS = ("md", "html", "sarif", "all")
|
|
31
|
+
|
|
32
|
+
INSTRUCTIONS = (
|
|
33
|
+
"Komagu tools run on this machine and do not call an LLM. "
|
|
34
|
+
"scan writes a Tier 1 audit under out (no --headless-ai). "
|
|
35
|
+
"read_audit summarizes an existing audit directory. "
|
|
36
|
+
"leak_check looks for secrets in audit output and returns masked hits. "
|
|
37
|
+
"inventory compares code routes with an OpenAPI document. "
|
|
38
|
+
"fix_preview returns a dry-run remediation plan and never modifies source. "
|
|
39
|
+
"Applying patches, starting the edge gateway, and live cross-user tests stay on the komagu CLI."
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ToolError(Exception):
|
|
44
|
+
"""A tool argument or expected input problem the caller can correct."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class ToolSpec:
|
|
48
|
+
"""One advertised MCP tool."""
|
|
49
|
+
|
|
50
|
+
def __init__(
|
|
51
|
+
self,
|
|
52
|
+
name: str,
|
|
53
|
+
description: str,
|
|
54
|
+
input_schema: Dict[str, Any],
|
|
55
|
+
handler: Callable[[Dict[str, Any]], Dict[str, Any]],
|
|
56
|
+
read_only: bool,
|
|
57
|
+
) -> None:
|
|
58
|
+
self.name = name
|
|
59
|
+
self.description = description
|
|
60
|
+
self.input_schema = input_schema
|
|
61
|
+
self.handler = handler
|
|
62
|
+
self.read_only = read_only
|
|
63
|
+
|
|
64
|
+
def descriptor(self) -> Dict[str, Any]:
|
|
65
|
+
"""MCP tools/list entry."""
|
|
66
|
+
return {
|
|
67
|
+
"name": self.name,
|
|
68
|
+
"description": self.description,
|
|
69
|
+
"inputSchema": self.input_schema,
|
|
70
|
+
"annotations": {
|
|
71
|
+
"readOnlyHint": self.read_only,
|
|
72
|
+
"destructiveHint": False,
|
|
73
|
+
"openWorldHint": False,
|
|
74
|
+
},
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class ToolRegistry:
|
|
79
|
+
"""Name lookup for the tools this server exposes."""
|
|
80
|
+
|
|
81
|
+
def __init__(self, tools: List[ToolSpec]) -> None:
|
|
82
|
+
self._tools = {tool.name: tool for tool in tools}
|
|
83
|
+
|
|
84
|
+
def descriptors(self) -> List[Dict[str, Any]]:
|
|
85
|
+
"""Tools in a stable order."""
|
|
86
|
+
return [self._tools[name].descriptor() for name in sorted(self._tools)]
|
|
87
|
+
|
|
88
|
+
def call(self, name: str, arguments: Dict[str, Any]) -> Dict[str, Any]:
|
|
89
|
+
"""Run one tool. Unknown names raise KeyError."""
|
|
90
|
+
tool = self._tools.get(name)
|
|
91
|
+
if tool is None:
|
|
92
|
+
raise KeyError(name)
|
|
93
|
+
return tool.handler(arguments)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def build_registry() -> ToolRegistry:
|
|
97
|
+
"""The five local analysis tools."""
|
|
98
|
+
return ToolRegistry(
|
|
99
|
+
[
|
|
100
|
+
ToolSpec(
|
|
101
|
+
"scan",
|
|
102
|
+
"Run a Tier 1 Komagu audit of a local directory and write reports under out. "
|
|
103
|
+
"Does not call a model. Check exitCode: 0 finished, 2 the target or arguments were rejected. "
|
|
104
|
+
"The audit summary lists finding ids, categories, and locations, not source snippets.",
|
|
105
|
+
{
|
|
106
|
+
"type": "object",
|
|
107
|
+
"properties": {
|
|
108
|
+
"target": {"type": "string", "description": "Directory to audit."},
|
|
109
|
+
"out": {"type": "string", "description": "Directory for audit artifacts. Created if needed."},
|
|
110
|
+
"lang": {
|
|
111
|
+
"type": "string",
|
|
112
|
+
"enum": list(LANGS),
|
|
113
|
+
"description": "Report language when format renders Markdown or HTML. Default en.",
|
|
114
|
+
},
|
|
115
|
+
"format": {
|
|
116
|
+
"type": "string",
|
|
117
|
+
"enum": list(FORMATS),
|
|
118
|
+
"description": "md, html, sarif, or all. Default sarif.",
|
|
119
|
+
},
|
|
120
|
+
"timeout_seconds": {
|
|
121
|
+
"type": "integer",
|
|
122
|
+
"minimum": 1,
|
|
123
|
+
"maximum": SCAN_TIMEOUT_MAX,
|
|
124
|
+
"description": "Scan process timeout. Default 180.",
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
"required": ["target", "out"],
|
|
128
|
+
"additionalProperties": False,
|
|
129
|
+
},
|
|
130
|
+
tool_scan,
|
|
131
|
+
read_only=False,
|
|
132
|
+
),
|
|
133
|
+
ToolSpec(
|
|
134
|
+
"read_audit",
|
|
135
|
+
"Summarize an existing Komagu audit directory (stack profile, coverage, finding metadata). "
|
|
136
|
+
"Does not include source snippets.",
|
|
137
|
+
{
|
|
138
|
+
"type": "object",
|
|
139
|
+
"properties": {
|
|
140
|
+
"audit_dir": {"type": "string", "description": "Directory that contains findings.json."},
|
|
141
|
+
},
|
|
142
|
+
"required": ["audit_dir"],
|
|
143
|
+
"additionalProperties": False,
|
|
144
|
+
},
|
|
145
|
+
tool_read_audit,
|
|
146
|
+
read_only=True,
|
|
147
|
+
),
|
|
148
|
+
ToolSpec(
|
|
149
|
+
"leak_check",
|
|
150
|
+
"Scan audit output for secrets and client canary strings. Hit values are masked. "
|
|
151
|
+
"exitCode 0 is clean, 1 is a suspected leak, 2 means the scan was incomplete.",
|
|
152
|
+
{
|
|
153
|
+
"type": "object",
|
|
154
|
+
"properties": {
|
|
155
|
+
"paths": {
|
|
156
|
+
"type": "array",
|
|
157
|
+
"items": {"type": "string"},
|
|
158
|
+
"description": "Files or directories to scan. At most 32.",
|
|
159
|
+
},
|
|
160
|
+
"canary_file": {
|
|
161
|
+
"type": "string",
|
|
162
|
+
"description": "Optional UTF-8 file with one canary string per line, kept outside the repository.",
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
"required": ["paths"],
|
|
166
|
+
"additionalProperties": False,
|
|
167
|
+
},
|
|
168
|
+
tool_leak_check,
|
|
169
|
+
read_only=True,
|
|
170
|
+
),
|
|
171
|
+
ToolSpec(
|
|
172
|
+
"inventory",
|
|
173
|
+
"Compare code routes with an OpenAPI document and an optional gateway log. "
|
|
174
|
+
"Returns shadow, zombie, missing, and dormant counts. Does not write a file.",
|
|
175
|
+
{
|
|
176
|
+
"type": "object",
|
|
177
|
+
"properties": {
|
|
178
|
+
"routes": {"type": "string", "description": "LSA routes JSON file."},
|
|
179
|
+
"openapi": {"type": "string", "description": "OpenAPI JSON or YAML file."},
|
|
180
|
+
"traffic_log": {"type": "string", "description": "Optional Kong JSON-lines or NGINX combined log."},
|
|
181
|
+
},
|
|
182
|
+
"required": ["routes", "openapi"],
|
|
183
|
+
"additionalProperties": False,
|
|
184
|
+
},
|
|
185
|
+
tool_inventory,
|
|
186
|
+
read_only=True,
|
|
187
|
+
),
|
|
188
|
+
ToolSpec(
|
|
189
|
+
"fix_preview",
|
|
190
|
+
"Dry-run remediation plan for findings.json. Never writes source or a patch file. "
|
|
191
|
+
"appliedCount is always 0. Pass findings after scan, or point target at a tree that already has findings.json.",
|
|
192
|
+
{
|
|
193
|
+
"type": "object",
|
|
194
|
+
"properties": {
|
|
195
|
+
"target": {"type": "string", "description": "Code directory the findings refer to."},
|
|
196
|
+
"findings": {"type": "string", "description": "Optional findings.json or SARIF file."},
|
|
197
|
+
},
|
|
198
|
+
"required": ["target"],
|
|
199
|
+
"additionalProperties": False,
|
|
200
|
+
},
|
|
201
|
+
tool_fix_preview,
|
|
202
|
+
read_only=True,
|
|
203
|
+
),
|
|
204
|
+
]
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _check_arguments(arguments: Dict[str, Any], allowed: Tuple[str, ...], required: Tuple[str, ...]) -> None:
|
|
209
|
+
if not isinstance(arguments, dict):
|
|
210
|
+
raise ToolError("arguments must be an object")
|
|
211
|
+
unknown = sorted(set(arguments) - set(allowed))
|
|
212
|
+
if unknown:
|
|
213
|
+
raise ToolError("unknown arguments: " + ", ".join(unknown))
|
|
214
|
+
missing = [key for key in required if key not in arguments]
|
|
215
|
+
if missing:
|
|
216
|
+
raise ToolError("missing arguments: " + ", ".join(missing))
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _as_path(value: Any, label: str) -> Path:
|
|
220
|
+
if not isinstance(value, str) or not value.strip():
|
|
221
|
+
raise ToolError(f"{label} must be a non-empty string")
|
|
222
|
+
if "\x00" in value:
|
|
223
|
+
raise ToolError(f"{label} contains a null byte")
|
|
224
|
+
return Path(value).expanduser().resolve()
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _require_dir(value: Any, label: str) -> Path:
|
|
228
|
+
path = _as_path(value, label)
|
|
229
|
+
if not path.is_dir():
|
|
230
|
+
raise ToolError(f"{label} is not a directory: {path}")
|
|
231
|
+
return path
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _require_file(value: Any, label: str) -> Path:
|
|
235
|
+
path = _as_path(value, label)
|
|
236
|
+
if not path.is_file():
|
|
237
|
+
raise ToolError(f"{label} is not a file: {path}")
|
|
238
|
+
return path
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _read_json(path: Path) -> Any:
|
|
242
|
+
if not path.is_file():
|
|
243
|
+
return None
|
|
244
|
+
try:
|
|
245
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
246
|
+
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
|
|
247
|
+
return {"_error": f"{path.name}: {exc}"}
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _cap(items: Any, cap: int = LIST_CAP) -> Tuple[List[Any], bool]:
|
|
251
|
+
if not isinstance(items, list):
|
|
252
|
+
return [], False
|
|
253
|
+
return items[:cap], len(items) > cap
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def summarize_audit(audit_dir: Path) -> Dict[str, Any]:
|
|
257
|
+
"""Finding metadata and artifact names from one audit directory."""
|
|
258
|
+
artifacts = sorted(entry.name for entry in audit_dir.iterdir() if entry.is_file())
|
|
259
|
+
profile = _read_json(audit_dir / "stack-profile.json")
|
|
260
|
+
findings_doc = _read_json(audit_dir / "findings.json")
|
|
261
|
+
coverage = _read_json(audit_dir / "coverage.json")
|
|
262
|
+
|
|
263
|
+
stacks: List[Any] = []
|
|
264
|
+
if isinstance(profile, dict):
|
|
265
|
+
for stack in profile.get("stacks") or []:
|
|
266
|
+
if isinstance(stack, dict) and stack.get("id"):
|
|
267
|
+
stacks.append(stack.get("id"))
|
|
268
|
+
elif isinstance(stack, str):
|
|
269
|
+
stacks.append(stack)
|
|
270
|
+
|
|
271
|
+
findings: List[Any] = []
|
|
272
|
+
summary = None
|
|
273
|
+
if isinstance(findings_doc, dict):
|
|
274
|
+
raw = findings_doc.get("findings")
|
|
275
|
+
if isinstance(raw, list):
|
|
276
|
+
findings = raw
|
|
277
|
+
summary = findings_doc.get("summary")
|
|
278
|
+
elif isinstance(findings_doc, list):
|
|
279
|
+
findings = findings_doc
|
|
280
|
+
|
|
281
|
+
listed: List[Dict[str, Any]] = []
|
|
282
|
+
for item in findings[:LIST_CAP]:
|
|
283
|
+
if not isinstance(item, dict):
|
|
284
|
+
continue
|
|
285
|
+
location = item.get("location") if isinstance(item.get("location"), dict) else {}
|
|
286
|
+
listed.append(
|
|
287
|
+
{
|
|
288
|
+
"finding_id": item.get("finding_id"),
|
|
289
|
+
"category": item.get("category"),
|
|
290
|
+
"severity": item.get("severity"),
|
|
291
|
+
"title": item.get("title"),
|
|
292
|
+
"file": location.get("file"),
|
|
293
|
+
"start_line": location.get("start_line"),
|
|
294
|
+
"status": item.get("status"),
|
|
295
|
+
}
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
return {
|
|
299
|
+
"auditDir": str(audit_dir),
|
|
300
|
+
"artifacts": artifacts,
|
|
301
|
+
"stacks": stacks,
|
|
302
|
+
"coverage": coverage if isinstance(coverage, dict) else None,
|
|
303
|
+
"summary": summary if isinstance(summary, dict) else None,
|
|
304
|
+
"findings": listed,
|
|
305
|
+
"findingsTotal": len(findings),
|
|
306
|
+
"findingsTruncated": len(findings) > LIST_CAP,
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def tool_scan(arguments: Dict[str, Any]) -> Dict[str, Any]:
|
|
311
|
+
"""Tier 1 scan in a child process so its logs never touch the MCP stdout stream."""
|
|
312
|
+
_check_arguments(arguments, ("target", "out", "lang", "format", "timeout_seconds"), ("target", "out"))
|
|
313
|
+
target = _require_dir(arguments["target"], "target")
|
|
314
|
+
out = _as_path(arguments["out"], "out")
|
|
315
|
+
if out.exists() and not out.is_dir():
|
|
316
|
+
raise ToolError(f"out is not a directory: {out}")
|
|
317
|
+
lang = arguments.get("lang", "en")
|
|
318
|
+
fmt = arguments.get("format", "sarif")
|
|
319
|
+
if lang not in LANGS:
|
|
320
|
+
raise ToolError("lang must be en, ja, or all")
|
|
321
|
+
if fmt not in FORMATS:
|
|
322
|
+
raise ToolError("format must be md, html, sarif, or all")
|
|
323
|
+
timeout = arguments.get("timeout_seconds", 180)
|
|
324
|
+
if isinstance(timeout, bool) or not isinstance(timeout, int) or not 1 <= timeout <= SCAN_TIMEOUT_MAX:
|
|
325
|
+
raise ToolError(f"timeout_seconds must be an integer from 1 to {SCAN_TIMEOUT_MAX}")
|
|
326
|
+
|
|
327
|
+
command = [
|
|
328
|
+
sys.executable,
|
|
329
|
+
str(BIN_KOMAGU),
|
|
330
|
+
"--no-banner",
|
|
331
|
+
"scan",
|
|
332
|
+
str(target),
|
|
333
|
+
"--out",
|
|
334
|
+
str(out),
|
|
335
|
+
"--lang",
|
|
336
|
+
lang,
|
|
337
|
+
"--format",
|
|
338
|
+
fmt,
|
|
339
|
+
]
|
|
340
|
+
try:
|
|
341
|
+
completed = subprocess.run(
|
|
342
|
+
command,
|
|
343
|
+
cwd=str(REPO_ROOT),
|
|
344
|
+
capture_output=True,
|
|
345
|
+
text=True,
|
|
346
|
+
timeout=timeout,
|
|
347
|
+
check=False,
|
|
348
|
+
)
|
|
349
|
+
except subprocess.TimeoutExpired as exc:
|
|
350
|
+
raise ToolError(f"scan timed out after {timeout} seconds") from exc
|
|
351
|
+
except OSError as exc:
|
|
352
|
+
raise ToolError(f"could not start scan: {exc}") from exc
|
|
353
|
+
|
|
354
|
+
audit = summarize_audit(out) if out.is_dir() else None
|
|
355
|
+
payload: Dict[str, Any] = {
|
|
356
|
+
"exitCode": completed.returncode,
|
|
357
|
+
"target": str(target),
|
|
358
|
+
"out": str(out),
|
|
359
|
+
"format": fmt,
|
|
360
|
+
"lang": lang,
|
|
361
|
+
"audit": audit,
|
|
362
|
+
}
|
|
363
|
+
if completed.returncode != 0:
|
|
364
|
+
payload["stderrTail"] = (completed.stderr or completed.stdout or "")[-STDERR_CAP:]
|
|
365
|
+
return payload
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def tool_read_audit(arguments: Dict[str, Any]) -> Dict[str, Any]:
|
|
369
|
+
"""Read finding metadata already on disk."""
|
|
370
|
+
_check_arguments(arguments, ("audit_dir",), ("audit_dir",))
|
|
371
|
+
audit_dir = _require_dir(arguments["audit_dir"], "audit_dir")
|
|
372
|
+
return summarize_audit(audit_dir)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def tool_leak_check(arguments: Dict[str, Any]) -> Dict[str, Any]:
|
|
376
|
+
"""Delegate to the same scanner as komagu leak-check. Values stay masked."""
|
|
377
|
+
_check_arguments(arguments, ("paths", "canary_file"), ("paths",))
|
|
378
|
+
raw_paths = arguments["paths"]
|
|
379
|
+
if not isinstance(raw_paths, list) or not raw_paths:
|
|
380
|
+
raise ToolError("paths must be a non-empty array of strings")
|
|
381
|
+
if len(raw_paths) > MAX_PATHS:
|
|
382
|
+
raise ToolError(f"paths accepts at most {MAX_PATHS} entries")
|
|
383
|
+
paths = [str(_as_path(item, "paths entry")) for item in raw_paths]
|
|
384
|
+
canaries: Tuple[str, ...] = ()
|
|
385
|
+
if "canary_file" in arguments and arguments["canary_file"] is not None:
|
|
386
|
+
canary_file = _require_file(arguments["canary_file"], "canary_file")
|
|
387
|
+
try:
|
|
388
|
+
canaries = load_canaries(str(canary_file))
|
|
389
|
+
except CanaryFileError as exc:
|
|
390
|
+
raise ToolError(str(exc)) from exc
|
|
391
|
+
result = scan_paths(paths, canaries)
|
|
392
|
+
hits, truncated = _cap([dataclasses.asdict(hit) for hit in result.hits])
|
|
393
|
+
return {
|
|
394
|
+
"exitCode": result.exit_code,
|
|
395
|
+
"filesScanned": result.files_scanned,
|
|
396
|
+
"canariesLoaded": result.canaries_loaded,
|
|
397
|
+
"hits": hits,
|
|
398
|
+
"hitsTruncated": truncated,
|
|
399
|
+
"notScanned": [{"path": path, "reason": reason} for path, reason in result.unscanned],
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def tool_inventory(arguments: Dict[str, Any]) -> Dict[str, Any]:
|
|
404
|
+
"""In-process inventory diff. Nothing is written."""
|
|
405
|
+
_check_arguments(arguments, ("routes", "openapi", "traffic_log"), ("routes", "openapi"))
|
|
406
|
+
routes_file = _require_file(arguments["routes"], "routes")
|
|
407
|
+
openapi_file = _require_file(arguments["openapi"], "openapi")
|
|
408
|
+
traffic_file: Optional[Path] = None
|
|
409
|
+
if arguments.get("traffic_log"):
|
|
410
|
+
traffic_file = _require_file(arguments["traffic_log"], "traffic_log")
|
|
411
|
+
|
|
412
|
+
code_routes = load_code_routes(routes_file)
|
|
413
|
+
spec_endpoints = load_openapi_spec(openapi_file)
|
|
414
|
+
traffic_data = load_traffic_log(traffic_file) if traffic_file else {}
|
|
415
|
+
diff_doc = diff_api_inventory(
|
|
416
|
+
code_routes=code_routes,
|
|
417
|
+
spec_endpoints=spec_endpoints,
|
|
418
|
+
traffic_data=traffic_data,
|
|
419
|
+
routes_file_name=routes_file.name,
|
|
420
|
+
openapi_file_name=openapi_file.name,
|
|
421
|
+
traffic_file_name=traffic_file.name if traffic_file else None,
|
|
422
|
+
)
|
|
423
|
+
payload = dict(diff_doc)
|
|
424
|
+
for key in ("shadow_apis", "zombie_apis", "documented_missing_code", "dormant_code"):
|
|
425
|
+
capped, truncated = _cap(payload.get(key))
|
|
426
|
+
payload[key] = capped
|
|
427
|
+
payload[key + "_truncated"] = truncated
|
|
428
|
+
return payload
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def tool_fix_preview(arguments: Dict[str, Any]) -> Dict[str, Any]:
|
|
432
|
+
"""Dry-run only. output_patch is never set, so no patch file is written."""
|
|
433
|
+
_check_arguments(arguments, ("target", "findings"), ("target",))
|
|
434
|
+
target = _require_dir(arguments["target"], "target")
|
|
435
|
+
findings: Optional[Path] = None
|
|
436
|
+
if arguments.get("findings"):
|
|
437
|
+
findings = _require_file(arguments["findings"], "findings")
|
|
438
|
+
runner = RemediationRunner(
|
|
439
|
+
target_dir=target,
|
|
440
|
+
findings_path=findings,
|
|
441
|
+
dry_run=True,
|
|
442
|
+
output_patch=None,
|
|
443
|
+
scan_sca=False,
|
|
444
|
+
query_osv=False,
|
|
445
|
+
)
|
|
446
|
+
try:
|
|
447
|
+
report = runner.run()
|
|
448
|
+
except FileNotFoundError as exc:
|
|
449
|
+
raise ToolError(str(exc)) from exc
|
|
450
|
+
if report.applied_count != 0 or not report.dry_run:
|
|
451
|
+
raise ToolError("refusing to return a preview that modified source")
|
|
452
|
+
|
|
453
|
+
plans: List[Dict[str, Any]] = []
|
|
454
|
+
for plan in report.plans[:LIST_CAP]:
|
|
455
|
+
item = plan.to_dict()
|
|
456
|
+
if plan.diff:
|
|
457
|
+
item["diff"] = plan.diff[:DIFF_CAP]
|
|
458
|
+
item["diffTruncated"] = len(plan.diff) > DIFF_CAP
|
|
459
|
+
plans.append(item)
|
|
460
|
+
body = report.to_dict()
|
|
461
|
+
body["plans"] = plans
|
|
462
|
+
body["plansTruncated"] = len(report.plans) > LIST_CAP
|
|
463
|
+
body["modifiedSource"] = False
|
|
464
|
+
return body
|