@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
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to `@hoanghainh/komagu` (published as `komainu-guard` up to v1.7.0) are documented here. From v1.2.2 on, entries are generated by [release-please](https://github.com/googleapis/release-please) from [Conventional Commits](https://www.conventionalcommits.org/). Earlier entries are copied from the [GitHub Releases](https://github.com/hoanghainh1188/komainu/releases).
|
|
4
4
|
|
|
5
|
+
## [2.7.0](https://github.com/hoanghainh1188/komagu/compare/v2.6.1...v2.7.0) (2026-11-14)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
### Features
|
|
9
|
+
|
|
10
|
+
* **assess:** add Path Traversal (CWE-22) and SSRF (CWE-918) LSA support ([#395](https://github.com/hoanghainh1188/komagu/issues/395)) ([#400](https://github.com/hoanghainh1188/komagu/issues/400)) ([018cd02](https://github.com/hoanghainh1188/komagu/commit/018cd021f219acc4e214460366f60edf5ad6333e))
|
|
11
|
+
* **operate:** add a zero-dependency MCP server (komagu mcp --stdio) ([#399](https://github.com/hoanghainh1188/komagu/issues/399)) ([a95a08c](https://github.com/hoanghainh1188/komagu/commit/a95a08c37061b0c7a7dbb6ebb369622ba304034e))
|
|
12
|
+
* **remediate:** add BFLA & Mass Assignment auto-fix strategies ([#392](https://github.com/hoanghainh1188/komagu/issues/392)) ([#396](https://github.com/hoanghainh1188/komagu/issues/396)) ([1620d44](https://github.com/hoanghainh1188/komagu/commit/1620d441086f34c4cad09c7df81e3fbbaab6e261))
|
|
13
|
+
* **remediate:** add Tier 2 AI-assisted multi-line fix with verification gate ([#394](https://github.com/hoanghainh1188/komagu/issues/394)) ([#398](https://github.com/hoanghainh1188/komagu/issues/398)) ([a569378](https://github.com/hoanghainh1188/komagu/commit/a5693780710a21c52a4c7982155ff0acd7dbc044))
|
|
14
|
+
|
|
5
15
|
## [2.6.1](https://github.com/hoanghainh1188/komagu/compare/v2.6.0...v2.6.1) (2026-11-11)
|
|
6
16
|
|
|
7
17
|
|
package/README.md
CHANGED
|
@@ -111,6 +111,7 @@ Komagu operates on a strict **Bring-Your-Own-LLM (BYO-LLM)** and runtime-neutral
|
|
|
111
111
|
| `komagu devsecops run <target>` | Complete SAST, secret leak (Gitleaks), and SCA scan | `npx @hoanghainh/komagu devsecops run .` |
|
|
112
112
|
| `komagu simulate --scenario <name>` | Executes Breach & Attack Simulation (Times Car, Metabase SQLi, IDC Frontier) | `npx @hoanghainh/komagu simulate --target http://localhost:5001 -o report.md` |
|
|
113
113
|
| `komagu demo --up` | Launches the interactive Member Portal reference app | `npx @hoanghainh/komagu demo --up` |
|
|
114
|
+
| `komagu mcp --stdio` | Serves local analysis tools (scan, audit summary, leak check, inventory, dry-run fix preview) over the Model Context Protocol. Standard library only; stdout is JSON-RPC. | `npx @hoanghainh/komagu mcp --stdio` |
|
|
114
115
|
|
|
115
116
|
---
|
|
116
117
|
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Path Traversal (CWE-22) & SSRF (CWE-918) Patterns
|
|
2
|
+
|
|
3
|
+
## 1. Path Traversal (`path_traversal`, CWE-22)
|
|
4
|
+
|
|
5
|
+
**What to look for:**
|
|
6
|
+
- File-system read/write/download/include sinks (`open`, `fs.readFile`, `res.sendFile`, `new File`, `Files.readAllBytes`, `file_get_contents`, `File.ReadAllText`, `os.Open`) receiving a filename or subpath from request input (`req.query`, `$_GET`, `@RequestParam`, etc.).
|
|
7
|
+
|
|
8
|
+
**False-positive checks (do NOT report when present):**
|
|
9
|
+
- The path is canonicalized (`realpath`, `getCanonicalPath`, `Path.GetFullPath`, `filepath.Clean`) **and** verified to start with the intended base directory (`resolved.startsWith(baseDir)`).
|
|
10
|
+
- Only an allow-listed fixed filename or basename without directory traversal (`..`) is accepted, or a lookup maps an opaque ID to a server-side path.
|
|
11
|
+
|
|
12
|
+
## 2. Server-Side Request Forgery (`ssrf`, CWE-918)
|
|
13
|
+
|
|
14
|
+
**What to look for:**
|
|
15
|
+
- Outbound HTTP/network clients (`requests.get`, `urllib.request.urlopen`, `fetch`, `axios`, `HttpURLConnection`, `RestTemplate`, `curl_init`, `HttpClient`, `http.Get`) using a URL, host, or webhook target controlled by the caller.
|
|
16
|
+
|
|
17
|
+
**False-positive checks (do NOT report when present):**
|
|
18
|
+
- The scheme and hostname are validated against an explicit server-side allow-list before the request is sent.
|
|
19
|
+
- DNS resolution is checked to reject loopback (`127.0.0.0/8`, `::1`), link-local/cloud metadata (`169.254.169.254`), and RFC1918 private ranges (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`) with redirects disabled or re-validated.
|
|
@@ -36,8 +36,10 @@ See `_shared-rules.md` §4. Severity and confidence are independent: a
|
|
|
36
36
|
| `sql_injection` | string_concatenation, string_interpolation, format_string, template_placeholder, dynamic_identifier, second_order, stored_procedure_dynamic_sql | CWE-89 | A03:2021 |
|
|
37
37
|
| `sql_injection` | (HQL/JPQL) | CWE-564, CWE-89 | A03:2021 |
|
|
38
38
|
| `nosql_injection` | operator_injection, js_injection | CWE-943 | A03:2021 |
|
|
39
|
+
| `path_traversal` | arbitrary_file_read, arbitrary_file_write | CWE-22 | A01:2021 |
|
|
40
|
+
| `ssrf` | unvalidated_outbound_url, internal_metadata_probe | CWE-918 | A10:2021, API7:2023 |
|
|
39
41
|
|
|
40
|
-
Finding id prefixes: `BOLA-`, `INJ-`, `BFLA-`, `MASS-`.
|
|
42
|
+
Finding id prefixes: `BOLA-`, `INJ-`, `BFLA-`, `MASS-`, `PATH-`, `SSRF-`.
|
|
41
43
|
|
|
42
44
|
## Fingerprint
|
|
43
45
|
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Deterministic Tier 1 pre-scanner for Path Traversal (CWE-22) and SSRF (CWE-918).
|
|
3
|
+
|
|
4
|
+
Usage: find_path_ssrf.py TARGET [--config audit-config.json] [--out path-ssrf.candidates.json]
|
|
5
|
+
|
|
6
|
+
Standard-library-only (Python 3.8+).
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import re
|
|
12
|
+
import sys
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, Dict, List, Optional, Pattern, Tuple
|
|
15
|
+
|
|
16
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
17
|
+
from lsa_common import KIT_VERSION, iter_source_files, load_config, write_json # noqa: E402
|
|
18
|
+
from redaction import redact # noqa: E402
|
|
19
|
+
|
|
20
|
+
_PATH_SINKS: Tuple[Tuple[str, Pattern[str]], ...] = (
|
|
21
|
+
("fs_read_node", re.compile(r"\b(?:fs\.(?:readFile|readFileSync|createReadStream|writeFile|writeFileSync)|res\.(?:sendFile|download))\s*\(")),
|
|
22
|
+
("path_open_py", re.compile(r"\b(?:open|send_file|send_from_directory)\s*\(")),
|
|
23
|
+
("file_java", re.compile(r"\b(?:new\s+File|Paths\.get|Files\.(?:readAllBytes|readString|newInputStream|copy))\s*\(")),
|
|
24
|
+
("file_php", re.compile(r"\b(?:file_get_contents|fopen|readfile|include_once|require_once|include|require)\s*(?:\(| )")),
|
|
25
|
+
("file_dotnet", re.compile(r"\bFile\.(?:ReadAllText|ReadAllBytes|OpenRead|OpenWrite)\s*\(")),
|
|
26
|
+
("file_go", re.compile(r"\b(?:os\.(?:Open|ReadFile|Create)|ioutil\.ReadFile)\s*\(")),
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
_SSRF_SINKS: Tuple[Tuple[str, Pattern[str]], ...] = (
|
|
30
|
+
("http_py", re.compile(r"\b(?:requests\.(?:get|post|put|delete|head|request)|urllib\.request\.urlopen|httpx\.(?:get|post|request))\s*\(")),
|
|
31
|
+
("http_node", re.compile(r"\b(?:fetch|axios(?:\.(?:get|post|put|request))?|https?\.(?:get|request))\s*\(")),
|
|
32
|
+
("http_java", re.compile(r"\b(?:new\s+URL\s*\(|HttpURLConnection|RestTemplate\.(?:getFor|postFor|exchange)|WebClient\.)")),
|
|
33
|
+
("http_php", re.compile(r"\b(?:curl_init|file_get_contents)\s*\(")),
|
|
34
|
+
("http_dotnet", re.compile(r"\b(?:HttpClient|WebRequest\.Create)\b")),
|
|
35
|
+
("http_go", re.compile(r"\bhttp\.(?:Get|Post|NewRequest)\s*\(")),
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
_REQUEST_INPUT_HINT = re.compile(
|
|
39
|
+
r"(?:\$_(?:GET|POST|REQUEST)|"
|
|
40
|
+
r"\breq(?:uest)?\.(?:query|params|body|args|form|GET|POST|URL)|"
|
|
41
|
+
r"\b(?:getParameter|@RequestParam|@PathVariable|r\.URL\.Query|QueryRow|Request\.QueryString)\b|"
|
|
42
|
+
r"\b(?:file_?name|file_?path|target_?url|webhook_?url|callback_?url|redirect_?url|endpoint_?url|avatar_?url)\b)",
|
|
43
|
+
re.IGNORECASE,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
_LITERAL_ONLY_CALL = re.compile(
|
|
47
|
+
r"\(\s*(?:['\"][^'\"]+['\"]\s*(?:,|\))|`https?://[^`$]+`)"
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
_PATH_SANITIZER_HINT = re.compile(
|
|
51
|
+
r"(?:\brealpath\b|\bcanonical(?:Path)?\b|\bGetFullPath\b|\bPath\.basename\b|\bos\.path\.basename\b|\bfilepath\.Clean\b|\bstartsWith\s*\()",
|
|
52
|
+
re.IGNORECASE,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
_SSRF_SANITIZER_HINT = re.compile(
|
|
56
|
+
r"(?:\ballowlist\b|\bwhitelist\b|\ballowed_?hosts?\b|\bis_safe_url\b|\bvalidate_url\b)",
|
|
57
|
+
re.IGNORECASE,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _has_nearby_input(lines: List[str], idx: int, window: int = 5) -> bool:
|
|
62
|
+
"""True when request-controlled input appears on the sink line or within +-window lines."""
|
|
63
|
+
start = max(0, idx - window)
|
|
64
|
+
end = min(len(lines), idx + 2)
|
|
65
|
+
snippet = "\n".join(lines[start:end])
|
|
66
|
+
return _REQUEST_INPUT_HINT.search(snippet) is not None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _has_nearby_guard(lines: List[str], idx: int, guard_rx: Pattern[str], window: int = 4) -> bool:
|
|
70
|
+
"""True when a sanitizer/allow-list guard is visible near the sink."""
|
|
71
|
+
start = max(0, idx - window)
|
|
72
|
+
end = min(len(lines), idx + 2)
|
|
73
|
+
return guard_rx.search("\n".join(lines[start:end])) is not None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def scan_file_path_ssrf(path: Path, rel: str) -> List[Dict[str, Any]]:
|
|
77
|
+
"""Scan a single source file for Path Traversal (CWE-22) and SSRF (CWE-918) candidates."""
|
|
78
|
+
try:
|
|
79
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
80
|
+
except OSError:
|
|
81
|
+
return []
|
|
82
|
+
|
|
83
|
+
lines = text.splitlines()
|
|
84
|
+
candidates: List[Dict[str, Any]] = []
|
|
85
|
+
|
|
86
|
+
for idx, line in enumerate(lines):
|
|
87
|
+
stripped = line.strip()
|
|
88
|
+
if not stripped or stripped.startswith(("//", "#", "/*", "*")):
|
|
89
|
+
continue
|
|
90
|
+
if _LITERAL_ONLY_CALL.search(stripped):
|
|
91
|
+
continue
|
|
92
|
+
|
|
93
|
+
# Check SSRF first when PHP file_get_contents is used with URL hints
|
|
94
|
+
matched_ssrf = False
|
|
95
|
+
for sink_id, rx in _SSRF_SINKS:
|
|
96
|
+
if rx.search(stripped):
|
|
97
|
+
if sink_id == "http_php" and "file_get_contents" in stripped and not re.search(r"url|http", stripped, re.IGNORECASE):
|
|
98
|
+
continue
|
|
99
|
+
if _has_nearby_input(lines, idx) and not _has_nearby_guard(lines, idx, _SSRF_SANITIZER_HINT):
|
|
100
|
+
candidates.append(
|
|
101
|
+
{
|
|
102
|
+
"category": "ssrf",
|
|
103
|
+
"cwe": "CWE-918",
|
|
104
|
+
"sink": sink_id,
|
|
105
|
+
"file": rel,
|
|
106
|
+
"line": idx + 1,
|
|
107
|
+
"snippet": redact(stripped[:240]),
|
|
108
|
+
"priority": "high",
|
|
109
|
+
}
|
|
110
|
+
)
|
|
111
|
+
matched_ssrf = True
|
|
112
|
+
break
|
|
113
|
+
|
|
114
|
+
if matched_ssrf:
|
|
115
|
+
continue
|
|
116
|
+
|
|
117
|
+
for sink_id, rx in _PATH_SINKS:
|
|
118
|
+
if rx.search(stripped):
|
|
119
|
+
if _has_nearby_input(lines, idx) and not _has_nearby_guard(lines, idx, _PATH_SANITIZER_HINT):
|
|
120
|
+
candidates.append(
|
|
121
|
+
{
|
|
122
|
+
"category": "path_traversal",
|
|
123
|
+
"cwe": "CWE-22",
|
|
124
|
+
"sink": sink_id,
|
|
125
|
+
"file": rel,
|
|
126
|
+
"line": idx + 1,
|
|
127
|
+
"snippet": redact(stripped[:240]),
|
|
128
|
+
"priority": "high",
|
|
129
|
+
}
|
|
130
|
+
)
|
|
131
|
+
break
|
|
132
|
+
|
|
133
|
+
return candidates
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def find_path_ssrf(target: Path, config: Dict[str, Any], cache: Optional[Any] = None) -> Dict[str, Any]:
|
|
137
|
+
"""Scan target directory for Path Traversal and SSRF candidates."""
|
|
138
|
+
all_candidates: List[Dict[str, Any]] = []
|
|
139
|
+
files_scanned = 0
|
|
140
|
+
|
|
141
|
+
for path, rel in iter_source_files(target, config):
|
|
142
|
+
files_scanned += 1
|
|
143
|
+
found = scan_file_path_ssrf(path, rel)
|
|
144
|
+
all_candidates.extend(found)
|
|
145
|
+
|
|
146
|
+
path_count = sum(1 for c in all_candidates if c["category"] == "path_traversal")
|
|
147
|
+
ssrf_count = sum(1 for c in all_candidates if c["category"] == "ssrf")
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
"generated_by": f"lsa find_path_ssrf.py {KIT_VERSION}",
|
|
151
|
+
"files_scanned": files_scanned,
|
|
152
|
+
"summary": {
|
|
153
|
+
"total": len(all_candidates),
|
|
154
|
+
"path_traversal": path_count,
|
|
155
|
+
"ssrf": ssrf_count,
|
|
156
|
+
},
|
|
157
|
+
"candidates": all_candidates,
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def main() -> int:
|
|
162
|
+
ap = argparse.ArgumentParser(description=__doc__)
|
|
163
|
+
ap.add_argument("target")
|
|
164
|
+
ap.add_argument("--config")
|
|
165
|
+
ap.add_argument("--out")
|
|
166
|
+
a = ap.parse_args()
|
|
167
|
+
res = find_path_ssrf(Path(a.target).resolve(), load_config(a.config))
|
|
168
|
+
if a.out:
|
|
169
|
+
write_json(Path(a.out), res)
|
|
170
|
+
else:
|
|
171
|
+
print(f"path_traversal={res['summary']['path_traversal']} ssrf={res['summary']['ssrf']}")
|
|
172
|
+
return 0
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
if __name__ == "__main__":
|
|
176
|
+
sys.exit(main())
|
|
@@ -50,6 +50,14 @@ RULE_TEXT = {
|
|
|
50
50
|
"Mass assignment of object properties",
|
|
51
51
|
"A request body is bound to a model or entity without an allow-list, so a caller can write properties they must not control.",
|
|
52
52
|
),
|
|
53
|
+
"path_traversal": (
|
|
54
|
+
"Path traversal / arbitrary file access",
|
|
55
|
+
"A filesystem path is constructed from request-controlled input without canonicalizing and enforcing a base directory boundary.",
|
|
56
|
+
),
|
|
57
|
+
"ssrf": (
|
|
58
|
+
"Server-Side Request Forgery (SSRF)",
|
|
59
|
+
"An outbound network request target URL is derived from request-controlled input without host allow-listing or private-IP blocking.",
|
|
60
|
+
),
|
|
53
61
|
}
|
|
54
62
|
|
|
55
63
|
CWE_TAXONOMY_DEFS: Dict[str, Dict[str, str]] = {
|
|
@@ -101,6 +109,18 @@ CWE_TAXONOMY_DEFS: Dict[str, Dict[str, str]] = {
|
|
|
101
109
|
"shortDescription": "Improper Input Validation",
|
|
102
110
|
"helpUri": "https://cwe.mitre.org/data/definitions/20.html",
|
|
103
111
|
},
|
|
112
|
+
"CWE-22": {
|
|
113
|
+
"id": "CWE-22",
|
|
114
|
+
"name": "Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')",
|
|
115
|
+
"shortDescription": "Path Traversal",
|
|
116
|
+
"helpUri": "https://cwe.mitre.org/data/definitions/22.html",
|
|
117
|
+
},
|
|
118
|
+
"CWE-918": {
|
|
119
|
+
"id": "CWE-918",
|
|
120
|
+
"name": "Server-Side Request Forgery (SSRF)",
|
|
121
|
+
"shortDescription": "Server-Side Request Forgery (SSRF)",
|
|
122
|
+
"helpUri": "https://cwe.mitre.org/data/definitions/918.html",
|
|
123
|
+
},
|
|
104
124
|
}
|
|
105
125
|
|
|
106
126
|
OWASP_TAGS = {
|
|
@@ -129,6 +149,15 @@ OWASP_TAGS = {
|
|
|
129
149
|
"OWASP:A01:2021-Broken Access Control",
|
|
130
150
|
"OWASP-API3:2023-BOPLA",
|
|
131
151
|
],
|
|
152
|
+
"path_traversal": [
|
|
153
|
+
"external/owasp/a01-2021-broken-access-control",
|
|
154
|
+
"OWASP:A01:2021-Broken Access Control",
|
|
155
|
+
],
|
|
156
|
+
"ssrf": [
|
|
157
|
+
"external/owasp/a10-2021-server-side-request-forgery",
|
|
158
|
+
"OWASP:A10:2021-Server-Side Request Forgery",
|
|
159
|
+
"OWASP-API7:2023-SSRF",
|
|
160
|
+
],
|
|
132
161
|
}
|
|
133
162
|
|
|
134
163
|
|
|
@@ -139,7 +139,7 @@ def load_validator(schema_name: str):
|
|
|
139
139
|
|
|
140
140
|
|
|
141
141
|
BASE_CATEGORIES = ("bola", "sql_injection", "nosql_injection")
|
|
142
|
-
EXTRA_CATEGORIES = ("bfla", "mass_assignment")
|
|
142
|
+
EXTRA_CATEGORIES = ("bfla", "mass_assignment", "path_traversal", "ssrf")
|
|
143
143
|
|
|
144
144
|
|
|
145
145
|
def count_by_category(active: List[Dict]) -> Dict[str, int]:
|
|
@@ -25,6 +25,7 @@ from detect_stack import detect # noqa: E402
|
|
|
25
25
|
from changed_files import ChangedFilesError, git_changed_files, select_files # noqa: E402
|
|
26
26
|
from find_injection import find_injection # noqa: E402
|
|
27
27
|
from find_mass_assignment import find_mass_assignment # noqa: E402
|
|
28
|
+
from find_path_ssrf import find_path_ssrf # noqa: E402
|
|
28
29
|
from lsa_common import KIT_VERSION, deep_merge, load_config, write_json # noqa: E402
|
|
29
30
|
from make_batches import make_batches # noqa: E402
|
|
30
31
|
from map_routes import map_routes # noqa: E402
|
|
@@ -91,6 +92,8 @@ def main() -> int:
|
|
|
91
92
|
write_json(out / "injection.candidates.json", inj)
|
|
92
93
|
mass = find_mass_assignment(target, scan_cfg, cache=cache)
|
|
93
94
|
write_json(out / "mass-assignment.candidates.json", mass)
|
|
95
|
+
path_ssrf = find_path_ssrf(target, scan_cfg, cache=cache)
|
|
96
|
+
write_json(out / "path-ssrf.candidates.json", path_ssrf)
|
|
94
97
|
|
|
95
98
|
if cache.enabled:
|
|
96
99
|
cache.save()
|
|
@@ -116,6 +119,7 @@ def main() -> int:
|
|
|
116
119
|
f"- Route candidates: {routes['summary']['total']} (with id-like params: {routes['summary']['with_id_like_params']})",
|
|
117
120
|
f"- Injection candidates: {inj['summary']['total']} (high priority: {inj['summary']['high_priority']})",
|
|
118
121
|
f"- Mass assignment candidates: {mass['summary']['total']} (high priority: {mass['summary']['high_priority']})",
|
|
122
|
+
f"- Path traversal / SSRF candidates: {path_ssrf['summary']['total']} (path_traversal: {path_ssrf['summary']['path_traversal']}, ssrf: {path_ssrf['summary']['ssrf']})",
|
|
119
123
|
f"- BFLA route hints: {routes['summary'].get('bfla_hints', 0)}",
|
|
120
124
|
f"- Batches: {len(idx['routes'])} route, {len(idx['injection'])} injection",
|
|
121
125
|
f"- Knowledge files to load: {', '.join(prof['knowledge_files'])}", "",
|
|
@@ -187,6 +187,10 @@ def main() -> int:
|
|
|
187
187
|
if res_mod.returncode != 0:
|
|
188
188
|
fails.append(f"test_modern_stacks.py failed:\n{res_mod.stdout}\n{res_mod.stderr}")
|
|
189
189
|
print(f" modern stacks {'OK' if res_mod.returncode == 0 else 'FAIL'}")
|
|
190
|
+
res_ps = subprocess.run([sys.executable, str(HERE / "test_path_ssrf.py")], capture_output=True, text=True)
|
|
191
|
+
if res_ps.returncode != 0:
|
|
192
|
+
fails.append(f"test_path_ssrf.py failed:\n{res_ps.stdout}\n{res_ps.stderr}")
|
|
193
|
+
print(f" path & ssrf {'OK' if res_ps.returncode == 0 else 'FAIL'}")
|
|
190
194
|
res_perf = subprocess.run([sys.executable, str(HERE / "test_performance.py")], capture_output=True, text=True)
|
|
191
195
|
if res_perf.returncode != 0:
|
|
192
196
|
fails.append(f"test_performance.py failed:\n{res_perf.stdout}\n{res_perf.stderr}")
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Tests for Path Traversal (CWE-22) and SSRF (CWE-918) LSA support (Issue #395)."""
|
|
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
|
+
KIT = Path(__file__).resolve().parents[1]
|
|
14
|
+
sys.path.insert(0, str(KIT / "scripts"))
|
|
15
|
+
|
|
16
|
+
from find_path_ssrf import find_path_ssrf # noqa: E402
|
|
17
|
+
from findings_to_sarif import to_sarif # noqa: E402
|
|
18
|
+
from lsa_common import load_config # noqa: E402
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class TestPathTraversalAndSSRF(unittest.TestCase):
|
|
22
|
+
"""Verify Tier 1 pre-scan and SARIF export for path_traversal and ssrf."""
|
|
23
|
+
|
|
24
|
+
def setUp(self) -> None:
|
|
25
|
+
self.temp_dir = Path(tempfile.mkdtemp(prefix="komagu_path_ssrf_")).resolve()
|
|
26
|
+
self.cfg = load_config(None)
|
|
27
|
+
|
|
28
|
+
def tearDown(self) -> None:
|
|
29
|
+
shutil.rmtree(self.temp_dir, ignore_errors=True)
|
|
30
|
+
|
|
31
|
+
def test_detects_vulnerable_and_skips_sanitized(self) -> None:
|
|
32
|
+
(self.temp_dir / "files.js").write_text(
|
|
33
|
+
"const fs = require('fs');\n"
|
|
34
|
+
"app.get('/download', (req, res) => {\n"
|
|
35
|
+
" fs.readFile('/var/data/' + req.query.file, () => {});\n"
|
|
36
|
+
"});\n"
|
|
37
|
+
"app.get('/safe-download', (req, res) => {\n"
|
|
38
|
+
" const safe = path.basename(req.query.file);\n"
|
|
39
|
+
" fs.readFile('/var/data/' + safe, () => {});\n"
|
|
40
|
+
"});\n",
|
|
41
|
+
encoding="utf-8",
|
|
42
|
+
)
|
|
43
|
+
(self.temp_dir / "webhook.py").write_text(
|
|
44
|
+
"import requests\n"
|
|
45
|
+
"def send_webhook(request):\n"
|
|
46
|
+
" target_url = request.args.get('url')\n"
|
|
47
|
+
" return requests.post(target_url, json={'ok': True})\n"
|
|
48
|
+
"def health():\n"
|
|
49
|
+
" return requests.get('https://status.example.com/ping')\n",
|
|
50
|
+
encoding="utf-8",
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
res = find_path_ssrf(self.temp_dir, self.cfg)
|
|
54
|
+
self.assertEqual(res["summary"]["path_traversal"], 1)
|
|
55
|
+
self.assertEqual(res["summary"]["ssrf"], 1)
|
|
56
|
+
cats = {c["category"]: c for c in res["candidates"]}
|
|
57
|
+
self.assertEqual(cats["path_traversal"]["cwe"], "CWE-22")
|
|
58
|
+
self.assertEqual(cats["path_traversal"]["line"], 3)
|
|
59
|
+
self.assertEqual(cats["ssrf"]["cwe"], "CWE-918")
|
|
60
|
+
self.assertEqual(cats["ssrf"]["line"], 4)
|
|
61
|
+
|
|
62
|
+
def test_sarif_export_supports_path_traversal_and_ssrf(self) -> None:
|
|
63
|
+
findings = [
|
|
64
|
+
{
|
|
65
|
+
"finding_id": "PATH-01",
|
|
66
|
+
"category": "path_traversal",
|
|
67
|
+
"severity": "high",
|
|
68
|
+
"confidence": "high",
|
|
69
|
+
"status": "confirmed",
|
|
70
|
+
"title": "Arbitrary file read via ?file=",
|
|
71
|
+
"cwe": ["CWE-22"],
|
|
72
|
+
"location": {"file": "files.js", "start_line": 3},
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
"finding_id": "SSRF-01",
|
|
76
|
+
"category": "ssrf",
|
|
77
|
+
"severity": "high",
|
|
78
|
+
"confidence": "high",
|
|
79
|
+
"status": "confirmed",
|
|
80
|
+
"title": "Unvalidated webhook URL",
|
|
81
|
+
"cwe": ["CWE-918"],
|
|
82
|
+
"location": {"file": "webhook.py", "start_line": 4},
|
|
83
|
+
},
|
|
84
|
+
]
|
|
85
|
+
sarif = to_sarif({"findings": findings}, target_dir=self.temp_dir)
|
|
86
|
+
results = sarif["runs"][0]["results"]
|
|
87
|
+
self.assertEqual(len(results), 2)
|
|
88
|
+
rule_ids = {r["ruleId"] for r in results}
|
|
89
|
+
self.assertIn("lsa/path_traversal/general", rule_ids)
|
|
90
|
+
self.assertIn("lsa/ssrf/general", rule_ids)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
if __name__ == "__main__":
|
|
94
|
+
unittest.main()
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
"properties": {
|
|
20
20
|
"finding_id": {
|
|
21
21
|
"type": "string",
|
|
22
|
-
"pattern": "^(BOLA|INJ|BFLA|MASS)-[A-Za-z0-9_.-]+$"
|
|
22
|
+
"pattern": "^(BOLA|INJ|BFLA|MASS|PATH|SSRF)-[A-Za-z0-9_.-]+$"
|
|
23
23
|
},
|
|
24
24
|
"category": {
|
|
25
25
|
"enum": [
|
|
@@ -27,7 +27,9 @@
|
|
|
27
27
|
"sql_injection",
|
|
28
28
|
"nosql_injection",
|
|
29
29
|
"bfla",
|
|
30
|
-
"mass_assignment"
|
|
30
|
+
"mass_assignment",
|
|
31
|
+
"path_traversal",
|
|
32
|
+
"ssrf"
|
|
31
33
|
]
|
|
32
34
|
},
|
|
33
35
|
"fingerprint": {
|
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/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()
|