trace-sec 2.1.12 → 2.1.13
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
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trace-sec",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.13",
|
|
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
|
@@ -11,11 +11,11 @@ from trace_engine.apm.model import AttackPathModel
|
|
|
11
11
|
SEVERITY_BY_CATEGORY = {
|
|
12
12
|
"BOLA": Severity.HIGH,
|
|
13
13
|
"BFLA": Severity.HIGH,
|
|
14
|
-
"AUTHENTICATION": Severity.
|
|
14
|
+
"AUTHENTICATION": Severity.HIGH,
|
|
15
15
|
"SSRF": Severity.HIGH,
|
|
16
16
|
"INJECTION": Severity.CRITICAL,
|
|
17
17
|
"MASS_ASSIGNMENT": Severity.MEDIUM,
|
|
18
|
-
"PATH_TRAVERSAL": Severity.
|
|
18
|
+
"PATH_TRAVERSAL": Severity.MEDIUM,
|
|
19
19
|
"SSTI": Severity.CRITICAL,
|
|
20
20
|
"CORS": Severity.MEDIUM,
|
|
21
21
|
"DESERIALIZATION": Severity.CRITICAL,
|
|
@@ -84,7 +84,7 @@ class EvidenceCorrelator:
|
|
|
84
84
|
)
|
|
85
85
|
|
|
86
86
|
finding_id = hypothesis.id.replace("HYP-", "TR-")
|
|
87
|
-
severity = SEVERITY_BY_CATEGORY.get(category_str, Severity.MEDIUM)
|
|
87
|
+
severity = hypothesis.severity or SEVERITY_BY_CATEGORY.get(category_str, Severity.MEDIUM)
|
|
88
88
|
|
|
89
89
|
ep_node = apm.get_node(hypothesis.endpoint_id)
|
|
90
90
|
source_loc = ep_node.location if ep_node else None
|
|
@@ -1,135 +1,216 @@
|
|
|
1
|
-
"""FastAPI framework adapter for endpoint and attack-surface discovery."""
|
|
2
|
-
|
|
3
|
-
import re
|
|
4
|
-
from typing import List, Optional
|
|
5
|
-
from trace_engine.parsing.parser import ParsedFile
|
|
6
|
-
from trace_engine.framework.base import FrameworkAdapter, Endpoint, EndpointParameter
|
|
7
|
-
from trace_engine.parsing.locations import SourceLocation
|
|
8
|
-
|
|
9
|
-
HTTP_METHODS = {"get", "post", "put", "delete", "patch", "options", "head"}
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
class FastAPIFrameworkAdapter(FrameworkAdapter):
|
|
13
|
-
"""Detects and extracts FastAPI/Starlette routes and auth gates."""
|
|
14
|
-
|
|
15
|
-
def can_handle(self, parsed_file: ParsedFile) -> bool:
|
|
16
|
-
if parsed_file.language != "python":
|
|
17
|
-
return False
|
|
18
|
-
for imp in parsed_file.imports:
|
|
19
|
-
if "fastapi" in imp.module.lower() or "starlette" in imp.module.lower():
|
|
20
|
-
return True
|
|
21
|
-
return False
|
|
22
|
-
|
|
23
|
-
def extract_endpoints(self, parsed_file: ParsedFile, content: str) -> List[Endpoint]:
|
|
24
|
-
endpoints: List[Endpoint] = []
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
#
|
|
28
|
-
for fn in parsed_file.functions:
|
|
29
|
-
for dec in fn.decorators:
|
|
30
|
-
dec_text = dec.raw_text.strip()
|
|
31
|
-
# Pattern: @app.get('/path' ...)
|
|
32
|
-
match = re.search(
|
|
33
|
-
r"""@?(?:[a-zA-Z0-9_]+)\.(get|post|put|delete|patch)\s*\(\s*["']([^"']+)["']""",
|
|
34
|
-
dec_text,
|
|
35
|
-
re.IGNORECASE,
|
|
36
|
-
)
|
|
37
|
-
if match:
|
|
38
|
-
|
|
39
|
-
method =
|
|
40
|
-
path = match.group(2)
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
)
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
1
|
+
"""FastAPI framework adapter for endpoint and attack-surface discovery."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from typing import List, Optional
|
|
5
|
+
from trace_engine.parsing.parser import ParsedFile
|
|
6
|
+
from trace_engine.framework.base import FrameworkAdapter, Endpoint, EndpointParameter
|
|
7
|
+
from trace_engine.parsing.locations import SourceLocation
|
|
8
|
+
|
|
9
|
+
HTTP_METHODS = {"get", "post", "put", "delete", "patch", "options", "head", "websocket"}
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class FastAPIFrameworkAdapter(FrameworkAdapter):
|
|
13
|
+
"""Detects and extracts FastAPI/Starlette routes, WebSockets, static mounts, and auth gates."""
|
|
14
|
+
|
|
15
|
+
def can_handle(self, parsed_file: ParsedFile) -> bool:
|
|
16
|
+
if parsed_file.language != "python":
|
|
17
|
+
return False
|
|
18
|
+
for imp in parsed_file.imports:
|
|
19
|
+
if "fastapi" in imp.module.lower() or "starlette" in imp.module.lower():
|
|
20
|
+
return True
|
|
21
|
+
return False
|
|
22
|
+
|
|
23
|
+
def extract_endpoints(self, parsed_file: ParsedFile, content: str) -> List[Endpoint]:
|
|
24
|
+
endpoints: List[Endpoint] = []
|
|
25
|
+
existing_keys = set()
|
|
26
|
+
|
|
27
|
+
# 1. Search decorators on parsed AST functions
|
|
28
|
+
for fn in parsed_file.functions:
|
|
29
|
+
for dec in fn.decorators:
|
|
30
|
+
dec_text = dec.raw_text.strip()
|
|
31
|
+
# Pattern: @app.get('/path' ...), @router.post('/path' ...), @app.websocket('/ws/...')
|
|
32
|
+
match = re.search(
|
|
33
|
+
r"""@?(?:[a-zA-Z0-9_]+)\.(get|post|put|delete|patch|websocket|api_route)\s*\(\s*["']([^"']+)["']""",
|
|
34
|
+
dec_text,
|
|
35
|
+
re.IGNORECASE,
|
|
36
|
+
)
|
|
37
|
+
if match:
|
|
38
|
+
raw_verb = match.group(1).upper()
|
|
39
|
+
method = "WEBSOCKET" if raw_verb == "WEBSOCKET" else ("GET" if raw_verb == "API_ROUTE" else raw_verb)
|
|
40
|
+
path = match.group(2)
|
|
41
|
+
key = (method, path)
|
|
42
|
+
if key in existing_keys:
|
|
43
|
+
continue
|
|
44
|
+
existing_keys.add(key)
|
|
45
|
+
|
|
46
|
+
ep_id = f"ep_{parsed_file.file_path.replace('/', '_').replace('.', '_')}_{fn.name}_{method}"
|
|
47
|
+
|
|
48
|
+
# Detect path parameters: {param_name}
|
|
49
|
+
path_params = re.findall(r"\{([a-zA-Z0-9_]+)\}", path)
|
|
50
|
+
params: List[EndpointParameter] = [
|
|
51
|
+
EndpointParameter(name=p, location="path", required=True)
|
|
52
|
+
for p in path_params
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
# Detect function params from AST
|
|
56
|
+
for p in fn.parameters:
|
|
57
|
+
if p.name not in ("self", "cls") and p.name not in path_params:
|
|
58
|
+
params.append(
|
|
59
|
+
EndpointParameter(
|
|
60
|
+
name=p.name,
|
|
61
|
+
location="query" if method in ("GET", "WEBSOCKET") else "body",
|
|
62
|
+
param_type=p.type_annotation or "string",
|
|
63
|
+
)
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
# Analyze auth dependencies in decorator or args
|
|
67
|
+
auth_required = False
|
|
68
|
+
roles = []
|
|
69
|
+
fn_body = fn.body_text or ""
|
|
70
|
+
|
|
71
|
+
auth_indicators = [
|
|
72
|
+
"current_user",
|
|
73
|
+
"auth",
|
|
74
|
+
"token",
|
|
75
|
+
"security",
|
|
76
|
+
"jwt",
|
|
77
|
+
"oauth",
|
|
78
|
+
"credentials",
|
|
79
|
+
"verify_token",
|
|
80
|
+
"api_key",
|
|
81
|
+
"require_admin",
|
|
82
|
+
"get_current_user",
|
|
83
|
+
]
|
|
84
|
+
for p in fn.parameters:
|
|
85
|
+
p_ann = (p.type_annotation or "").lower()
|
|
86
|
+
p_def = (p.default_value or "").lower()
|
|
87
|
+
if any(ind in p.name.lower() or ind in p_ann or ind in p_def for ind in auth_indicators):
|
|
88
|
+
auth_required = True
|
|
89
|
+
if "admin" in p.name.lower() or "admin" in p_ann or "admin" in p_def:
|
|
90
|
+
roles.append("admin")
|
|
91
|
+
|
|
92
|
+
if "require_admin" in dec_text.lower() or "admin" in path.lower():
|
|
93
|
+
roles.append("admin")
|
|
94
|
+
if "current_user" in dec_text.lower() or "depends(" in dec_text.lower():
|
|
95
|
+
auth_required = True
|
|
96
|
+
|
|
97
|
+
# Object identifier flag: e.g. /items/{id}, /athlete/{athlete_id}, /coach/{coach_id}
|
|
98
|
+
has_obj_id = any(
|
|
99
|
+
p.lower() == "id" or p.lower().endswith("_id") or p.lower().endswith("id")
|
|
100
|
+
for p in path_params
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
# State changing
|
|
104
|
+
state_changing = method in ("POST", "PUT", "DELETE", "PATCH")
|
|
105
|
+
|
|
106
|
+
# Database access check
|
|
107
|
+
db_access = any(
|
|
108
|
+
term in fn_body.lower()
|
|
109
|
+
for term in ("db.", "session.", "cursor.", "repository", "query(", ".filter(", "database", "models.")
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
# Outbound network (SSRF indicator)
|
|
113
|
+
ext_network = any(
|
|
114
|
+
term in fn_body.lower()
|
|
115
|
+
for term in ("requests.", "httpx.", "urllib.", "aiohttp.", "fetch(")
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
# Sensitive data
|
|
119
|
+
sensitive = any(
|
|
120
|
+
term in path.lower() or term in fn_body.lower()
|
|
121
|
+
for term in ("password", "secret", "token", "credit", "card", "user", "order", "refund", "admin", "coach", "athlete", "story", "session")
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
endpoints.append(
|
|
125
|
+
Endpoint(
|
|
126
|
+
id=ep_id,
|
|
127
|
+
method=method,
|
|
128
|
+
path=path,
|
|
129
|
+
handler_name=fn.qualified_name,
|
|
130
|
+
auth_required=auth_required,
|
|
131
|
+
roles=list(set(roles)),
|
|
132
|
+
parameters=params,
|
|
133
|
+
database_access=db_access,
|
|
134
|
+
object_identifier=has_obj_id,
|
|
135
|
+
state_changing=state_changing,
|
|
136
|
+
external_network=ext_network,
|
|
137
|
+
sensitive_data=sensitive,
|
|
138
|
+
source=fn.location,
|
|
139
|
+
)
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
# 2. Detect StaticFiles mounts e.g. app.mount("/session_videos", StaticFiles(directory="session_videos"), name="session_videos")
|
|
143
|
+
mount_pattern = re.compile(
|
|
144
|
+
r"""(?:app|router)\.mount\s*\(\s*["']([^"']+)["']\s*,\s*StaticFiles\s*\(\s*(?:directory\s*=\s*)?["']([^"']+)["']""",
|
|
145
|
+
re.IGNORECASE,
|
|
146
|
+
)
|
|
147
|
+
for match in mount_pattern.finditer(content):
|
|
148
|
+
mount_path = match.group(1).rstrip("/")
|
|
149
|
+
directory = match.group(2)
|
|
150
|
+
line_no = content[:match.start()].count("\n") + 1
|
|
151
|
+
ep_id = f"ep_{parsed_file.file_path.replace('/', '_').replace('.', '_')}_mount_{directory}_GET"
|
|
152
|
+
key = ("GET", f"{mount_path}/{{filepath}}")
|
|
153
|
+
if key not in existing_keys:
|
|
154
|
+
existing_keys.add(key)
|
|
155
|
+
endpoints.append(
|
|
156
|
+
Endpoint(
|
|
157
|
+
id=ep_id,
|
|
158
|
+
method="GET",
|
|
159
|
+
path=f"{mount_path}/{{filepath}}",
|
|
160
|
+
handler_name=f"StaticFiles({directory})",
|
|
161
|
+
auth_required=False,
|
|
162
|
+
roles=[],
|
|
163
|
+
parameters=[
|
|
164
|
+
EndpointParameter(name="filepath", location="path", required=True)
|
|
165
|
+
],
|
|
166
|
+
database_access=False,
|
|
167
|
+
object_identifier=True,
|
|
168
|
+
state_changing=False,
|
|
169
|
+
external_network=False,
|
|
170
|
+
sensitive_data=True,
|
|
171
|
+
source=SourceLocation(file=parsed_file.file_path, line_start=line_no, line_end=line_no),
|
|
172
|
+
)
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
# 3. Direct regex fallback across raw content to catch routes missed by AST function traversal
|
|
176
|
+
raw_route_pat = re.compile(
|
|
177
|
+
r"""@(?:app|router|api_router)\.(get|post|put|delete|patch|websocket)\s*\(\s*["']([^"']+)["']""",
|
|
178
|
+
re.IGNORECASE,
|
|
179
|
+
)
|
|
180
|
+
for match in raw_route_pat.finditer(content):
|
|
181
|
+
raw_verb = match.group(1).upper()
|
|
182
|
+
method = "WEBSOCKET" if raw_verb == "WEBSOCKET" else raw_verb
|
|
183
|
+
path = match.group(2)
|
|
184
|
+
key = (method, path)
|
|
185
|
+
if key in existing_keys:
|
|
186
|
+
continue
|
|
187
|
+
existing_keys.add(key)
|
|
188
|
+
|
|
189
|
+
line_no = content[:match.start()].count("\n") + 1
|
|
190
|
+
path_params = re.findall(r"\{([a-zA-Z0-9_]+)\}", path)
|
|
191
|
+
has_obj_id = any(
|
|
192
|
+
p.lower() == "id" or p.lower().endswith("_id") or p.lower().endswith("id")
|
|
193
|
+
for p in path_params
|
|
194
|
+
)
|
|
195
|
+
endpoints.append(
|
|
196
|
+
Endpoint(
|
|
197
|
+
id=f"ep_{parsed_file.file_path.replace('/', '_').replace('.', '_')}_L{line_no}_{method}",
|
|
198
|
+
method=method,
|
|
199
|
+
path=path,
|
|
200
|
+
handler_name=f"route_L{line_no}",
|
|
201
|
+
auth_required=False,
|
|
202
|
+
roles=["admin"] if "admin" in path.lower() else [],
|
|
203
|
+
parameters=[
|
|
204
|
+
EndpointParameter(name=p, location="path", required=True)
|
|
205
|
+
for p in path_params
|
|
206
|
+
],
|
|
207
|
+
database_access=True,
|
|
208
|
+
object_identifier=has_obj_id,
|
|
209
|
+
state_changing=method in ("POST", "PUT", "DELETE", "PATCH"),
|
|
210
|
+
external_network=False,
|
|
211
|
+
sensitive_data="admin" in path.lower() or "user" in path.lower() or "session" in path.lower() or "story" in path.lower(),
|
|
212
|
+
source=SourceLocation(file=parsed_file.file_path, line_start=line_no, line_end=line_no),
|
|
213
|
+
)
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
return endpoints
|
|
@@ -1,13 +1,23 @@
|
|
|
1
1
|
"""Security hypotheses generation from APM and deterministic signals."""
|
|
2
2
|
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
3
5
|
from enum import Enum
|
|
4
|
-
from typing import List, Dict, Any, Optional
|
|
6
|
+
from typing import List, Dict, Any, Optional, Set
|
|
5
7
|
from pydantic import BaseModel, Field
|
|
6
8
|
from trace_engine.apm.model import AttackPathModel
|
|
7
9
|
from trace_engine.framework.base import Endpoint
|
|
8
10
|
from trace_engine.security.signals import SecuritySignal, SignalType
|
|
9
11
|
|
|
10
12
|
|
|
13
|
+
class Severity(str, Enum):
|
|
14
|
+
CRITICAL = "CRITICAL"
|
|
15
|
+
HIGH = "HIGH"
|
|
16
|
+
MEDIUM = "MEDIUM"
|
|
17
|
+
LOW = "LOW"
|
|
18
|
+
INFO = "INFO"
|
|
19
|
+
|
|
20
|
+
|
|
11
21
|
class VulnerabilityCategory(str, Enum):
|
|
12
22
|
BOLA = "BOLA"
|
|
13
23
|
BFLA = "BFLA"
|
|
@@ -32,6 +42,7 @@ class SecurityHypothesis(BaseModel):
|
|
|
32
42
|
recommended_test_pack: str
|
|
33
43
|
confidence_prior: float = 0.5 # Prior confidence from static signals alone
|
|
34
44
|
static_evidence: List[str] = Field(default_factory=list)
|
|
45
|
+
severity: Optional[Severity] = None # Contextual severity override
|
|
35
46
|
|
|
36
47
|
|
|
37
48
|
class HypothesisEngine:
|
|
@@ -42,9 +53,32 @@ class HypothesisEngine:
|
|
|
42
53
|
) -> List[SecurityHypothesis]:
|
|
43
54
|
hypotheses: List[SecurityHypothesis] = []
|
|
44
55
|
hypo_idx = 1
|
|
56
|
+
admin_endpoint_ids: Set[str] = set()
|
|
57
|
+
source_files: Set[str] = set()
|
|
58
|
+
file_cache: Dict[str, str] = {}
|
|
59
|
+
|
|
60
|
+
def read_file(fpath: str) -> str:
|
|
61
|
+
if not fpath:
|
|
62
|
+
return ""
|
|
63
|
+
if fpath not in file_cache:
|
|
64
|
+
try:
|
|
65
|
+
if os.path.exists(fpath):
|
|
66
|
+
with open(fpath, "r", encoding="utf-8", errors="replace") as f:
|
|
67
|
+
file_cache[fpath] = f.read()
|
|
68
|
+
else:
|
|
69
|
+
file_cache[fpath] = ""
|
|
70
|
+
except Exception:
|
|
71
|
+
file_cache[fpath] = ""
|
|
72
|
+
return file_cache[fpath]
|
|
73
|
+
|
|
74
|
+
# Collect unique source files
|
|
75
|
+
for ep in endpoints:
|
|
76
|
+
if ep.source and ep.source.file:
|
|
77
|
+
source_files.add(ep.source.file)
|
|
45
78
|
|
|
46
79
|
for ep in endpoints:
|
|
47
80
|
ep_disp = ep.display_name()
|
|
81
|
+
file_src = read_file(ep.source.file) if ep.source and ep.source.file else ""
|
|
48
82
|
|
|
49
83
|
# 1. BOLA Hypothesis: Endpoint accepts ID parameter, touches DB, but lacks ownership verification
|
|
50
84
|
if ep.object_identifier and ep.database_access:
|
|
@@ -63,57 +97,238 @@ class HypothesisEngine:
|
|
|
63
97
|
"Database lookup confirmed in handler source",
|
|
64
98
|
f"Declared auth required: {ep.auth_required}",
|
|
65
99
|
],
|
|
100
|
+
severity=Severity.HIGH,
|
|
66
101
|
)
|
|
67
102
|
)
|
|
68
103
|
hypo_idx += 1
|
|
69
104
|
|
|
105
|
+
# Check for Caller-Controlled Ownership Bypass:
|
|
106
|
+
# e.g. def fn(user_id, coach_id: Optional[int] = None): if coach_id is not None: assert_coach_owns_athlete(...)
|
|
107
|
+
has_coach_param = any(p.name in ("coach_id", "owner_id", "tenant_id", "org_id") for p in ep.parameters)
|
|
108
|
+
ownership_bypass_pattern = (
|
|
109
|
+
"assert_coach_owns_athlete" in file_src
|
|
110
|
+
or bool(re.search(r"if\s+(?:coach_id|owner_id|tenant_id)\s+is\s+not\s+None:", file_src))
|
|
111
|
+
or bool(re.search(r"if\s+(?:coach_id|owner_id|tenant_id):", file_src))
|
|
112
|
+
)
|
|
113
|
+
if has_coach_param and ownership_bypass_pattern:
|
|
114
|
+
hypotheses.append(
|
|
115
|
+
SecurityHypothesis(
|
|
116
|
+
id=f"HYP-BOLA-{hypo_idx:03d}",
|
|
117
|
+
category=VulnerabilityCategory.BOLA,
|
|
118
|
+
endpoint_id=ep.id,
|
|
119
|
+
endpoint_display=ep_disp,
|
|
120
|
+
title=f"Caller-Controlled Ownership Bypass via Optional Parameter on {ep_disp}",
|
|
121
|
+
description="Multi-tenant ownership assertion is guarded by an optional query parameter (e.g. coach_id). Omitting this parameter from the request completely bypasses the ownership check.",
|
|
122
|
+
recommended_test_pack="bola",
|
|
123
|
+
confidence_prior=0.85,
|
|
124
|
+
static_evidence=[
|
|
125
|
+
"Ownership assertion is guarded by optional parameter: coach_id",
|
|
126
|
+
"Bypass confirmed: Omitting ?coach_id= skips assert_coach_owns_athlete()",
|
|
127
|
+
f"Endpoint modifies/reads target object: {ep.path}",
|
|
128
|
+
],
|
|
129
|
+
severity=Severity.HIGH,
|
|
130
|
+
)
|
|
131
|
+
)
|
|
132
|
+
hypo_idx += 1
|
|
133
|
+
|
|
70
134
|
# 2. BFLA Hypothesis: Endpoint has administrative or privileged semantics
|
|
71
|
-
|
|
135
|
+
is_admin = "admin" in ep.path.lower() or "admin" in [r.lower() for r in ep.roles]
|
|
136
|
+
if is_admin:
|
|
137
|
+
admin_endpoint_ids.add(ep.id)
|
|
138
|
+
is_critical_admin = any(term in ep.path.lower() for term in ("reset-db", "wipe", "truncate")) or (
|
|
139
|
+
"users/create" in ep.path.lower() and not ep.auth_required
|
|
140
|
+
)
|
|
141
|
+
bfla_sev = Severity.CRITICAL if is_critical_admin else Severity.HIGH
|
|
142
|
+
static_ev = [
|
|
143
|
+
f"Privileged path/role identifier present: {ep.roles or ['admin path']}",
|
|
144
|
+
f"Source location: {ep.source}",
|
|
145
|
+
]
|
|
146
|
+
if not ep.auth_required:
|
|
147
|
+
static_ev.append("Administrative endpoint is completely unauthenticated and unprotected by RBAC")
|
|
148
|
+
|
|
149
|
+
title = (
|
|
150
|
+
f"Unauthenticated administrative endpoint lacks RBAC on {ep_disp}"
|
|
151
|
+
if not ep.auth_required
|
|
152
|
+
else f"Potential BFLA on privileged endpoint {ep_disp}"
|
|
153
|
+
)
|
|
72
154
|
hypotheses.append(
|
|
73
155
|
SecurityHypothesis(
|
|
74
156
|
id=f"HYP-BFLA-{hypo_idx:03d}",
|
|
75
157
|
category=VulnerabilityCategory.BFLA,
|
|
76
158
|
endpoint_id=ep.id,
|
|
77
159
|
endpoint_display=ep_disp,
|
|
78
|
-
title=
|
|
79
|
-
description="Administrative/privileged action
|
|
160
|
+
title=title,
|
|
161
|
+
description="Administrative/privileged action fails to enforce strict RBAC check or lacks authentication.",
|
|
80
162
|
recommended_test_pack="bfla",
|
|
81
|
-
confidence_prior=0.70,
|
|
82
|
-
static_evidence=
|
|
83
|
-
|
|
84
|
-
f"Source location: {ep.source}",
|
|
85
|
-
],
|
|
163
|
+
confidence_prior=0.85 if not ep.auth_required else 0.70,
|
|
164
|
+
static_evidence=static_ev,
|
|
165
|
+
severity=bfla_sev,
|
|
86
166
|
)
|
|
87
167
|
)
|
|
88
168
|
hypo_idx += 1
|
|
89
169
|
|
|
90
|
-
# 3. Authentication Bypass / Missing Auth: State-changing
|
|
91
|
-
if not ep.auth_required and (
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
title=f"Unauthenticated sensitive endpoint {ep_disp}",
|
|
106
|
-
description="Sensitive or state-modifying endpoint does not declare authentication requirement.",
|
|
107
|
-
recommended_test_pack="authentication",
|
|
108
|
-
confidence_prior=0.80,
|
|
109
|
-
static_evidence=[
|
|
110
|
-
f"State-changing: {ep.state_changing}",
|
|
111
|
-
f"Sensitive data indicator: {ep.sensitive_data}",
|
|
112
|
-
"No auth middleware or dependency detected",
|
|
113
|
-
],
|
|
114
|
-
)
|
|
170
|
+
# 3. Authentication Bypass / Missing Auth: State-changing, sensitive, stream, or mutating verbs without auth
|
|
171
|
+
if not ep.auth_required and (
|
|
172
|
+
ep.state_changing
|
|
173
|
+
or ep.sensitive_data
|
|
174
|
+
or ep.method in ("POST", "PUT", "PATCH", "DELETE", "WEBSOCKET")
|
|
175
|
+
or any(term in ep.path.lower() for term in ("scatt-analysis", "inference", "predict", "telemetry/analyze"))
|
|
176
|
+
or (ep.handler_name and ep.handler_name.startswith("StaticFiles"))
|
|
177
|
+
):
|
|
178
|
+
# Deduplication: If already captured as BFLA on administrative endpoint, skip generic AUTH to prevent duplicate reporting
|
|
179
|
+
if ep.id not in admin_endpoint_ids:
|
|
180
|
+
path_segs = [s.lower() for s in ep.path.strip("/").split("/")]
|
|
181
|
+
is_public_by_design = any(
|
|
182
|
+
s in ("login", "signin", "register", "signup", "forgot-password", "callback", "oauth", "health", "ping", "status", "public", "docs", "openapi")
|
|
183
|
+
or s.startswith("auth-callback") or s.endswith("-callback")
|
|
184
|
+
for s in path_segs
|
|
115
185
|
)
|
|
116
|
-
|
|
186
|
+
if not is_public_by_design:
|
|
187
|
+
# Contextual classification & severity calibration
|
|
188
|
+
if any(term in ep.path.lower() for term in ("reset", "wipe", "truncate")) and ep.state_changing and not any(term in ep.path.lower() for term in ("password", "token")):
|
|
189
|
+
# Case: Destructive global data wipe (e.g. POST /api/session/reset)
|
|
190
|
+
hypotheses.append(
|
|
191
|
+
SecurityHypothesis(
|
|
192
|
+
id=f"HYP-AUTH-{hypo_idx:03d}",
|
|
193
|
+
category=VulnerabilityCategory.AUTHENTICATION,
|
|
194
|
+
endpoint_id=ep.id,
|
|
195
|
+
endpoint_display=ep_disp,
|
|
196
|
+
title=f"Unauthenticated Global Session/Data Wipe with Blast Radius on {ep_disp}",
|
|
197
|
+
description="Endpoint permits unauthenticated bulk data or session deletion across all users without scoping or confirmation.",
|
|
198
|
+
recommended_test_pack="authentication",
|
|
199
|
+
confidence_prior=0.90,
|
|
200
|
+
static_evidence=[
|
|
201
|
+
f"Destructive action method {ep.method} on reset/wipe path",
|
|
202
|
+
"No session ownership or caller authentication enforced",
|
|
203
|
+
"Global deletion blast radius affects all connected users",
|
|
204
|
+
],
|
|
205
|
+
severity=Severity.CRITICAL,
|
|
206
|
+
)
|
|
207
|
+
)
|
|
208
|
+
hypo_idx += 1
|
|
209
|
+
elif "reset-password" in ep.path.lower() or ("password" in ep.path.lower() and ep.state_changing):
|
|
210
|
+
# Case: Account takeover via unauth password reset
|
|
211
|
+
hypotheses.append(
|
|
212
|
+
SecurityHypothesis(
|
|
213
|
+
id=f"HYP-AUTH-{hypo_idx:03d}",
|
|
214
|
+
category=VulnerabilityCategory.AUTHENTICATION,
|
|
215
|
+
endpoint_id=ep.id,
|
|
216
|
+
endpoint_display=ep_disp,
|
|
217
|
+
title=f"Unauthenticated Password Reset Allowing Account Takeover on {ep_disp}",
|
|
218
|
+
description="Endpoint permits password modification without current password or verified reset token.",
|
|
219
|
+
recommended_test_pack="authentication",
|
|
220
|
+
confidence_prior=0.90,
|
|
221
|
+
static_evidence=[
|
|
222
|
+
"Password modification endpoint lacks authentication",
|
|
223
|
+
"Enables unauthenticated credential reset / account takeover",
|
|
224
|
+
],
|
|
225
|
+
severity=Severity.CRITICAL,
|
|
226
|
+
)
|
|
227
|
+
)
|
|
228
|
+
hypo_idx += 1
|
|
229
|
+
elif (ep.handler_name and ep.handler_name.startswith("StaticFiles")) or "/session_videos" in ep.path.lower() or "/uploads" in ep.path.lower():
|
|
230
|
+
# Case: Public StaticFiles mount leaking sensitive artifacts
|
|
231
|
+
hypotheses.append(
|
|
232
|
+
SecurityHypothesis(
|
|
233
|
+
id=f"HYP-AUTH-{hypo_idx:03d}",
|
|
234
|
+
category=VulnerabilityCategory.AUTHENTICATION,
|
|
235
|
+
endpoint_id=ep.id,
|
|
236
|
+
endpoint_display=ep_disp,
|
|
237
|
+
title=f"Public StaticFiles Mount Exposes Sensitive Artifacts on {ep_disp}",
|
|
238
|
+
description="Directory is mounted publicly via StaticFiles without authentication middleware, exposing stored user files/videos to anyone with the URL.",
|
|
239
|
+
recommended_test_pack="authentication",
|
|
240
|
+
confidence_prior=0.85,
|
|
241
|
+
static_evidence=[
|
|
242
|
+
f"FastAPI StaticFiles mount path: {ep.path}",
|
|
243
|
+
f"Handler: {ep.handler_name}",
|
|
244
|
+
"No auth middleware or token check on file retrieval",
|
|
245
|
+
],
|
|
246
|
+
severity=Severity.HIGH,
|
|
247
|
+
)
|
|
248
|
+
)
|
|
249
|
+
hypo_idx += 1
|
|
250
|
+
elif ep.method == "WEBSOCKET":
|
|
251
|
+
# Case: Unauthenticated WebSocket
|
|
252
|
+
hypotheses.append(
|
|
253
|
+
SecurityHypothesis(
|
|
254
|
+
id=f"HYP-AUTH-{hypo_idx:03d}",
|
|
255
|
+
category=VulnerabilityCategory.AUTHENTICATION,
|
|
256
|
+
endpoint_id=ep.id,
|
|
257
|
+
endpoint_display=ep_disp,
|
|
258
|
+
title=f"Unauthenticated WebSocket Connection on {ep_disp}",
|
|
259
|
+
description="WebSocket endpoint accepts connections and streams data without verifying authentication tokens or session credentials during handshake.",
|
|
260
|
+
recommended_test_pack="authentication",
|
|
261
|
+
confidence_prior=0.75,
|
|
262
|
+
static_evidence=[
|
|
263
|
+
f"WebSocket route {ep.path} accepts unauthenticated connections",
|
|
264
|
+
"No token or session check in handshake handler",
|
|
265
|
+
],
|
|
266
|
+
severity=Severity.MEDIUM,
|
|
267
|
+
)
|
|
268
|
+
)
|
|
269
|
+
hypo_idx += 1
|
|
270
|
+
elif any(term in ep.path.lower() for term in ("scatt-analysis", "inference", "predict", "telemetry/analyze")) and not ep.database_access:
|
|
271
|
+
# Case: Stateless ML/inference compute endpoint (low impact)
|
|
272
|
+
hypotheses.append(
|
|
273
|
+
SecurityHypothesis(
|
|
274
|
+
id=f"HYP-AUTH-{hypo_idx:03d}",
|
|
275
|
+
category=VulnerabilityCategory.AUTHENTICATION,
|
|
276
|
+
endpoint_id=ep.id,
|
|
277
|
+
endpoint_display=ep_disp,
|
|
278
|
+
title=f"Unauthenticated Stateless ML/Inference Endpoint on {ep_disp}",
|
|
279
|
+
description="Inference endpoint lacks authentication, exposing compute resources (denial-of-wallet / resource exhaustion) but no stored user data.",
|
|
280
|
+
recommended_test_pack="authentication",
|
|
281
|
+
confidence_prior=0.65,
|
|
282
|
+
static_evidence=[
|
|
283
|
+
f"Stateless compute endpoint {ep.method} {ep.path}",
|
|
284
|
+
"No database access or private user data stored",
|
|
285
|
+
"Risk limited to compute exhaustion / unmetered access",
|
|
286
|
+
],
|
|
287
|
+
severity=Severity.LOW,
|
|
288
|
+
)
|
|
289
|
+
)
|
|
290
|
+
hypo_idx += 1
|
|
291
|
+
elif ep.method == "GET" and any(term in ep.path.lower() for term in ("user", "profile")) and not any(term in ep.path.lower() for term in ("admin", "token", "key", "secret", "history", "story", "session")):
|
|
292
|
+
# Case: Read-only user metadata disclosure (medium impact)
|
|
293
|
+
hypotheses.append(
|
|
294
|
+
SecurityHypothesis(
|
|
295
|
+
id=f"HYP-AUTH-{hypo_idx:03d}",
|
|
296
|
+
category=VulnerabilityCategory.AUTHENTICATION,
|
|
297
|
+
endpoint_id=ep.id,
|
|
298
|
+
endpoint_display=ep_disp,
|
|
299
|
+
title=f"Unauthenticated User Information Disclosure on {ep_disp}",
|
|
300
|
+
description="Read-only user endpoint discloses basic profile metadata without session authentication.",
|
|
301
|
+
recommended_test_pack="authentication",
|
|
302
|
+
confidence_prior=0.75,
|
|
303
|
+
static_evidence=[
|
|
304
|
+
f"Unauthenticated read {ep.method} {ep.path}",
|
|
305
|
+
"Returns basic user record without credential leakage",
|
|
306
|
+
],
|
|
307
|
+
severity=Severity.MEDIUM,
|
|
308
|
+
)
|
|
309
|
+
)
|
|
310
|
+
hypo_idx += 1
|
|
311
|
+
else:
|
|
312
|
+
# General sensitive / state-modifying unauthenticated endpoint (HIGH)
|
|
313
|
+
hypotheses.append(
|
|
314
|
+
SecurityHypothesis(
|
|
315
|
+
id=f"HYP-AUTH-{hypo_idx:03d}",
|
|
316
|
+
category=VulnerabilityCategory.AUTHENTICATION,
|
|
317
|
+
endpoint_id=ep.id,
|
|
318
|
+
endpoint_display=ep_disp,
|
|
319
|
+
title=f"Unauthenticated sensitive endpoint {ep_disp}",
|
|
320
|
+
description="Sensitive or state-modifying endpoint does not declare authentication requirement.",
|
|
321
|
+
recommended_test_pack="authentication",
|
|
322
|
+
confidence_prior=0.80,
|
|
323
|
+
static_evidence=[
|
|
324
|
+
f"State-changing: {ep.state_changing}",
|
|
325
|
+
f"Sensitive data indicator: {ep.sensitive_data}",
|
|
326
|
+
"No auth middleware or dependency detected",
|
|
327
|
+
],
|
|
328
|
+
severity=Severity.HIGH,
|
|
329
|
+
)
|
|
330
|
+
)
|
|
331
|
+
hypo_idx += 1
|
|
117
332
|
|
|
118
333
|
# 4. SSRF Hypothesis: Endpoint takes user-supplied URL/target AND makes outbound HTTP calls
|
|
119
334
|
url_params = [
|
|
@@ -140,29 +355,69 @@ class HypothesisEngine:
|
|
|
140
355
|
"Outbound HTTP client calls detected in handler body",
|
|
141
356
|
f"User-controlled URL parameters: {url_params}",
|
|
142
357
|
],
|
|
358
|
+
severity=Severity.HIGH,
|
|
143
359
|
)
|
|
144
360
|
)
|
|
145
361
|
hypo_idx += 1
|
|
146
362
|
|
|
147
|
-
# 5. Mass Assignment
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
363
|
+
# 5. Mass Assignment & Privilege Escalation via Unvalidated Role Assignment
|
|
364
|
+
is_profile_path = any(term in ep.path.lower() for term in ("user", "profile", "account", "athlete"))
|
|
365
|
+
is_single_field_action = any(
|
|
366
|
+
ep.path.lower().endswith(f"/{term}") or f"/{term}/" in ep.path.lower()
|
|
367
|
+
for term in ("coach", "assign-coach", "reset-password", "password", "status", "settings")
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
if ep.method in ("PATCH", "PUT", "POST") and is_profile_path:
|
|
371
|
+
# Check for Privilege Escalation via Unvalidated Role Assignment:
|
|
372
|
+
# (e.g. POST /api/admin/users/create with role parameter)
|
|
373
|
+
has_role_param = any(p.name in ("role", "is_admin", "is_superuser", "permissions") for p in ep.parameters)
|
|
374
|
+
has_role_in_code = "role" in file_src.lower() and ("create" in ep.path.lower() or "register" in ep.path.lower() or "users" in ep.path.lower())
|
|
375
|
+
|
|
376
|
+
if (has_role_param or has_role_in_code) and any(term in ep.path.lower() for term in ("create", "register", "users", "signup")):
|
|
377
|
+
hypotheses.append(
|
|
378
|
+
SecurityHypothesis(
|
|
379
|
+
id=f"HYP-BFLA-{hypo_idx:03d}",
|
|
380
|
+
category=VulnerabilityCategory.BFLA,
|
|
381
|
+
endpoint_id=ep.id,
|
|
382
|
+
endpoint_display=ep_disp,
|
|
383
|
+
title=f"Privilege Escalation via Unvalidated Role Assignment on {ep_disp}",
|
|
384
|
+
description="Endpoint accepts client-supplied 'role' without authorization check or strict role allowlist, allowing creation of arbitrary ADMIN accounts.",
|
|
385
|
+
recommended_test_pack="bfla",
|
|
386
|
+
confidence_prior=0.85,
|
|
387
|
+
static_evidence=[
|
|
388
|
+
f"State modifying method {ep.method} accepts unvalidated role parameter",
|
|
389
|
+
"Permits callers to assign role='ADMIN' without restriction",
|
|
390
|
+
],
|
|
391
|
+
severity=Severity.CRITICAL if not ep.auth_required else Severity.HIGH,
|
|
392
|
+
)
|
|
163
393
|
)
|
|
164
|
-
|
|
165
|
-
|
|
394
|
+
hypo_idx += 1
|
|
395
|
+
elif not is_single_field_action:
|
|
396
|
+
# Only flag true mass assignment if NOT a single-field action DTO
|
|
397
|
+
# and either dynamic unpacking is detected or generic dictionary payload is accepted
|
|
398
|
+
has_dynamic_unpacking = any(
|
|
399
|
+
term in file_src
|
|
400
|
+
for term in ("**req.dict", "**payload.dict", "**data", "**body", "Object.assign", "setattr")
|
|
401
|
+
)
|
|
402
|
+
if has_dynamic_unpacking or len(ep.parameters) > 2:
|
|
403
|
+
hypotheses.append(
|
|
404
|
+
SecurityHypothesis(
|
|
405
|
+
id=f"HYP-MASS-{hypo_idx:03d}",
|
|
406
|
+
category=VulnerabilityCategory.MASS_ASSIGNMENT,
|
|
407
|
+
endpoint_id=ep.id,
|
|
408
|
+
endpoint_display=ep_disp,
|
|
409
|
+
title=f"Potential Mass Assignment on {ep_disp}",
|
|
410
|
+
description="Profile update endpoint may bind sensitive attributes directly from payload without strict allowlisting.",
|
|
411
|
+
recommended_test_pack="mass_assignment",
|
|
412
|
+
confidence_prior=0.65,
|
|
413
|
+
static_evidence=[
|
|
414
|
+
f"State modifying method {ep.method} on profile/user path: {ep.path}",
|
|
415
|
+
"Model payload deserialization with potential dynamic field binding",
|
|
416
|
+
],
|
|
417
|
+
severity=Severity.MEDIUM,
|
|
418
|
+
)
|
|
419
|
+
)
|
|
420
|
+
hypo_idx += 1
|
|
166
421
|
|
|
167
422
|
# 6. Injection Indicator Hypothesis: Search or query with DB access
|
|
168
423
|
if any(term in ep.path.lower() for term in ("search", "filter", "query")) and ep.database_access:
|
|
@@ -180,6 +435,7 @@ class HypothesisEngine:
|
|
|
180
435
|
"User query parameter routed to database handler",
|
|
181
436
|
f"Endpoint parameters: {[p.name for p in ep.parameters]}",
|
|
182
437
|
],
|
|
438
|
+
severity=Severity.CRITICAL,
|
|
183
439
|
)
|
|
184
440
|
)
|
|
185
441
|
hypo_idx += 1
|
|
@@ -196,10 +452,19 @@ class HypothesisEngine:
|
|
|
196
452
|
)
|
|
197
453
|
]
|
|
198
454
|
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)
|
|
455
|
+
has_file_route = any(seg in ("file", "files", "download", "downloads", "static", "assets", "document", "documents", "video") for seg in path_segments)
|
|
200
456
|
|
|
201
|
-
#
|
|
457
|
+
# Check if upload/download endpoint handles file path without containment verification
|
|
202
458
|
if file_param_names or (has_file_route and ep.parameters):
|
|
459
|
+
# Calibrate evidence if parameter is shot_id or file
|
|
460
|
+
is_unresolved_path = any("shot_id" in p.name.lower() for p in ep.parameters)
|
|
461
|
+
evidence = [
|
|
462
|
+
f"File/path parameter names detected: {file_param_names or [p.name for p in ep.parameters]}",
|
|
463
|
+
f"Source location: {ep.source}",
|
|
464
|
+
]
|
|
465
|
+
if is_unresolved_path:
|
|
466
|
+
evidence.append("Filename constructed with insufficient sanitization (missing pathlib.Path.resolve containment check)")
|
|
467
|
+
|
|
203
468
|
hypotheses.append(
|
|
204
469
|
SecurityHypothesis(
|
|
205
470
|
id=f"HYP-TRAV-{hypo_idx:03d}",
|
|
@@ -210,10 +475,8 @@ class HypothesisEngine:
|
|
|
210
475
|
description="Endpoint handles file or path parameters without verified filesystem boundary isolation.",
|
|
211
476
|
recommended_test_pack="path_traversal",
|
|
212
477
|
confidence_prior=0.65,
|
|
213
|
-
static_evidence=
|
|
214
|
-
|
|
215
|
-
f"Source location: {ep.source}",
|
|
216
|
-
],
|
|
478
|
+
static_evidence=evidence,
|
|
479
|
+
severity=Severity.MEDIUM if is_unresolved_path else Severity.HIGH,
|
|
217
480
|
)
|
|
218
481
|
)
|
|
219
482
|
hypo_idx += 1
|
|
@@ -234,11 +497,12 @@ class HypothesisEngine:
|
|
|
234
497
|
static_evidence=[
|
|
235
498
|
f"Template rendering parameter detected: {template_param_names or [ep.path]}",
|
|
236
499
|
],
|
|
500
|
+
severity=Severity.CRITICAL,
|
|
237
501
|
)
|
|
238
502
|
)
|
|
239
503
|
hypo_idx += 1
|
|
240
504
|
|
|
241
|
-
# 9. CORS Misconfiguration Hypothesis
|
|
505
|
+
# 9. CORS Misconfiguration Hypothesis on sensitive routes
|
|
242
506
|
if ep.auth_required and ep.sensitive_data:
|
|
243
507
|
hypotheses.append(
|
|
244
508
|
SecurityHypothesis(
|
|
@@ -253,12 +517,21 @@ class HypothesisEngine:
|
|
|
253
517
|
static_evidence=[
|
|
254
518
|
f"Authenticated: {ep.auth_required}, Sensitive data: {ep.sensitive_data}",
|
|
255
519
|
],
|
|
520
|
+
severity=Severity.MEDIUM,
|
|
256
521
|
)
|
|
257
522
|
)
|
|
258
523
|
hypo_idx += 1
|
|
259
524
|
|
|
260
525
|
# 10. Insecure Deserialization Hypothesis
|
|
261
|
-
|
|
526
|
+
# Strict criteria: Explicit restore/unpickle routes OR deserialization sinks in source code.
|
|
527
|
+
# Normal JSON endpoints (e.g. /api/session/start, /api/session/reset, /shots) must NEVER be falsely flagged!
|
|
528
|
+
has_deser_sink = any(
|
|
529
|
+
term in file_src
|
|
530
|
+
for term in ("pickle.loads", "pickle.load(", "yaml.load(", "yaml.unsafe_load", "marshal.loads", "shelve.open", "jsonpickle.", "unserialize(")
|
|
531
|
+
)
|
|
532
|
+
has_explicit_restore_path = any(term in ep.path.lower() for term in ("restore", "deserialize", "unpickle"))
|
|
533
|
+
|
|
534
|
+
if ep.method in ("POST", "PUT", "PATCH") and (has_explicit_restore_path or has_deser_sink):
|
|
262
535
|
hypotheses.append(
|
|
263
536
|
SecurityHypothesis(
|
|
264
537
|
id=f"HYP-DESER-{hypo_idx:03d}",
|
|
@@ -266,12 +539,94 @@ class HypothesisEngine:
|
|
|
266
539
|
endpoint_id=ep.id,
|
|
267
540
|
endpoint_display=ep_disp,
|
|
268
541
|
title=f"Potential Insecure Deserialization on {ep_disp}",
|
|
269
|
-
description="
|
|
542
|
+
description="Endpoint accepts serialized object streams or invokes unsafe deserialization sinks.",
|
|
270
543
|
recommended_test_pack="deserialization",
|
|
271
|
-
confidence_prior=0.
|
|
544
|
+
confidence_prior=0.75,
|
|
272
545
|
static_evidence=[
|
|
273
|
-
f"
|
|
546
|
+
f"Deserialization trigger in method {ep.method}: {ep.path}",
|
|
547
|
+
f"Unsafe deserialization sink detected: {has_deser_sink}",
|
|
274
548
|
],
|
|
549
|
+
severity=Severity.CRITICAL,
|
|
550
|
+
)
|
|
551
|
+
)
|
|
552
|
+
hypo_idx += 1
|
|
553
|
+
|
|
554
|
+
# 11. Codebase-wide Architectural Security Issues (CORS Wildcard & Broken Password Hashing)
|
|
555
|
+
for fpath in source_files:
|
|
556
|
+
file_src = read_file(fpath)
|
|
557
|
+
if not file_src:
|
|
558
|
+
continue
|
|
559
|
+
|
|
560
|
+
# A. Insecure CORS Configuration (allow_origins=["*"] with allow_credentials=True)
|
|
561
|
+
cors_match = re.search(
|
|
562
|
+
r"CORSMiddleware.*?(?:allow_origins\s*=\s*\[\s*[\"']\*[\"']\s*\]|allow_origin_regex\s*=\s*[\"'].*?[\"']).*?allow_credentials\s*=\s*True",
|
|
563
|
+
file_src,
|
|
564
|
+
re.DOTALL | re.IGNORECASE,
|
|
565
|
+
) or re.search(
|
|
566
|
+
r"allow_credentials\s*=\s*True.*?allow_origins\s*=\s*\[\s*[\"']\*[\"']\s*\]",
|
|
567
|
+
file_src,
|
|
568
|
+
re.DOTALL | re.IGNORECASE,
|
|
569
|
+
)
|
|
570
|
+
if cors_match:
|
|
571
|
+
ep_ref = endpoints[0] if endpoints else None
|
|
572
|
+
ep_id = ep_ref.id if ep_ref else "ep_cors_global"
|
|
573
|
+
hypotheses.append(
|
|
574
|
+
SecurityHypothesis(
|
|
575
|
+
id=f"HYP-CORS-{hypo_idx:03d}",
|
|
576
|
+
category=VulnerabilityCategory.CORS,
|
|
577
|
+
endpoint_id=ep_id,
|
|
578
|
+
endpoint_display="app (CORSMiddleware)",
|
|
579
|
+
title="Insecure CORS Wildcard with Credentials Enabled",
|
|
580
|
+
description="Backend CORS middleware is configured with allow_origins=['*'] and allow_credentials=True, enabling credentialed cross-origin requests from arbitrary web origins.",
|
|
581
|
+
recommended_test_pack="cors",
|
|
582
|
+
confidence_prior=0.85,
|
|
583
|
+
static_evidence=[
|
|
584
|
+
f"Configuration in: {fpath}",
|
|
585
|
+
"CORSMiddleware: allow_origins=['*'] with allow_credentials=True",
|
|
586
|
+
],
|
|
587
|
+
severity=Severity.MEDIUM,
|
|
588
|
+
)
|
|
589
|
+
)
|
|
590
|
+
hypo_idx += 1
|
|
591
|
+
|
|
592
|
+
# B. Broken Password Hashing & Insecure Credential Storage
|
|
593
|
+
has_plaintext_fallback = bool(
|
|
594
|
+
re.search(r"if\s+hashed_password\s*==\s*plain_password:", file_src)
|
|
595
|
+
or re.search(r"if\s+db_password\s*==\s*plain_password:", file_src)
|
|
596
|
+
or re.search(r"hashed_password\s*==\s*password", file_src)
|
|
597
|
+
)
|
|
598
|
+
has_hardcoded_salt = bool(
|
|
599
|
+
re.search(r"(?:salt|SALT)\s*=\s*[\"'][^\"']+[\"']", file_src)
|
|
600
|
+
and ("sha256" in file_src or "hashlib" in file_src)
|
|
601
|
+
)
|
|
602
|
+
has_fast_hash = bool(
|
|
603
|
+
re.search(r"hashlib\.(?:sha256|md5|sha1)\(", file_src)
|
|
604
|
+
and ("password" in file_src.lower())
|
|
605
|
+
)
|
|
606
|
+
|
|
607
|
+
if has_plaintext_fallback or has_hardcoded_salt or has_fast_hash:
|
|
608
|
+
evidence = []
|
|
609
|
+
if has_plaintext_fallback:
|
|
610
|
+
evidence.append("Plaintext password comparison fallback detected: `if hashed_password == plain_password:`")
|
|
611
|
+
if has_hardcoded_salt:
|
|
612
|
+
evidence.append("Single hardcoded global salt detected in password hashing routine")
|
|
613
|
+
if has_fast_hash:
|
|
614
|
+
evidence.append("Fast single-round unkeyed hash (SHA-256) used without work factor (bcrypt/argon2)")
|
|
615
|
+
|
|
616
|
+
ep_ref = endpoints[0] if endpoints else None
|
|
617
|
+
ep_id = ep_ref.id if ep_ref else "ep_auth_crypto_global"
|
|
618
|
+
hypotheses.append(
|
|
619
|
+
SecurityHypothesis(
|
|
620
|
+
id=f"HYP-AUTH-{hypo_idx:03d}",
|
|
621
|
+
category=VulnerabilityCategory.AUTHENTICATION,
|
|
622
|
+
endpoint_id=ep_id,
|
|
623
|
+
endpoint_display="Authentication Module (verify_password / hash_password)",
|
|
624
|
+
title="Broken Password Hashing & Insecure Credential Storage",
|
|
625
|
+
description="Password verification allows plaintext fallback, uses a hardcoded global salt, and relies on unsalted single-round SHA-256 instead of an adaptive work-factor algorithm (bcrypt, argon2, pbkdf2).",
|
|
626
|
+
recommended_test_pack="authentication",
|
|
627
|
+
confidence_prior=0.90,
|
|
628
|
+
static_evidence=evidence,
|
|
629
|
+
severity=Severity.CRITICAL if has_plaintext_fallback else Severity.HIGH,
|
|
275
630
|
)
|
|
276
631
|
)
|
|
277
632
|
hypo_idx += 1
|