trace-sec 2.1.11 → 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.11",
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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "trace-sec"
7
- version = "2.1.11"
7
+ version = "2.1.13"
8
8
  description = "Threat Reconnaissance & Attack-path Correlation Engine"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -3,6 +3,6 @@
3
3
  Local-first application security analysis and runtime validation engine.
4
4
  """
5
5
 
6
- __version__ = "1.0.0"
6
+ __version__ = "2.1.13"
7
7
  __app_name__ = "TRACE"
8
8
  __description__ = "Threat Reconnaissance & Attack-path Correlation Engine"
@@ -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.CRITICAL,
14
+ "AUTHENTICATION": Severity.HIGH,
15
15
  "SSRF": Severity.HIGH,
16
16
  "INJECTION": Severity.CRITICAL,
17
17
  "MASS_ASSIGNMENT": Severity.MEDIUM,
18
- "PATH_TRAVERSAL": Severity.HIGH,
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
- ep_count = 0
26
-
27
- # Also search decorators in parsed 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' ...) or @router.post('/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
- ep_count += 1
39
- method = match.group(1).upper()
40
- path = match.group(2)
41
- ep_id = f"ep_{parsed_file.file_path.replace('/', '_').replace('.', '_')}_{fn.name}_{method}"
42
-
43
- # Detect path parameters: {param_name}
44
- path_params = re.findall(r"\{([a-zA-Z0-9_]+)\}", path)
45
- params: List[EndpointParameter] = [
46
- EndpointParameter(name=p, location="path", required=True)
47
- for p in path_params
48
- ]
49
-
50
- # Detect function params from AST
51
- for p in fn.parameters:
52
- if p.name not in ("self", "cls") and p.name not in path_params:
53
- params.append(
54
- EndpointParameter(
55
- name=p.name,
56
- location="query" if method == "GET" else "body",
57
- param_type=p.type_annotation or "string",
58
- )
59
- )
60
-
61
- # Analyze auth dependencies in decorator or args
62
- auth_required = False
63
- roles = []
64
- fn_body = fn.body_text or ""
65
-
66
- auth_indicators = [
67
- "current_user",
68
- "auth",
69
- "token",
70
- "security",
71
- "jwt",
72
- "oauth",
73
- "credentials",
74
- "verify_token",
75
- "api_key",
76
- ]
77
- for p in fn.parameters:
78
- p_ann = (p.type_annotation or "").lower()
79
- p_def = (p.default_value or "").lower()
80
- if any(ind in p.name.lower() or ind in p_ann or ind in p_def for ind in auth_indicators):
81
- auth_required = True
82
- if "admin" in p.name.lower() or "admin" in p_ann or "admin" in p_def:
83
- roles.append("admin")
84
-
85
- if "require_admin" in dec_text.lower() or "admin" in path.lower():
86
- roles.append("admin")
87
- if "current_user" in dec_text.lower() or "depends(get_current_user" in dec_text.lower():
88
- auth_required = True
89
-
90
- # Object identifier flag (e.g. /items/{id})
91
- has_obj_id = any(
92
- p in ("id", "user_id", "order_id", "product_id", "item_id")
93
- for p in path_params
94
- )
95
-
96
- # State changing
97
- state_changing = method in ("POST", "PUT", "DELETE", "PATCH")
98
-
99
- # Database access check
100
- db_access = any(
101
- term in fn_body.lower()
102
- for term in ("db.", "session.", "cursor.", "repository", "query(", ".filter(", "orders", "database", "find")
103
- )
104
-
105
- # Outbound network (SSRF indicator)
106
- ext_network = any(
107
- term in fn_body.lower()
108
- for term in ("requests.", "httpx.", "urllib.", "aiohttp.", "fetch(")
109
- )
110
-
111
- # Sensitive data
112
- sensitive = any(
113
- term in path.lower() or term in fn_body.lower()
114
- for term in ("password", "secret", "token", "credit", "card", "user", "order", "refund", "admin")
115
- )
116
-
117
- endpoints.append(
118
- Endpoint(
119
- id=ep_id,
120
- method=method,
121
- path=path,
122
- handler_name=fn.qualified_name,
123
- auth_required=auth_required,
124
- roles=list(set(roles)),
125
- parameters=params,
126
- database_access=db_access,
127
- object_identifier=has_obj_id,
128
- state_changing=state_changing,
129
- external_network=ext_network,
130
- sensitive_data=sensitive,
131
- source=fn.location,
132
- )
133
- )
134
-
135
- return endpoints
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
@@ -60,6 +60,9 @@ BOLD_WHITE = "bold white"
60
60
  DIM_WHITE = "dim white"
61
61
  GREEN = "bold #10B981"
62
62
  DIM_GREEN = "dim #10B981"
63
+ RED = "bold #EF4444"
64
+ DIM_RED = "dim #EF4444"
65
+ PURPLE = "bold #A855F7"
63
66
 
64
67
  # Clean ASCII Logo (Mint Green body/middle with Crisp White shadow)
65
68
  BIG_TRACE_LOGO = """\
@@ -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
- if "admin" in ep.path.lower() or "admin" in [r.lower() for r in ep.roles]:
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=f"Potential BFLA on privileged endpoint {ep_disp}",
79
- description="Administrative/privileged action may fail to enforce strict RBAC check at runtime.",
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
- f"Privileged path/role identifier present: {ep.roles or ['admin path']}",
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 endpoint without auth
91
- if not ep.auth_required and (ep.state_changing or ep.sensitive_data):
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:
99
- hypotheses.append(
100
- SecurityHypothesis(
101
- id=f"HYP-AUTH-{hypo_idx:03d}",
102
- category=VulnerabilityCategory.AUTHENTICATION,
103
- endpoint_id=ep.id,
104
- endpoint_display=ep_disp,
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
- hypo_idx += 1
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 Hypothesis: PATCH or PUT or POST on user or profile
148
- if ep.method in ("PATCH", "PUT", "POST") and any(term in ep.path.lower() for term in ("user", "profile", "account")):
149
- hypotheses.append(
150
- SecurityHypothesis(
151
- id=f"HYP-MASS-{hypo_idx:03d}",
152
- category=VulnerabilityCategory.MASS_ASSIGNMENT,
153
- endpoint_id=ep.id,
154
- endpoint_display=ep_disp,
155
- title=f"Potential Mass Assignment on {ep_disp}",
156
- description="Profile update endpoint may bind sensitive attributes (e.g. is_admin, role) directly from payload.",
157
- recommended_test_pack="mass_assignment",
158
- confidence_prior=0.65,
159
- static_evidence=[
160
- f"State modifying method {ep.method} on profile/user path",
161
- "Model payload deserialization detected",
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
- hypo_idx += 1
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
- # Must have actual file parameter or file route with parameters
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
- f"File/path parameter names detected: {file_param_names or [ep.path]}",
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
- if ep.method in ("POST", "PUT", "PATCH") and any(term in ep.path.lower() for term in ("import", "upload", "state", "event", "session", "restore", "sync")):
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="State or data restoration endpoint accepting structured object representations.",
542
+ description="Endpoint accepts serialized object streams or invokes unsafe deserialization sinks.",
270
543
  recommended_test_pack="deserialization",
271
- confidence_prior=0.60,
544
+ confidence_prior=0.75,
272
545
  static_evidence=[
273
- f"State restoration path pattern in method {ep.method}: {ep.path}",
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