@hoanghainh/komagu 2.6.0 → 2.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +1 -0
  3. package/assess/lsa/knowledge/path-ssrf-patterns.md +19 -0
  4. package/assess/lsa/knowledge/severity-and-cwe.md +3 -1
  5. package/assess/lsa/scripts/find_path_ssrf.py +176 -0
  6. package/assess/lsa/scripts/findings_to_sarif.py +29 -0
  7. package/assess/lsa/scripts/merge_findings.py +1 -1
  8. package/assess/lsa/scripts/run_prescan.py +4 -0
  9. package/assess/lsa/tests/run_tests.py +4 -0
  10. package/assess/lsa/tests/test_path_ssrf.py +94 -0
  11. package/contracts/schemas/finding.schema.json +4 -2
  12. package/examples/member-portal/backend-dotnet/Auth/DemoAuthenticationHandler.cs +75 -0
  13. package/examples/member-portal/backend-dotnet/Program.cs +1 -72
  14. package/examples/member-portal/backend-spring/src/main/kotlin/io/komagu/portal/Application.kt +4 -0
  15. package/examples/member-portal/backend-spring/src/main/kotlin/io/komagu/portal/controller/Controllers.kt +3 -0
  16. package/examples/member-portal/backend-spring/src/main/kotlin/io/komagu/portal/model/Entities.kt +4 -0
  17. package/examples/member-portal/tests/test_d6_demo_app.py +4 -4
  18. package/examples/member-portal/tests/test_d6_remediation_verification.py +6 -6
  19. package/operate/README.md +6 -1
  20. package/operate/ai/client.py +5 -1
  21. package/operate/cli/__init__.py +1 -1
  22. package/operate/cli/commands/fix.py +47 -0
  23. package/operate/cli/commands/mcp.py +54 -0
  24. package/operate/cli/main.py +42 -4
  25. package/operate/cross_user_tester/runner.py +4 -1
  26. package/operate/devsecops/sbom.py +12 -4
  27. package/operate/mcp/__init__.py +11 -0
  28. package/operate/mcp/server.py +206 -0
  29. package/operate/mcp/tools.py +464 -0
  30. package/operate/remediator/ai_fixer.py +285 -0
  31. package/operate/remediator/runner.py +11 -0
  32. package/operate/remediator/strategies.py +64 -0
  33. package/operate/simulate/scenarios.py +11 -2
  34. package/operate/tests/test_ai_remediation.py +288 -0
  35. package/operate/tests/test_audit_minimum_versions.py +89 -0
  36. package/operate/tests/test_cli.py +1 -1
  37. package/operate/tests/test_mcp_server.py +380 -0
  38. package/operate/tests/test_scheme_and_xml_guards.py +67 -0
  39. package/package.json +1 -1
  40. package/remediate/authz/bfla_patcher.py +319 -0
  41. package/remediate/authz/dotnet/src/Komagu.Security.Authorization/LoggingSecurityAuditSink.cs +19 -3
  42. package/remediate/authz/golang/authz_test.go +5 -2
  43. package/remediate/authz/mass_assignment_patcher.py +188 -0
  44. package/remediate/authz/patcher.py +8 -0
  45. package/remediate/authz/spring/src/main/kotlin/io/komagu/security/authorization/DefaultKomaguSecurityManager.kt +74 -78
  46. package/remediate/authz/spring/src/main/kotlin/io/komagu/security/spring/OwnershipAspect.kt +129 -158
  47. package/remediate/authz/tests/test_bfla_and_mass_assignment_patcher.py +213 -0
  48. package/remediate/deps/patcher.py +2 -1
  49. package/remediate/edge/mock-backend/app.py +2 -1
  50. package/remediate/edge/tests/test_edge_security.py +5 -5
  51. package/remediate/ekyc/requirements.txt +1 -1
  52. package/remediate/identity/dotnet/src/Komagu.Security.Identity/OidcServiceCollectionExtensions.cs +19 -6
  53. package/remediate/identity/python/requirements.txt +2 -2
  54. package/remediate/identity/spring/src/main/kotlin/io/komagu/security/identity/KomaguIdentityAutoConfiguration.kt +31 -38
  55. package/remediate/legacy_shims/tests/test_legacy_shims.py +8 -8
  56. package/remediate/query/dotnet/src/Komagu.Security.Query/SafeSortAllowList.cs +5 -2
  57. package/remediate/query/spring/src/main/kotlin/io/komagu/security/query/SafeSortAllowList.kt +10 -11
  58. package/remediate/query/spring/src/main/kotlin/io/komagu/security/query/detekt/StringBuiltSqlQueryRule.kt +19 -24
