@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 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. **Automated Remediation Runner (`remediator/runner.py` / `komagu fix`)**:
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
 
@@ -2,4 +2,4 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- __version__ = "2.6.1" # x-release-please-version
5
+ __version__ = "2.7.0" # x-release-please-version
@@ -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()