trace-sec 2.1.9 → 2.1.11
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/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/trace_engine/cli.py +59 -12
- package/src/trace_engine/findings/correlate.py +7 -1
- package/src/trace_engine/findings/recommendations.py +122 -5
- package/src/trace_engine/framework/express.py +243 -82
- package/src/trace_engine/framework/nextjs.py +54 -15
- package/src/trace_engine/framework/react_router.py +27 -32
- package/src/trace_engine/interactive.py +17 -7
- package/src/trace_engine/security/hypotheses.py +36 -8
- package/src/trace_engine/testpacks/authentication.py +7 -1
- package/src/trace_engine/testpacks/bfla.py +7 -1
- package/src/trace_engine/testpacks/bola.py +5 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trace-sec",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.11",
|
|
4
4
|
"description": "Threat Reconnaissance & Attack-path Correlation Engine — Security Verification Layer for Coding Agents with Fine-Tuned SecureBERT 2.0 & Laya AI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "bin/trace.js",
|
package/pyproject.toml
CHANGED
package/src/trace_engine/cli.py
CHANGED
|
@@ -289,22 +289,69 @@ def test(
|
|
|
289
289
|
active_tokens={"user-a": "user-a", "user-b": "user-b", "admin": "admin"},
|
|
290
290
|
)
|
|
291
291
|
|
|
292
|
-
|
|
292
|
+
# Check target connectivity
|
|
293
|
+
target_online = False
|
|
294
|
+
try:
|
|
295
|
+
import httpx
|
|
296
|
+
with httpx.Client(timeout=1.0) as chk:
|
|
297
|
+
chk.get(target)
|
|
298
|
+
target_online = True
|
|
299
|
+
except Exception:
|
|
300
|
+
target_online = False
|
|
301
|
+
|
|
302
|
+
if target_online:
|
|
303
|
+
console.print(f"\n[bold green]Executing Controlled Runtime Tests[/bold green] against [white]{target}[/white]...")
|
|
304
|
+
else:
|
|
305
|
+
console.print(f"\n[yellow]Notice:[/yellow] Target runtime [white]{target}[/white] is offline or unreachable.")
|
|
306
|
+
console.print(f" [dim]• Active HTTP exploit probes require a running service (e.g. localhost or lab testbed).[/dim]")
|
|
307
|
+
console.print(f" [dim]• Auditing codebase via Neuro-Symbolic Attack-Path Model (Safe Offline Mode)...[/dim]\n")
|
|
308
|
+
|
|
293
309
|
executed = 0
|
|
294
310
|
confirmed = 0
|
|
311
|
+
correlator = EvidenceCorrelator()
|
|
312
|
+
findings: List[Finding] = []
|
|
295
313
|
|
|
296
314
|
for hyp in hypotheses:
|
|
297
|
-
|
|
298
|
-
if
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
315
|
+
test_res = None
|
|
316
|
+
if target_online:
|
|
317
|
+
pack = default_registry.get(hyp.recommended_test_pack)
|
|
318
|
+
if pack:
|
|
319
|
+
executed += 1
|
|
320
|
+
try:
|
|
321
|
+
test_res = pack.execute(hyp, client, context)
|
|
322
|
+
if test_res and test_res.confirmed:
|
|
323
|
+
confirmed += 1
|
|
324
|
+
console.print(f" [bold red]CONFIRMED:[/bold red] [{hyp.id}] {test_res.summary}")
|
|
325
|
+
else:
|
|
326
|
+
console.print(f" [dim]Passed / Inconclusive:[/dim] [{hyp.id}] {test_res.summary}")
|
|
327
|
+
except Exception:
|
|
328
|
+
test_res = None
|
|
329
|
+
|
|
330
|
+
finding = correlator.correlate(hyp, test_res, graph_model)
|
|
331
|
+
if finding:
|
|
332
|
+
findings.append(finding)
|
|
333
|
+
|
|
334
|
+
# Persist findings & APM
|
|
335
|
+
trace_dir = init_trace_dir(project_dir)
|
|
336
|
+
save_apm_sqlite(graph_model, trace_dir / "graph.db")
|
|
337
|
+
store = FindingStore(trace_dir)
|
|
338
|
+
store.save_findings(findings)
|
|
339
|
+
|
|
340
|
+
try:
|
|
341
|
+
root_trace = Path(".trace")
|
|
342
|
+
root_trace.mkdir(parents=True, exist_ok=True)
|
|
343
|
+
(root_trace / "last_scan_repo.txt").write_text(str(project_dir), encoding="utf-8")
|
|
344
|
+
if project_dir != Path(".").resolve():
|
|
345
|
+
root_store = FindingStore(root_trace)
|
|
346
|
+
root_store.save_findings(findings)
|
|
347
|
+
except Exception:
|
|
348
|
+
pass
|
|
349
|
+
|
|
350
|
+
if target_online:
|
|
351
|
+
console.print(f"\n[dim]Executed {executed} test packs. {confirmed} attack paths confirmed at runtime.[/dim]\n")
|
|
352
|
+
|
|
353
|
+
print_findings_table(findings)
|
|
354
|
+
console.print(f"[dim]Total findings identified: {len(findings)} (Saved to {trace_dir / 'findings.json'})[/dim]\n")
|
|
308
355
|
|
|
309
356
|
|
|
310
357
|
@app.command()
|
|
@@ -67,6 +67,10 @@ class EvidenceCorrelator:
|
|
|
67
67
|
runtime_evidence.append(
|
|
68
68
|
f"HTTP {obs.request_method} {obs.request_url} -> Status {obs.response_status} ({obs.latency_ms}ms)"
|
|
69
69
|
)
|
|
70
|
+
else:
|
|
71
|
+
runtime_evidence.append(
|
|
72
|
+
"Static Attack-Path Model verified vulnerable control boundary. Active dynamic probe was not executed (offline scan mode or live target host not specified)."
|
|
73
|
+
)
|
|
70
74
|
|
|
71
75
|
# Correlation explanation
|
|
72
76
|
if test_result and test_result.confirmed:
|
|
@@ -85,6 +89,8 @@ class EvidenceCorrelator:
|
|
|
85
89
|
ep_node = apm.get_node(hypothesis.endpoint_id)
|
|
86
90
|
source_loc = ep_node.location if ep_node else None
|
|
87
91
|
|
|
92
|
+
file_path = source_loc.file if source_loc else ""
|
|
93
|
+
|
|
88
94
|
return Finding(
|
|
89
95
|
id=finding_id,
|
|
90
96
|
title=hypothesis.title,
|
|
@@ -97,7 +103,7 @@ class EvidenceCorrelator:
|
|
|
97
103
|
static_evidence=hypothesis.static_evidence,
|
|
98
104
|
runtime_evidence=runtime_evidence,
|
|
99
105
|
correlation_notes=correlation_notes,
|
|
100
|
-
remediation=get_remediation_for_category(category_str),
|
|
106
|
+
remediation=get_remediation_for_category(category_str, filepath=file_path),
|
|
101
107
|
reproduction_steps=test_result.reproduction_steps if test_result else [],
|
|
102
108
|
observations=test_result.observations if test_result else [],
|
|
103
109
|
)
|
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
"""Remediation recommendations for security findings."""
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
REMEDIATION_MAP_PYTHON = {
|
|
4
7
|
"BOLA": (
|
|
5
8
|
"Enforce strict object ownership validation before returning records. "
|
|
6
9
|
"Query using both the object ID and the authenticated user/tenant ID (e.g. `WHERE id = :id AND user_id = :current_user.id`)."
|
|
7
10
|
),
|
|
8
11
|
"BFLA": (
|
|
9
|
-
"Implement declarative role-based access control (RBAC)
|
|
12
|
+
"Implement declarative role-based access control (RBAC) dependencies (e.g. `Security(require_role('admin'))`). "
|
|
10
13
|
"Validate user privileges on the backend for every privileged/administrative route."
|
|
11
14
|
),
|
|
12
15
|
"AUTHENTICATION": (
|
|
@@ -25,11 +28,125 @@ REMEDIATION_MAP = {
|
|
|
25
28
|
"Define explicit request DTO schemas (e.g. Pydantic models with only allowed mutable fields). "
|
|
26
29
|
"Do not allow raw dictionary binding directly into database models."
|
|
27
30
|
),
|
|
31
|
+
"PATH_TRAVERSAL": (
|
|
32
|
+
"Sanitize file path inputs using `os.path.basename()` or `pathlib.Path.resolve()`, "
|
|
33
|
+
"and verify that the resolved path is strictly within the allowed directory. Disallow path traversal sequences (`..`)."
|
|
34
|
+
),
|
|
35
|
+
"SSTI": (
|
|
36
|
+
"Avoid rendering untrusted user inputs directly inside template engines. "
|
|
37
|
+
"Use contextual output encoding or sandbox template evaluation environments."
|
|
38
|
+
),
|
|
39
|
+
"CORS": (
|
|
40
|
+
"Do not reflect arbitrary Origin headers or use wildcard `*` with credentials. "
|
|
41
|
+
"Configure an explicit allowlist of trusted origins."
|
|
42
|
+
),
|
|
43
|
+
"DESERIALIZATION": (
|
|
44
|
+
"Avoid deserializing untrusted payloads (e.g. `pickle.loads`). "
|
|
45
|
+
"Use safe serialization formats like standard JSON with strict schema validation."
|
|
46
|
+
),
|
|
28
47
|
}
|
|
29
48
|
|
|
49
|
+
REMEDIATION_MAP_NODE = {
|
|
50
|
+
"BOLA": (
|
|
51
|
+
"Enforce strict object ownership validation before returning or modifying records. "
|
|
52
|
+
"Scope queries using both the object ID and the authenticated user/tenant identity (e.g. `findByIdAndUserId(req.params.id, req.user.id)` or `WHERE id = ? AND tenant_id = ?`)."
|
|
53
|
+
),
|
|
54
|
+
"BFLA": (
|
|
55
|
+
"Implement role-based access control (RBAC) middleware (e.g. `requireRole('admin')` or `checkPermission(...)`). "
|
|
56
|
+
"Validate `req.user.role` on the backend before executing privileged operations."
|
|
57
|
+
),
|
|
58
|
+
"AUTHENTICATION": (
|
|
59
|
+
"Mount authentication middleware (e.g. `router.use(verifyToken)` or `app.use('/api/...', verifyToken, router)`) "
|
|
60
|
+
"to ensure unauthorized users cannot trigger state-changing or sensitive operations."
|
|
61
|
+
),
|
|
62
|
+
"SSRF": (
|
|
63
|
+
"Avoid making outbound HTTP requests (e.g. with `axios` or `fetch`) using user-controlled URLs. "
|
|
64
|
+
"Implement a strict destination domain allowlist and block loopback (`127.0.0.1`, `localhost`) and private RFC-1918 IP addresses."
|
|
65
|
+
),
|
|
66
|
+
"INJECTION": (
|
|
67
|
+
"Use parameterized queries, prepared statements, or ORM abstractions (e.g. Prisma, Mongoose, TypeORM, Knex). "
|
|
68
|
+
"Never concatenate or interpolate raw `req.body` or `req.query` into database queries or shell commands."
|
|
69
|
+
),
|
|
70
|
+
"MASS_ASSIGNMENT": (
|
|
71
|
+
"Define explicit request DTO schemas (e.g. using Zod, Joi, or TypeScript DTOs) and pick allowlisted fields from `req.body`. "
|
|
72
|
+
"Never pass raw `req.body` directly into database creation or update functions."
|
|
73
|
+
),
|
|
74
|
+
"PATH_TRAVERSAL": (
|
|
75
|
+
"Sanitize file path inputs using `path.basename()` or `path.resolve()`, "
|
|
76
|
+
"and verify that the target path strictly resides within the intended root directory (e.g. `resolvedPath.startsWith(BASE_DIR)`). Disallow directory traversal sequences (`..`)."
|
|
77
|
+
),
|
|
78
|
+
"SSTI": (
|
|
79
|
+
"Avoid rendering untrusted user inputs directly inside template engines. "
|
|
80
|
+
"Use contextual output encoding or sandbox template evaluation environments."
|
|
81
|
+
),
|
|
82
|
+
"CORS": (
|
|
83
|
+
"Do not reflect the `Origin` request header or allow `Access-Control-Allow-Origin: *` with `Access-Control-Allow-Credentials: true`. "
|
|
84
|
+
"Configure an explicit allowlist of trusted origins."
|
|
85
|
+
),
|
|
86
|
+
"DESERIALIZATION": (
|
|
87
|
+
"Avoid deserializing untrusted payloads (e.g. `node-serialize`). "
|
|
88
|
+
"Use safe data serialization formats like standard `JSON.parse` with strict schema validation."
|
|
89
|
+
),
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
REMEDIATION_MAP_GO = {
|
|
93
|
+
"BOLA": (
|
|
94
|
+
"Enforce strict tenant and user ownership validation. "
|
|
95
|
+
"Scope database queries to the authenticated user ID from context (e.g. `db.Where(\"id = ? AND user_id = ?\", id, currentUserID)`)."
|
|
96
|
+
),
|
|
97
|
+
"BFLA": (
|
|
98
|
+
"Implement role-based authorization checks in Gin/Echo middleware. "
|
|
99
|
+
"Verify user claims from context before proceeding with administrative actions."
|
|
100
|
+
),
|
|
101
|
+
"AUTHENTICATION": (
|
|
102
|
+
"Protect this route with authentication middleware (e.g. Gin/Echo middleware validating JWT/session token into context) "
|
|
103
|
+
"to ensure unauthorized users cannot trigger state-changing operations."
|
|
104
|
+
),
|
|
105
|
+
"SSRF": (
|
|
106
|
+
"Validate target URLs against an allowlist and block private IP ranges before executing `http.Get` or `http.Post`."
|
|
107
|
+
),
|
|
108
|
+
"INJECTION": (
|
|
109
|
+
"Use parameterized SQL placeholders (`?` or `$1`) with `database/sql` or GORM. Never concatenate input into raw SQL queries."
|
|
110
|
+
),
|
|
111
|
+
"MASS_ASSIGNMENT": (
|
|
112
|
+
"Bind incoming requests to dedicated request structs with strictly permitted fields instead of raw map or model structs."
|
|
113
|
+
),
|
|
114
|
+
"PATH_TRAVERSAL": (
|
|
115
|
+
"Use `filepath.Clean` and `filepath.Rel` to verify file paths stay within the target base directory."
|
|
116
|
+
),
|
|
117
|
+
"SSTI": "Ensure HTML templates use context-aware escaping (`html/template`).",
|
|
118
|
+
"CORS": "Configure explicit origins in CORS middleware. Do not allow wildcard origins with credentials.",
|
|
119
|
+
"DESERIALIZATION": "Use standard `encoding/json` with typed Go structs for decoding untrusted client data.",
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
# Default backwards-compatible map
|
|
123
|
+
REMEDIATION_MAP = REMEDIATION_MAP_PYTHON
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def get_remediation_for_category(category: str, filepath: str = "", framework: str = "") -> str:
|
|
127
|
+
"""Returns framework- and language-tailored remediation recommendations."""
|
|
128
|
+
cat_upper = category.upper()
|
|
129
|
+
file_lower = filepath.lower()
|
|
130
|
+
|
|
131
|
+
# Detect language / framework
|
|
132
|
+
if any(file_lower.endswith(ext) for ext in (".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs")) or framework.lower() in ("express", "nextjs", "react_router", "node"):
|
|
133
|
+
return REMEDIATION_MAP_NODE.get(
|
|
134
|
+
cat_upper,
|
|
135
|
+
"Review access control, authentication middleware, and input validation on this attack path.",
|
|
136
|
+
)
|
|
137
|
+
elif file_lower.endswith(".go") or framework.lower() == "go":
|
|
138
|
+
return REMEDIATION_MAP_GO.get(
|
|
139
|
+
cat_upper,
|
|
140
|
+
"Review access control and input validation on this attack path.",
|
|
141
|
+
)
|
|
142
|
+
elif file_lower.endswith(".py") or framework.lower() in ("fastapi", "flask", "django"):
|
|
143
|
+
return REMEDIATION_MAP_PYTHON.get(
|
|
144
|
+
cat_upper,
|
|
145
|
+
"Review access control and input validation on this attack path.",
|
|
146
|
+
)
|
|
30
147
|
|
|
31
|
-
|
|
32
|
-
return
|
|
33
|
-
|
|
148
|
+
# Fallback to general remediation
|
|
149
|
+
return REMEDIATION_MAP_NODE.get(cat_upper) or REMEDIATION_MAP_PYTHON.get(
|
|
150
|
+
cat_upper,
|
|
34
151
|
"Review access control and input validation on this attack path.",
|
|
35
152
|
)
|
|
@@ -1,82 +1,243 @@
|
|
|
1
|
-
"""Express.js framework adapter for endpoint discovery."""
|
|
2
|
-
|
|
3
|
-
import re
|
|
4
|
-
from
|
|
5
|
-
from
|
|
6
|
-
from trace_engine.
|
|
7
|
-
from trace_engine.
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
def
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
#
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
)
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
)
|
|
81
|
-
|
|
82
|
-
|
|
1
|
+
"""Express.js framework adapter for endpoint discovery."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import List, Dict, Tuple, Optional
|
|
6
|
+
from trace_engine.parsing.parser import ParsedFile
|
|
7
|
+
from trace_engine.framework.base import FrameworkAdapter, Endpoint, EndpointParameter
|
|
8
|
+
from trace_engine.parsing.locations import SourceLocation
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ExpressFrameworkAdapter(FrameworkAdapter):
|
|
12
|
+
"""Detects and extracts Express.js routes and middleware."""
|
|
13
|
+
|
|
14
|
+
def __init__(self):
|
|
15
|
+
# Cache for parsed server/entrypoint mount configurations: {server_file_abs_path: {router_stem: (prefix, has_auth)}}
|
|
16
|
+
self._server_mount_cache: Dict[str, Dict[str, Tuple[str, bool]]] = {}
|
|
17
|
+
|
|
18
|
+
def can_handle(self, parsed_file: ParsedFile) -> bool:
|
|
19
|
+
if parsed_file.language not in ("javascript", "typescript"):
|
|
20
|
+
return False
|
|
21
|
+
# Express route files must import express
|
|
22
|
+
return any("express" in imp.module.lower() for imp in parsed_file.imports)
|
|
23
|
+
|
|
24
|
+
def _discover_server_mounts(self, server_path: Path) -> Dict[str, Tuple[str, bool]]:
|
|
25
|
+
"""Parses server.js / app.js to find router mounts like app.use('/api/discovery', discoveryRoutes)."""
|
|
26
|
+
resolved_key = str(server_path.resolve())
|
|
27
|
+
if resolved_key in self._server_mount_cache:
|
|
28
|
+
return self._server_mount_cache[resolved_key]
|
|
29
|
+
|
|
30
|
+
mount_map: Dict[str, Tuple[str, bool]] = {}
|
|
31
|
+
try:
|
|
32
|
+
content = server_path.read_text(encoding="utf-8", errors="replace")
|
|
33
|
+
except Exception:
|
|
34
|
+
self._server_mount_cache[resolved_key] = mount_map
|
|
35
|
+
return mount_map
|
|
36
|
+
|
|
37
|
+
# 1. Map variable names to imported file stems
|
|
38
|
+
# e.g., const discoveryRoutes = require('./routes/discoveryRoutes')
|
|
39
|
+
# or import discoveryRoutes from './routes/discoveryRoutes'
|
|
40
|
+
var_to_stem: Dict[str, str] = {}
|
|
41
|
+
import_pattern = re.compile(
|
|
42
|
+
r"""(?:const|let|var|import)\s+([a-zA-Z0-9_$]+)\s*(?:=\s*require\s*\(\s*['"]([^'"]+)['"]\s*\)|from\s*['"]([^'"]+)['"])""",
|
|
43
|
+
re.IGNORECASE,
|
|
44
|
+
)
|
|
45
|
+
for m in import_pattern.finditer(content):
|
|
46
|
+
var_name = m.group(1).lower()
|
|
47
|
+
imp_path = m.group(2) or m.group(3) or ""
|
|
48
|
+
stem = imp_path.replace("\\", "/").split("/")[-1].split(".")[0].lower()
|
|
49
|
+
if stem:
|
|
50
|
+
var_to_stem[var_name] = stem
|
|
51
|
+
var_to_stem[stem] = stem
|
|
52
|
+
|
|
53
|
+
# 2. Extract app.use('/prefix', ...) statements
|
|
54
|
+
# e.g., app.use('/api/discovery', discoveryRoutes);
|
|
55
|
+
# or app.use('/api/discovery', verifyToken, discoveryRoutes);
|
|
56
|
+
# or app.use('/api/discovery', require('./routes/discoveryRoutes'));
|
|
57
|
+
use_pattern = re.compile(
|
|
58
|
+
r"""(?:app|server|api|router)\.use\s*\(\s*["']([^"']+)["']\s*,\s*([^;]+)\)""",
|
|
59
|
+
re.IGNORECASE | re.DOTALL,
|
|
60
|
+
)
|
|
61
|
+
for m in use_pattern.finditer(content):
|
|
62
|
+
raw_prefix = m.group(1).strip()
|
|
63
|
+
prefix = "/" + raw_prefix.strip("/") if raw_prefix.strip("/") else ""
|
|
64
|
+
args_str = m.group(2)
|
|
65
|
+
|
|
66
|
+
args = [a.strip() for a in args_str.split(",") if a.strip()]
|
|
67
|
+
if not args:
|
|
68
|
+
continue
|
|
69
|
+
|
|
70
|
+
router_arg = args[-1]
|
|
71
|
+
middleware_args = args[:-1]
|
|
72
|
+
|
|
73
|
+
has_auth = any(
|
|
74
|
+
any(sig in m_arg.lower() for sig in ("auth", "jwt", "passport", "verifytoken", "isauthenticated", "requireauth"))
|
|
75
|
+
for m_arg in middleware_args
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
# Determine target router stem
|
|
79
|
+
target_stem = ""
|
|
80
|
+
inline_req = re.search(r"""require\s*\(\s*['"]([^'"]+)['"]\s*\)""", router_arg)
|
|
81
|
+
if inline_req:
|
|
82
|
+
req_path = inline_req.group(1)
|
|
83
|
+
target_stem = req_path.replace("\\", "/").split("/")[-1].split(".")[0].lower()
|
|
84
|
+
else:
|
|
85
|
+
var_key = router_arg.strip().split("(")[0].strip().lower()
|
|
86
|
+
target_stem = var_to_stem.get(var_key, var_key)
|
|
87
|
+
|
|
88
|
+
if target_stem:
|
|
89
|
+
mount_map[target_stem] = (prefix, has_auth)
|
|
90
|
+
core_stem = re.sub(r'routes?$', '', target_stem)
|
|
91
|
+
if core_stem and core_stem != target_stem:
|
|
92
|
+
mount_map[core_stem] = (prefix, has_auth)
|
|
93
|
+
|
|
94
|
+
self._server_mount_cache[resolved_key] = mount_map
|
|
95
|
+
return mount_map
|
|
96
|
+
|
|
97
|
+
def _resolve_mount_prefix_and_auth(self, parsed_file: ParsedFile) -> Tuple[str, bool]:
|
|
98
|
+
"""Resolves the mount prefix and any mount-level authentication middleware for this file."""
|
|
99
|
+
norm_path = parsed_file.file_path.replace("\\", "/")
|
|
100
|
+
filename = norm_path.split("/")[-1]
|
|
101
|
+
stem = filename.rsplit(".", 1)[0].lower()
|
|
102
|
+
core_stem = re.sub(r'routes?$', '', stem)
|
|
103
|
+
|
|
104
|
+
# Candidate directories to look for server files
|
|
105
|
+
start_path = Path(parsed_file.absolute_path or parsed_file.file_path).resolve()
|
|
106
|
+
candidate_dirs = [start_path.parent]
|
|
107
|
+
for p in start_path.parents:
|
|
108
|
+
candidate_dirs.append(p)
|
|
109
|
+
if len(candidate_dirs) > 5:
|
|
110
|
+
break
|
|
111
|
+
|
|
112
|
+
server_names = (
|
|
113
|
+
"server.js", "server.ts", "server.mjs", "server.cjs",
|
|
114
|
+
"app.js", "app.ts", "app.mjs", "app.cjs",
|
|
115
|
+
"index.js", "index.ts", "index.mjs", "index.cjs",
|
|
116
|
+
"main.js", "main.ts",
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
for cdir in candidate_dirs:
|
|
120
|
+
# Check server files directly in cdir
|
|
121
|
+
for sname in server_names:
|
|
122
|
+
candidate_file = cdir / sname
|
|
123
|
+
if candidate_file.is_file():
|
|
124
|
+
mounts = self._discover_server_mounts(candidate_file)
|
|
125
|
+
if stem in mounts:
|
|
126
|
+
return mounts[stem]
|
|
127
|
+
if core_stem in mounts:
|
|
128
|
+
return mounts[core_stem]
|
|
129
|
+
for k, (pref, has_auth) in mounts.items():
|
|
130
|
+
if k in norm_path.lower():
|
|
131
|
+
return (pref, has_auth)
|
|
132
|
+
|
|
133
|
+
# Check subdirectories like backend/ or src/
|
|
134
|
+
for subdir in ("backend", "server", "src"):
|
|
135
|
+
for sname in server_names:
|
|
136
|
+
candidate_file = cdir / subdir / sname
|
|
137
|
+
if candidate_file.is_file():
|
|
138
|
+
mounts = self._discover_server_mounts(candidate_file)
|
|
139
|
+
if stem in mounts:
|
|
140
|
+
return mounts[stem]
|
|
141
|
+
if core_stem in mounts:
|
|
142
|
+
return mounts[core_stem]
|
|
143
|
+
for k, (pref, has_auth) in mounts.items():
|
|
144
|
+
if k in norm_path.lower():
|
|
145
|
+
return (pref, has_auth)
|
|
146
|
+
|
|
147
|
+
return ("", False)
|
|
148
|
+
|
|
149
|
+
def extract_endpoints(self, parsed_file: ParsedFile, content: str) -> List[Endpoint]:
|
|
150
|
+
endpoints: List[Endpoint] = []
|
|
151
|
+
|
|
152
|
+
# Multi-line regex supporting app, router, server, api
|
|
153
|
+
pattern = re.compile(
|
|
154
|
+
r"""(?:app|router|server|api)\.(get|post|put|delete|patch)\s*\(\s*["']([^"']+)["']\s*(?:,\s*([^)]+))?""",
|
|
155
|
+
re.IGNORECASE | re.DOTALL,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
content_lower = content.lower()
|
|
159
|
+
has_db_sink = any(
|
|
160
|
+
term in content_lower
|
|
161
|
+
for term in ("mongoose", "prisma", "sequelize", "knex", "pool.query", "db.query", "db.collection")
|
|
162
|
+
)
|
|
163
|
+
has_ext_sink = any(
|
|
164
|
+
term in content_lower
|
|
165
|
+
for term in ("axios", "node-fetch", "fetch(", "needle", "got(", "http.request", "https.request")
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
# File-level router middleware: e.g. router.use(verifyToken)
|
|
169
|
+
file_has_auth = bool(
|
|
170
|
+
re.search(
|
|
171
|
+
r"""(?:router|app)\.use\s*\(\s*[^)]*(?:auth|jwt|passport|verifytoken|isauthenticated|requireauth)""",
|
|
172
|
+
content,
|
|
173
|
+
re.IGNORECASE,
|
|
174
|
+
)
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
# Discover parent mount prefix and mount-level auth
|
|
178
|
+
mount_prefix, mount_has_auth = self._resolve_mount_prefix_and_auth(parsed_file)
|
|
179
|
+
|
|
180
|
+
for match in pattern.finditer(content):
|
|
181
|
+
method = match.group(1).upper()
|
|
182
|
+
raw_path = match.group(2)
|
|
183
|
+
handler_and_middleware = match.group(3) or ""
|
|
184
|
+
|
|
185
|
+
# Calculate accurate line number
|
|
186
|
+
line_no = content[:match.start()].count("\n") + 1
|
|
187
|
+
|
|
188
|
+
# Convert Express :id into {id}
|
|
189
|
+
norm_sub_path = re.sub(r":([a-zA-Z0-9_]+)", r"{\1}", raw_path)
|
|
190
|
+
path_params = re.findall(r":([a-zA-Z0-9_]+)", raw_path)
|
|
191
|
+
|
|
192
|
+
# Prepend mount prefix if available
|
|
193
|
+
if mount_prefix:
|
|
194
|
+
if norm_sub_path == "/" or not norm_sub_path:
|
|
195
|
+
final_path = mount_prefix
|
|
196
|
+
elif norm_sub_path.startswith(mount_prefix):
|
|
197
|
+
final_path = norm_sub_path
|
|
198
|
+
else:
|
|
199
|
+
final_path = f"{mount_prefix}/{norm_sub_path.lstrip('/')}"
|
|
200
|
+
else:
|
|
201
|
+
final_path = norm_sub_path
|
|
202
|
+
|
|
203
|
+
# Clean duplicate slashes
|
|
204
|
+
final_path = re.sub(r"/+", "/", final_path)
|
|
205
|
+
|
|
206
|
+
route_auth = any(
|
|
207
|
+
term in handler_and_middleware.lower()
|
|
208
|
+
for term in ("auth", "jwt", "passport", "verifytoken", "isauthenticated", "requireauth")
|
|
209
|
+
)
|
|
210
|
+
auth_required = file_has_auth or mount_has_auth or route_auth
|
|
211
|
+
roles = ["admin"] if "admin" in handler_and_middleware.lower() or "admin" in final_path.lower() else []
|
|
212
|
+
|
|
213
|
+
ep_id = f"ep_express_{parsed_file.file_path}_{line_no}_{method}".replace("/", "_").replace(".", "_")
|
|
214
|
+
|
|
215
|
+
endpoints.append(
|
|
216
|
+
Endpoint(
|
|
217
|
+
id=ep_id,
|
|
218
|
+
method=method,
|
|
219
|
+
path=final_path,
|
|
220
|
+
handler_name=f"route_handler_L{line_no}",
|
|
221
|
+
auth_required=auth_required,
|
|
222
|
+
roles=roles,
|
|
223
|
+
parameters=[
|
|
224
|
+
EndpointParameter(name=p, location="path", required=True)
|
|
225
|
+
for p in path_params
|
|
226
|
+
],
|
|
227
|
+
database_access=has_db_sink,
|
|
228
|
+
object_identifier=bool(path_params),
|
|
229
|
+
state_changing=method in ("POST", "PUT", "DELETE", "PATCH"),
|
|
230
|
+
external_network=has_ext_sink,
|
|
231
|
+
sensitive_data=any(
|
|
232
|
+
term in final_path.lower()
|
|
233
|
+
for term in ("admin", "user", "order", "deliverable", "workspace", "account", "profile")
|
|
234
|
+
),
|
|
235
|
+
source=SourceLocation(
|
|
236
|
+
file=parsed_file.file_path,
|
|
237
|
+
line_start=line_no,
|
|
238
|
+
line_end=line_no,
|
|
239
|
+
),
|
|
240
|
+
)
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
return endpoints
|
|
@@ -58,20 +58,44 @@ class NextJSFrameworkAdapter(FrameworkAdapter):
|
|
|
58
58
|
if parsed_file.language not in ("javascript", "typescript"):
|
|
59
59
|
return False
|
|
60
60
|
|
|
61
|
-
norm_path = parsed_file.file_path.replace("\\", "/")
|
|
61
|
+
norm_path = parsed_file.file_path.replace("\\", "/").lower()
|
|
62
62
|
|
|
63
|
-
#
|
|
64
|
-
if re.search(r'(?:^|/)
|
|
63
|
+
# 1. Reject build & tool configurations (e.g. next.config.ts, tailwind.config.js, etc.)
|
|
64
|
+
if re.search(r'(?:^|/)[a-zA-Z0-9_\.-]*\.config\.[a-zA-Z0-9]+$', norm_path):
|
|
65
|
+
return False
|
|
66
|
+
|
|
67
|
+
# 2. Reject UI component directories and non-API code
|
|
68
|
+
non_endpoint_dirs = (
|
|
69
|
+
"/components/", "/ui/", "/layouts/", "/widgets/",
|
|
70
|
+
"/hooks/", "/context/", "/styles/", "/types/",
|
|
71
|
+
"/lib/", "/utils/", "/helpers/", "/assets/", "/icons/"
|
|
72
|
+
)
|
|
73
|
+
if any(d in norm_path for d in non_endpoint_dirs) or any(norm_path.startswith(d.lstrip("/")) for d in non_endpoint_dirs):
|
|
74
|
+
return False
|
|
75
|
+
|
|
76
|
+
# 3. Reject client-side UI component suffixes (e.g. RegisterForm.tsx, DashboardHeader.tsx)
|
|
77
|
+
ui_suffixes = (
|
|
78
|
+
"form.tsx", "form.jsx", "header.tsx", "header.jsx",
|
|
79
|
+
"bar.tsx", "bar.jsx", "modal.tsx", "modal.jsx",
|
|
80
|
+
"card.tsx", "card.jsx", "layout.tsx", "layout.jsx",
|
|
81
|
+
"button.tsx", "button.jsx", "table.tsx", "table.jsx",
|
|
82
|
+
"view.tsx", "view.jsx", "provider.tsx", "provider.jsx"
|
|
83
|
+
)
|
|
84
|
+
if any(norm_path.endswith(s) for s in ui_suffixes):
|
|
85
|
+
return False
|
|
86
|
+
|
|
87
|
+
# 4. App Router: HTTP endpoints are strictly route.ts or route.js
|
|
88
|
+
if re.search(r'(?:^|/)(?:src/)?app/.+/route\.[a-zA-Z0-9]+$', norm_path):
|
|
65
89
|
return True
|
|
66
90
|
|
|
67
|
-
# Pages Router
|
|
68
|
-
if re.search(r'(?:^|/)(?:src/)?pages/
|
|
91
|
+
# 5. Pages Router: HTTP endpoints are strictly inside pages/api/
|
|
92
|
+
if re.search(r'(?:^|/)(?:src/)?pages/api/.+\.[a-zA-Z0-9]+$', norm_path):
|
|
69
93
|
return True
|
|
70
94
|
|
|
71
|
-
#
|
|
95
|
+
# 6. Only handle if explicitly importing next/server (e.g. NextRequest, NextResponse)
|
|
72
96
|
for imp in parsed_file.imports:
|
|
73
97
|
mod_low = imp.module.lower()
|
|
74
|
-
if mod_low == "next
|
|
98
|
+
if mod_low == "next/server":
|
|
75
99
|
return True
|
|
76
100
|
|
|
77
101
|
return False
|
|
@@ -79,16 +103,27 @@ class NextJSFrameworkAdapter(FrameworkAdapter):
|
|
|
79
103
|
def extract_endpoints(self, parsed_file: ParsedFile, content: str) -> List[Endpoint]:
|
|
80
104
|
endpoints: List[Endpoint] = []
|
|
81
105
|
norm_file_path = parsed_file.file_path.replace("\\", "/")
|
|
106
|
+
norm_lower = norm_file_path.lower()
|
|
82
107
|
|
|
83
|
-
|
|
84
|
-
|
|
108
|
+
# Reject client-side components with 'use client'
|
|
109
|
+
if "'use client'" in content or '"use client"' in content:
|
|
110
|
+
return []
|
|
111
|
+
|
|
112
|
+
# Reject build configurations
|
|
113
|
+
if ".config." in norm_lower:
|
|
114
|
+
return []
|
|
115
|
+
|
|
116
|
+
is_app_router = bool(re.search(r'(?:^|/)(?:src/)?app/.+/route\.[a-zA-Z0-9]+$', norm_lower))
|
|
117
|
+
is_pages_router = bool(re.search(r'(?:^|/)(?:src/)?pages/api/.+\.[a-zA-Z0-9]+$', norm_lower))
|
|
118
|
+
|
|
119
|
+
# In Next.js, only App Router route handlers and Pages Router API files are REST endpoints
|
|
120
|
+
if not is_app_router and not is_pages_router:
|
|
121
|
+
return []
|
|
85
122
|
|
|
86
123
|
if is_app_router:
|
|
87
124
|
url_path = self._infer_app_router_path(norm_file_path)
|
|
88
|
-
elif is_pages_router:
|
|
89
|
-
url_path = self._infer_pages_router_path(norm_file_path)
|
|
90
125
|
else:
|
|
91
|
-
url_path =
|
|
126
|
+
url_path = self._infer_pages_router_path(norm_file_path)
|
|
92
127
|
|
|
93
128
|
path_params = re.findall(r'\{([a-zA-Z0-9_]+)\}', url_path)
|
|
94
129
|
|
|
@@ -164,14 +199,18 @@ class NextJSFrameworkAdapter(FrameworkAdapter):
|
|
|
164
199
|
)
|
|
165
200
|
)
|
|
166
201
|
|
|
167
|
-
# Pages Router
|
|
168
|
-
if not endpoints:
|
|
202
|
+
# Pages Router default export handler (strictly pages/api/*)
|
|
203
|
+
if not endpoints and is_pages_router:
|
|
169
204
|
# Check for checked HTTP methods in handler body
|
|
170
205
|
checked_methods = []
|
|
171
206
|
for m in re.finditer(r'''req\.method\s*===?\s*["'](GET|POST|PUT|DELETE|PATCH)["']''', content):
|
|
172
207
|
checked_methods.append(m.group(1).upper())
|
|
173
208
|
|
|
174
|
-
|
|
209
|
+
default_match = re.search(r'''export\s+default\s+(?:async\s+)?(?:function|const|class)?\s*([a-zA-Z0-9_$]+)?''', content)
|
|
210
|
+
if not default_match:
|
|
211
|
+
return endpoints
|
|
212
|
+
|
|
213
|
+
methods_to_add = set(checked_methods) if checked_methods else ["GET", "POST"]
|
|
175
214
|
|
|
176
215
|
default_match = re.search(r'''export\s+default\s+(?:async\s+)?(?:function|const|class)?\s*([a-zA-Z0-9_$]+)?''', content)
|
|
177
216
|
handler_name = default_match.group(1) if (default_match and default_match.group(1)) else "handler"
|
|
@@ -108,11 +108,30 @@ class ReactRouterFrameworkAdapter(FrameworkAdapter):
|
|
|
108
108
|
if parsed_file.language not in ("javascript", "typescript"):
|
|
109
109
|
return False
|
|
110
110
|
|
|
111
|
-
norm_path = parsed_file.file_path.replace("\\", "/")
|
|
111
|
+
norm_path = parsed_file.file_path.replace("\\", "/").lower()
|
|
112
112
|
basename = norm_path.split("/")[-1]
|
|
113
113
|
|
|
114
|
-
# Ignore configuration files and hidden/dot files
|
|
115
|
-
if "config" in basename
|
|
114
|
+
# Ignore configuration files, test files, and hidden/dot files
|
|
115
|
+
if "config" in basename or basename.startswith(".") or ".test." in basename or ".spec." in basename:
|
|
116
|
+
return False
|
|
117
|
+
|
|
118
|
+
# Reject Express / Koa / Fastify backend route files
|
|
119
|
+
for imp in parsed_file.imports:
|
|
120
|
+
imp_low = imp.module.lower()
|
|
121
|
+
if imp_low in ("express", "koa", "fastify", "hapi", "@nestjs/common"):
|
|
122
|
+
return False
|
|
123
|
+
|
|
124
|
+
# Reject backend directories (Express / Node.js backend controllers are not React Router)
|
|
125
|
+
if any(norm_path.startswith(d) or f"/{d}" in norm_path for d in ("backend/", "server/", "api/", "controllers/", "srv/")):
|
|
126
|
+
return False
|
|
127
|
+
|
|
128
|
+
# Reject UI component directories and non-route code
|
|
129
|
+
non_route_dirs = (
|
|
130
|
+
"/components/", "/ui/", "/layouts/", "/widgets/",
|
|
131
|
+
"/hooks/", "/context/", "/styles/", "/types/",
|
|
132
|
+
"/lib/", "/utils/", "/helpers/", "/assets/", "/icons/"
|
|
133
|
+
)
|
|
134
|
+
if any(d in norm_path for d in non_route_dirs) or any(norm_path.startswith(d.lstrip("/")) for d in non_route_dirs):
|
|
116
135
|
return False
|
|
117
136
|
|
|
118
137
|
# If it's the routes configuration file itself
|
|
@@ -127,10 +146,9 @@ class ReactRouterFrameworkAdapter(FrameworkAdapter):
|
|
|
127
146
|
if key in self._route_cache:
|
|
128
147
|
return True
|
|
129
148
|
|
|
130
|
-
#
|
|
131
|
-
if
|
|
132
|
-
|
|
133
|
-
if any(f"/{d}/" in norm_path for d in ("styles", "assets", "types", "+types")):
|
|
149
|
+
# Only match files strictly in frontend routes directory (app/routes/ or src/routes/)
|
|
150
|
+
if re.search(r'(?:^|/)(?:src/|app/)?routes/.+\.[a-zA-Z0-9]+$', norm_path):
|
|
151
|
+
if any(f"/{d}/" in norm_path for d in ("styles", "assets", "types", "+types", "components")):
|
|
134
152
|
return False
|
|
135
153
|
return True
|
|
136
154
|
|
|
@@ -303,29 +321,6 @@ class ReactRouterFrameworkAdapter(FrameworkAdapter):
|
|
|
303
321
|
)
|
|
304
322
|
)
|
|
305
323
|
|
|
306
|
-
# If file
|
|
307
|
-
#
|
|
308
|
-
if not endpoints:
|
|
309
|
-
default_match = re.search(r'''export\s+default\s+(?:function|const|class)?\s*([a-zA-Z0-9_$]+)?''', content)
|
|
310
|
-
handler_name = default_match.group(1) if (default_match and default_match.group(1)) else "default"
|
|
311
|
-
line_no = content[:default_match.start()].count("\n") + 1 if default_match else 1
|
|
312
|
-
ep_id = f"ep_rr_page_{norm_file_path}_{line_no}".replace("/", "_").replace(".", "_")
|
|
313
|
-
endpoints.append(
|
|
314
|
-
Endpoint(
|
|
315
|
-
id=ep_id,
|
|
316
|
-
method="GET",
|
|
317
|
-
path=url_path,
|
|
318
|
-
handler_name=handler_name,
|
|
319
|
-
auth_required=auth_required,
|
|
320
|
-
roles=roles,
|
|
321
|
-
parameters=parameters,
|
|
322
|
-
database_access=has_db_sink,
|
|
323
|
-
object_identifier=bool(path_params),
|
|
324
|
-
state_changing=False,
|
|
325
|
-
external_network=has_ext_sink,
|
|
326
|
-
sensitive_data=sensitive_data,
|
|
327
|
-
source=SourceLocation(file=parsed_file.file_path, line_start=line_no, line_end=line_no),
|
|
328
|
-
)
|
|
329
|
-
)
|
|
330
|
-
|
|
324
|
+
# If file defines neither loader nor action nor explicit HTTP verbs, it is a pure UI component
|
|
325
|
+
# and has no backend server attack surface
|
|
331
326
|
return endpoints
|
|
@@ -438,13 +438,14 @@ def run_interactive_story() -> None:
|
|
|
438
438
|
display_story_header(4, 4, "Attack Vector Selection", "Select specific vulnerability testpacks to deploy")
|
|
439
439
|
|
|
440
440
|
pack_mapping = {
|
|
441
|
-
"1": ("
|
|
442
|
-
"2": ("
|
|
443
|
-
"3": ("
|
|
444
|
-
"4": ("
|
|
445
|
-
"5": ("
|
|
446
|
-
"6": ("
|
|
447
|
-
"7": ("
|
|
441
|
+
"1": ("Authentication & Auth Gates", "authentication", "Missing token validation & unauthenticated state-changing routes"),
|
|
442
|
+
"2": ("BOLA / IDOR", "bola", "Multi-tenant object access & IDOR cross-account authorization leak"),
|
|
443
|
+
"3": ("SQL & Command Injection", "injection", "Statistical timing oracle (p < 0.01) & error extraction"),
|
|
444
|
+
"4": ("SSRF ScopeGuard", "ssrf", "Egress network validation against RFC 1918 loopback & metadata IPs"),
|
|
445
|
+
"5": ("BFLA / RBAC Elevation", "bfla", "Broken function-level authorization & administrative privilege check"),
|
|
446
|
+
"6": ("Path Traversal", "path_traversal", "Canonical directory escape & arbitrary file disclosure probes"),
|
|
447
|
+
"7": ("CORS Misconfiguration", "cors", "Wildcard Access-Control-Allow-Origin & credential reflection"),
|
|
448
|
+
"8": ("Mass Assignment", "mass_assignment", "Unbounded payload parameter binding to persistent entities"),
|
|
448
449
|
}
|
|
449
450
|
|
|
450
451
|
console.print(f" [{BOLD_WHITE}]Available Testpacks:[/{BOLD_WHITE}]")
|
|
@@ -518,6 +519,15 @@ def run_interactive_story() -> None:
|
|
|
518
519
|
|
|
519
520
|
if selected_packs:
|
|
520
521
|
hypotheses = [h for h in all_hypotheses if h.recommended_test_pack in selected_packs]
|
|
522
|
+
if not hypotheses and all_hypotheses:
|
|
523
|
+
avail_cats = list({h.category.value for h in all_hypotheses})
|
|
524
|
+
console.print(
|
|
525
|
+
f"\n [{ORANGE}]Notice:[/{ORANGE}] [{WHITE}]No endpoints matched the selected testpack filter ({', '.join(selected_packs)}).[/{WHITE}]"
|
|
526
|
+
)
|
|
527
|
+
console.print(
|
|
528
|
+
f" [{DIM_WHITE}]Codebase contains attack vectors for: {', '.join(avail_cats)}. Auditing all codebase attack paths to prevent false negatives...[/{DIM_WHITE}]\n"
|
|
529
|
+
)
|
|
530
|
+
hypotheses = all_hypotheses
|
|
521
531
|
else:
|
|
522
532
|
hypotheses = all_hypotheses
|
|
523
533
|
|
|
@@ -89,7 +89,13 @@ class HypothesisEngine:
|
|
|
89
89
|
|
|
90
90
|
# 3. Authentication Bypass / Missing Auth: State-changing endpoint without auth
|
|
91
91
|
if not ep.auth_required and (ep.state_changing or ep.sensitive_data):
|
|
92
|
-
|
|
92
|
+
path_segs = [s.lower() for s in ep.path.strip("/").split("/")]
|
|
93
|
+
is_public_by_design = any(
|
|
94
|
+
s in ("login", "signin", "register", "signup", "forgot-password", "reset-password", "callback", "oauth", "health", "ping", "status", "public", "docs", "openapi")
|
|
95
|
+
or s.startswith("auth-callback") or s.endswith("-callback")
|
|
96
|
+
for s in path_segs
|
|
97
|
+
)
|
|
98
|
+
if not is_public_by_design:
|
|
93
99
|
hypotheses.append(
|
|
94
100
|
SecurityHypothesis(
|
|
95
101
|
id=f"HYP-AUTH-{hypo_idx:03d}",
|
|
@@ -109,8 +115,17 @@ class HypothesisEngine:
|
|
|
109
115
|
)
|
|
110
116
|
hypo_idx += 1
|
|
111
117
|
|
|
112
|
-
# 4. SSRF Hypothesis: Endpoint takes URL
|
|
113
|
-
|
|
118
|
+
# 4. SSRF Hypothesis: Endpoint takes user-supplied URL/target AND makes outbound HTTP calls
|
|
119
|
+
url_params = [
|
|
120
|
+
p.name for p in ep.parameters
|
|
121
|
+
if any(
|
|
122
|
+
term == p.name.lower()
|
|
123
|
+
or p.name.lower().endswith(f"_{term}")
|
|
124
|
+
or p.name.lower().endswith(term.capitalize())
|
|
125
|
+
for term in ("url", "uri", "target", "webhook", "callback", "dest", "destination", "endpoint", "feed", "proxy", "redirect_uri")
|
|
126
|
+
)
|
|
127
|
+
]
|
|
128
|
+
if ep.external_network and url_params:
|
|
114
129
|
hypotheses.append(
|
|
115
130
|
SecurityHypothesis(
|
|
116
131
|
id=f"HYP-SSRF-{hypo_idx:03d}",
|
|
@@ -118,12 +133,12 @@ class HypothesisEngine:
|
|
|
118
133
|
endpoint_id=ep.id,
|
|
119
134
|
endpoint_display=ep_disp,
|
|
120
135
|
title=f"Potential SSRF on {ep_disp}",
|
|
121
|
-
description="Endpoint
|
|
136
|
+
description="Endpoint accepts user-controlled destination URL and dispatches outbound HTTP requests.",
|
|
122
137
|
recommended_test_pack="ssrf",
|
|
123
138
|
confidence_prior=0.85,
|
|
124
139
|
static_evidence=[
|
|
125
140
|
"Outbound HTTP client calls detected in handler body",
|
|
126
|
-
f"URL
|
|
141
|
+
f"User-controlled URL parameters: {url_params}",
|
|
127
142
|
],
|
|
128
143
|
)
|
|
129
144
|
)
|
|
@@ -169,9 +184,22 @@ class HypothesisEngine:
|
|
|
169
184
|
)
|
|
170
185
|
hypo_idx += 1
|
|
171
186
|
|
|
172
|
-
# 7. Path Traversal Hypothesis: Endpoints accepting file or path parameters
|
|
173
|
-
file_param_names = [
|
|
174
|
-
|
|
187
|
+
# 7. Path Traversal Hypothesis: Endpoints accepting genuine file or directory path parameters
|
|
188
|
+
file_param_names = [
|
|
189
|
+
p.name for p in ep.parameters
|
|
190
|
+
if p.name.lower() in (
|
|
191
|
+
"file", "path", "filename", "file_name", "filepath", "file_path",
|
|
192
|
+
"doc_path", "document", "attachment", "dir_path", "download_path"
|
|
193
|
+
) or any(
|
|
194
|
+
p.name.lower().endswith(f"_{term}")
|
|
195
|
+
for term in ("file", "path", "filename", "filepath")
|
|
196
|
+
)
|
|
197
|
+
]
|
|
198
|
+
path_segments = [seg.lower() for seg in ep.path.strip("/").split("/")]
|
|
199
|
+
has_file_route = any(seg in ("file", "files", "download", "downloads", "static", "assets", "document", "documents") for seg in path_segments)
|
|
200
|
+
|
|
201
|
+
# Must have actual file parameter or file route with parameters
|
|
202
|
+
if file_param_names or (has_file_route and ep.parameters):
|
|
175
203
|
hypotheses.append(
|
|
176
204
|
SecurityHypothesis(
|
|
177
205
|
id=f"HYP-TRAV-{hypo_idx:03d}",
|
|
@@ -48,12 +48,18 @@ class AuthenticationTestPack(TestPack):
|
|
|
48
48
|
f"Send unauthenticated {method} request to {target_url}",
|
|
49
49
|
f"Received HTTP {obs.response_status} rather than expected 401/403",
|
|
50
50
|
]
|
|
51
|
+
elif obs.response_status == 0:
|
|
52
|
+
confirmed = False
|
|
53
|
+
summary = f"Target host unreachable ({obs.response_body}). Live probe inconclusive."
|
|
54
|
+
else:
|
|
55
|
+
confirmed = False
|
|
56
|
+
summary = f"Authentication enforced (server responded with HTTP {obs.response_status})."
|
|
51
57
|
|
|
52
58
|
return TestExecutionResult(
|
|
53
59
|
testpack_name=self.name,
|
|
54
60
|
hypothesis_id=hypothesis.id,
|
|
55
61
|
confirmed=confirmed,
|
|
56
|
-
confidence=0.90 if confirmed else 0.15,
|
|
62
|
+
confidence=0.90 if confirmed else (None if obs.response_status == 0 else 0.15),
|
|
57
63
|
summary=summary,
|
|
58
64
|
observations=observations,
|
|
59
65
|
reproduction_steps=steps,
|
|
@@ -58,12 +58,18 @@ class BflaTestPack(TestPack):
|
|
|
58
58
|
f"Invoke privileged endpoint {method} {target_url}",
|
|
59
59
|
f"Received HTTP {obs.response_status} indicating unauthorized function execution",
|
|
60
60
|
]
|
|
61
|
+
elif obs.response_status == 0:
|
|
62
|
+
confirmed = False
|
|
63
|
+
summary = f"Target host unreachable ({obs.response_body}). Live probe inconclusive."
|
|
64
|
+
else:
|
|
65
|
+
confirmed = False
|
|
66
|
+
summary = f"Function-level authorization enforced (received HTTP {obs.response_status})."
|
|
61
67
|
|
|
62
68
|
return TestExecutionResult(
|
|
63
69
|
testpack_name=self.name,
|
|
64
70
|
hypothesis_id=hypothesis.id,
|
|
65
71
|
confirmed=confirmed,
|
|
66
|
-
confidence=0.92 if confirmed else 0.20,
|
|
72
|
+
confidence=0.92 if confirmed else (None if obs.response_status == 0 else 0.20),
|
|
67
73
|
summary=summary,
|
|
68
74
|
observations=observations,
|
|
69
75
|
reproduction_steps=steps,
|
|
@@ -83,11 +83,15 @@ class BolaTestPack(TestPack):
|
|
|
83
83
|
]
|
|
84
84
|
break
|
|
85
85
|
|
|
86
|
+
any_reachable = any(obs.response_status != 0 for obs in observations)
|
|
87
|
+
if not confirmed and not any_reachable:
|
|
88
|
+
summary = "Target host unreachable. Dynamic probe inconclusive."
|
|
89
|
+
|
|
86
90
|
return TestExecutionResult(
|
|
87
91
|
testpack_name=self.name,
|
|
88
92
|
hypothesis_id=hypothesis.id,
|
|
89
93
|
confirmed=confirmed,
|
|
90
|
-
confidence=0.98 if confirmed else 0.20,
|
|
94
|
+
confidence=0.98 if confirmed else (None if not any_reachable else 0.20),
|
|
91
95
|
summary=summary,
|
|
92
96
|
observations=observations,
|
|
93
97
|
reproduction_steps=steps,
|