package/CHANGELOG.md CHANGED
@@ -2,6 +2,29 @@
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
+
15
+ ## [2.6.1](https://github.com/hoanghainh1188/komagu/compare/v2.6.0...v2.6.1) (2026-11-11)
16
+
17
+
18
+ ### Bug Fixes
19
+
20
+ * **deps:** raise vulnerable minimum versions and gate dependency audits ([#391](https://github.com/hoanghainh1188/komagu/issues/391)) ([40173a3](https://github.com/hoanghainh1188/komagu/commit/40173a36826b9a69ecb1046159d077381494850c))
21
+ * **security:** clear the 64 open code scanning alerts before gating ([#388](https://github.com/hoanghainh1188/komagu/issues/388)) ([49372da](https://github.com/hoanghainh1188/komagu/commit/49372da959690251db6d0810c60eb15ba3f2591f))
22
+
23
+
24
+ ### Documentation
25
+
26
+ * **pilot:** pin the runbook to v2.6.0 ([#386](https://github.com/hoanghainh1188/komagu/issues/386)) ([b416ff8](https://github.com/hoanghainh1188/komagu/commit/b416ff8613410d74b71a485a1ac05297abf164c6))
27
+
5
28
  ## [2.6.0](https://github.com/hoanghainh1188/komagu/compare/v2.5.0...v2.6.0) (2026-11-11)
6
29
 
7
30
 
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": {
@@ -0,0 +1,75 @@
1
+ namespace MemberPortal.Backend.Auth;
2
+
3
+ using System.Security.Claims;
4
+ using System.Text.Encodings.Web;
5
+ using Microsoft.AspNetCore.Authentication;
6
+ using Microsoft.Extensions.Options;
7
+
8
+ /// <summary>
9
+ /// Flexible authentication handler supporting mock tokens for personas as well as JWT claims.
10
+ /// </summary>
11
+ public class DemoAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
12
+ {
13
+ public DemoAuthenticationHandler(
14
+ IOptionsMonitor<AuthenticationSchemeOptions> options,
15
+ ILoggerFactory logger,
16
+ UrlEncoder encoder)
17
+ : base(options, logger, encoder)
18
+ {
19
+ }
20
+
21
+ protected override Task<AuthenticateResult> HandleAuthenticateAsync()
22
+ {
23
+ if (!Request.Headers.TryGetValue("Authorization", out var authHeaderValues))
24
+ {
25
+ // Anonymous fallback
26
+ return Task.FromResult(AuthenticateResult.NoResult());
27
+ }
28
+
29
+ var header = authHeaderValues.ToString();
30
+ var token = header.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)
31
+ ? header.Substring(7).Trim()
32
+ : header.Trim();
33
+
34
+ var claims = token switch
35
+ {
36
+ "token-user-a" => new[]
37
+ {
38
+ new Claim(ClaimTypes.NameIdentifier, "user-a"),
39
+ new Claim("sub", "user-a"),
40
+ new Claim("tenant_id", "tenant-alpha"),
41
+ new Claim("name", "Alice"),
42
+ new Claim(ClaimTypes.Role, "member")
43
+ },
44
+ "token-user-b" => new[]
45
+ {
46
+ new Claim(ClaimTypes.NameIdentifier, "user-b"),
47
+ new Claim("sub", "user-b"),
48
+ new Claim("tenant_id", "tenant-alpha"),
49
+ new Claim("name", "Bob"),
50
+ new Claim(ClaimTypes.Role, "member")
51
+ },
52
+ "token-user-c" => new[]
53
+ {
54
+ new Claim(ClaimTypes.NameIdentifier, "user-c"),
55
+ new Claim("sub", "user-c"),
56
+ new Claim("tenant_id", "tenant-beta"),
57
+ new Claim("name", "Charlie"),
58
+ new Claim(ClaimTypes.Role, "member")
59
+ },
60
+ _ => new[]
61
+ {
62
+ new Claim(ClaimTypes.NameIdentifier, token),
63
+ new Claim("sub", token),
64
+ new Claim("tenant_id", "tenant-alpha"),
65
+ new Claim(ClaimTypes.Role, "member")
66
+ }
67
+ };
68
+
69
+ var identity = new ClaimsIdentity(claims, Scheme.Name);
70
+ var principal = new ClaimsPrincipal(identity);
71
+ var ticket = new AuthenticationTicket(principal, Scheme.Name);
72
+
73
+ return Task.FromResult(AuthenticateResult.Success(ticket));
74
+ }
75
+ }
@@ -1,9 +1,7 @@
1
- using System.Security.Claims;
2
- using System.Text.Encodings.Web;
1
+ using MemberPortal.Backend.Auth;
3
2
  using MemberPortal.Backend.Data;
4
3
  using Microsoft.AspNetCore.Authentication;
5
4
  using Microsoft.EntityFrameworkCore;
6
- using Microsoft.Extensions.Options;
7
5
  using Komagu.Security.Authorization;
8
6
 
9
7
  var builder = WebApplication.CreateBuilder(args);
@@ -53,72 +51,3 @@ app.UseAuthorization();
53
51
  app.MapControllers();
54
52
 
55
53
  app.Run();
56
-
57
- /// <summary>
58
- /// Flexible authentication handler supporting mock tokens for personas as well as JWT claims.
59
- /// </summary>
60
- public class DemoAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
61
- {
62
- public DemoAuthenticationHandler(
63
- IOptionsMonitor<AuthenticationSchemeOptions> options,
64
- ILoggerFactory logger,
65
- UrlEncoder encoder)
66
- : base(options, logger, encoder)
67
- {
68
- }
69
-
70
- protected override Task<AuthenticateResult> HandleAuthenticateAsync()
71
- {
72
- if (!Request.Headers.TryGetValue("Authorization", out var authHeaderValues))
73
- {
74
- // Anonymous fallback
75
- return Task.FromResult(AuthenticateResult.NoResult());
76
- }
77
-
78
- var header = authHeaderValues.ToString();
79
- var token = header.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)
80
- ? header.Substring(7).Trim()
81
- : header.Trim();
82
-
83
- var claims = token switch
84
- {
85
- "token-user-a" => new[]
86
- {
87
- new Claim(ClaimTypes.NameIdentifier, "user-a"),
88
- new Claim("sub", "user-a"),
89
- new Claim("tenant_id", "tenant-alpha"),
90
- new Claim("name", "Alice"),
91
- new Claim(ClaimTypes.Role, "member")
92
- },
93
- "token-user-b" => new[]
94
- {
95
- new Claim(ClaimTypes.NameIdentifier, "user-b"),
96
- new Claim("sub", "user-b"),
97
- new Claim("tenant_id", "tenant-alpha"),
98
- new Claim("name", "Bob"),
99
- new Claim(ClaimTypes.Role, "member")
100
- },
101
- "token-user-c" => new[]
102
- {
103
- new Claim(ClaimTypes.NameIdentifier, "user-c"),
104
- new Claim("sub", "user-c"),
105
- new Claim("tenant_id", "tenant-beta"),
106
- new Claim("name", "Charlie"),
107
- new Claim(ClaimTypes.Role, "member")
108
- },
109
- _ => new[]
110
- {
111
- new Claim(ClaimTypes.NameIdentifier, token),
112
- new Claim("sub", token),
113
- new Claim("tenant_id", "tenant-alpha"),
114
- new Claim(ClaimTypes.Role, "member")
115
- }
116
- };
117
-
118
- var identity = new ClaimsIdentity(claims, Scheme.Name);
119
- var principal = new ClaimsPrincipal(identity);
120
- var ticket = new AuthenticationTicket(principal, Scheme.Name);
121
-
122
- return Task.FromResult(AuthenticateResult.Success(ticket));
123
- }
124
- }
@@ -34,6 +34,8 @@ import java.time.Instant
34
34
  // so the identity module's JWT decoder, which requires komagu.security.oidc.*, is left out.
35
35
  @SpringBootApplication(exclude = [KomaguIdentityAutoConfiguration::class])
36
36
  class Application {
37
+ // Demo seed data: one literal per row, read as a table.
38
+ @Suppress("LongMethod")
37
39
  @Bean
38
40
  fun seedDataRunner(
39
41
  orderRepository: OrderRepository,
@@ -107,6 +109,8 @@ class Application {
107
109
  }
108
110
  }
109
111
 
112
+ // Standard Spring Boot entry point; the copy of args happens once at startup.
113
+ @Suppress("SpreadOperator")
110
114
  fun main(args: Array<String>) {
111
115
  runApplication<Application>(*args)
112
116
  }
@@ -162,6 +162,9 @@ class DocumentController(
162
162
  }
163
163
 
164
164
  @RestController
165
+ // Each endpoint keeps the planted vulnerable branch and the remediated branch side by side,
166
+ // with early returns, so the demo shows the before and after in one place.
167
+ @Suppress("ReturnCount")
165
168
  @RequestMapping("/api/v1/test")
166
169
  class LegacyTestController(
167
170
  private val orderRepository: OrderRepository,