@hoanghainh/komagu 2.6.0 → 2.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +23 -0
- package/README.md +1 -0
- package/assess/lsa/knowledge/path-ssrf-patterns.md +19 -0
- package/assess/lsa/knowledge/severity-and-cwe.md +3 -1
- package/assess/lsa/scripts/find_path_ssrf.py +176 -0
- package/assess/lsa/scripts/findings_to_sarif.py +29 -0
- package/assess/lsa/scripts/merge_findings.py +1 -1
- package/assess/lsa/scripts/run_prescan.py +4 -0
- package/assess/lsa/tests/run_tests.py +4 -0
- package/assess/lsa/tests/test_path_ssrf.py +94 -0
- package/contracts/schemas/finding.schema.json +4 -2
- package/examples/member-portal/backend-dotnet/Auth/DemoAuthenticationHandler.cs +75 -0
- package/examples/member-portal/backend-dotnet/Program.cs +1 -72
- package/examples/member-portal/backend-spring/src/main/kotlin/io/komagu/portal/Application.kt +4 -0
- package/examples/member-portal/backend-spring/src/main/kotlin/io/komagu/portal/controller/Controllers.kt +3 -0
- package/examples/member-portal/backend-spring/src/main/kotlin/io/komagu/portal/model/Entities.kt +4 -0
- package/examples/member-portal/tests/test_d6_demo_app.py +4 -4
- package/examples/member-portal/tests/test_d6_remediation_verification.py +6 -6
- package/operate/README.md +6 -1
- package/operate/ai/client.py +5 -1
- package/operate/cli/__init__.py +1 -1
- package/operate/cli/commands/fix.py +47 -0
- package/operate/cli/commands/mcp.py +54 -0
- package/operate/cli/main.py +42 -4
- package/operate/cross_user_tester/runner.py +4 -1
- package/operate/devsecops/sbom.py +12 -4
- package/operate/mcp/__init__.py +11 -0
- package/operate/mcp/server.py +206 -0
- package/operate/mcp/tools.py +464 -0
- package/operate/remediator/ai_fixer.py +285 -0
- package/operate/remediator/runner.py +11 -0
- package/operate/remediator/strategies.py +64 -0
- package/operate/simulate/scenarios.py +11 -2
- package/operate/tests/test_ai_remediation.py +288 -0
- package/operate/tests/test_audit_minimum_versions.py +89 -0
- package/operate/tests/test_cli.py +1 -1
- package/operate/tests/test_mcp_server.py +380 -0
- package/operate/tests/test_scheme_and_xml_guards.py +67 -0
- package/package.json +1 -1
- package/remediate/authz/bfla_patcher.py +319 -0
- package/remediate/authz/dotnet/src/Komagu.Security.Authorization/LoggingSecurityAuditSink.cs +19 -3
- package/remediate/authz/golang/authz_test.go +5 -2
- package/remediate/authz/mass_assignment_patcher.py +188 -0
- package/remediate/authz/patcher.py +8 -0
- package/remediate/authz/spring/src/main/kotlin/io/komagu/security/authorization/DefaultKomaguSecurityManager.kt +74 -78
- package/remediate/authz/spring/src/main/kotlin/io/komagu/security/spring/OwnershipAspect.kt +129 -158
- package/remediate/authz/tests/test_bfla_and_mass_assignment_patcher.py +213 -0
- package/remediate/deps/patcher.py +2 -1
- package/remediate/edge/mock-backend/app.py +2 -1
- package/remediate/edge/tests/test_edge_security.py +5 -5
- package/remediate/ekyc/requirements.txt +1 -1
- package/remediate/identity/dotnet/src/Komagu.Security.Identity/OidcServiceCollectionExtensions.cs +19 -6
- package/remediate/identity/python/requirements.txt +2 -2
- package/remediate/identity/spring/src/main/kotlin/io/komagu/security/identity/KomaguIdentityAutoConfiguration.kt +31 -38
- package/remediate/legacy_shims/tests/test_legacy_shims.py +8 -8
- package/remediate/query/dotnet/src/Komagu.Security.Query/SafeSortAllowList.cs +5 -2
- package/remediate/query/spring/src/main/kotlin/io/komagu/security/query/SafeSortAllowList.kt +10 -11
- package/remediate/query/spring/src/main/kotlin/io/komagu/security/query/detekt/StringBuiltSqlQueryRule.kt +19 -24
package/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
|
|
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
|
-
}
|
package/examples/member-portal/backend-spring/src/main/kotlin/io/komagu/portal/Application.kt
CHANGED
|
@@ -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,
|