trace-sec 2.1.8 → 2.1.10

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.8",
3
+ "version": "2.1.10",
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.5"
7
+ version = "2.1.10"
8
8
  description = "Threat Reconnaissance & Attack-path Correlation Engine"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -69,14 +69,10 @@ console = Console()
69
69
 
70
70
  @app.callback()
71
71
  def main(ctx: typer.Context):
72
- """If no command is provided, launch interactive story mode (or show banner if non-interactive)."""
72
+ """If no command is provided, launch the interactive TRACE security terminal."""
73
73
  if ctx.invoked_subcommand is None:
74
- if sys.stdin.isatty():
75
- from trace_engine.interactive import run_interactive_story
76
- run_interactive_story()
77
- else:
78
- print_banner()
79
- print_security_notes()
74
+ from trace_engine.interactive import run_interactive_story
75
+ run_interactive_story()
80
76
 
81
77
 
82
78
  @app.command(name="interactive")
@@ -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
- REMEDIATION_MAP = {
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) middleware. "
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
- def get_remediation_for_category(category: str) -> str:
32
- return REMEDIATION_MAP.get(
33
- category.upper(),
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 typing import List
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
-
10
- class ExpressFrameworkAdapter(FrameworkAdapter):
11
- """Detects and extracts Express.js routes and middleware."""
12
-
13
- def can_handle(self, parsed_file: ParsedFile) -> bool:
14
- if parsed_file.language not in ("javascript", "typescript"):
15
- return False
16
- return any("express" in imp.module.lower() for imp in parsed_file.imports)
17
-
18
- def extract_endpoints(self, parsed_file: ParsedFile, content: str) -> List[Endpoint]:
19
- endpoints: List[Endpoint] = []
20
-
21
- # Multi-line regex supporting app, router, server, api
22
- pattern = re.compile(
23
- r"""(?:app|router|server|api)\.(get|post|put|delete|patch)\s*\(\s*["']([^"']+)["']\s*(?:,\s*([^)]+))?""",
24
- re.IGNORECASE | re.DOTALL,
25
- )
26
-
27
- content_lower = content.lower()
28
- has_db_sink = any(
29
- term in content_lower
30
- for term in ("mongoose", "prisma", "sequelize", "knex", "pool.query", "db.query", "db.collection")
31
- )
32
- has_ext_sink = any(
33
- term in content_lower
34
- for term in ("axios", "node-fetch", "fetch(", "needle", "got(", "http.request", "https.request")
35
- )
36
-
37
- for match in pattern.finditer(content):
38
- method = match.group(1).upper()
39
- raw_path = match.group(2)
40
- handler_and_middleware = match.group(3) or ""
41
-
42
- # Calculate accurate line number
43
- line_no = content[:match.start()].count("\n") + 1
44
-
45
- # Convert Express :id into {id}
46
- norm_path = re.sub(r":([a-zA-Z0-9_]+)", r"{\1}", raw_path)
47
- path_params = re.findall(r":([a-zA-Z0-9_]+)", raw_path)
48
-
49
- auth_required = any(
50
- term in handler_and_middleware.lower()
51
- for term in ("auth", "jwt", "passport", "verifytoken", "isauthenticated", "requireauth")
52
- )
53
- roles = ["admin"] if "admin" in handler_and_middleware.lower() or "admin" in norm_path.lower() else []
54
-
55
- ep_id = f"ep_express_{parsed_file.file_path}_{line_no}_{method}".replace("/", "_").replace(".", "_")
56
-
57
- endpoints.append(
58
- Endpoint(
59
- id=ep_id,
60
- method=method,
61
- path=norm_path,
62
- handler_name=f"route_handler_L{line_no}",
63
- auth_required=auth_required,
64
- roles=roles,
65
- parameters=[
66
- EndpointParameter(name=p, location="path", required=True)
67
- for p in path_params
68
- ],
69
- database_access=has_db_sink,
70
- object_identifier=bool(path_params),
71
- state_changing=method in ("POST", "PUT", "DELETE", "PATCH"),
72
- external_network=has_ext_sink,
73
- sensitive_data="admin" in norm_path.lower() or "user" in norm_path.lower() or "order" in norm_path.lower(),
74
- source=SourceLocation(
75
- file=parsed_file.file_path,
76
- line_start=line_no,
77
- line_end=line_no,
78
- ),
79
- )
80
- )
81
-
82
- return endpoints
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
- # App Router route/page
64
- if re.search(r'(?:^|/)(?:src/)?app/.+/(?:route|page)\.[a-zA-Z0-9]+$', norm_path):
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 api or page
68
- if re.search(r'(?:^|/)(?:src/)?pages/(?:api/)?.+\.[a-zA-Z0-9]+$', norm_path):
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
- # Imports from next
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" or mod_low.startswith("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
- is_app_router = bool(re.search(r'(?:^|/)(?:src/)?app/', norm_file_path))
84
- is_pages_router = bool(re.search(r'(?:^|/)(?:src/)?pages/', norm_file_path))
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 = "/" + norm_file_path.split("/")[-1].split(".")[0]
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 or App Router default export
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
- methods_to_add = set(checked_methods) if checked_methods else (["GET"] if "page." in norm_file_path else ["GET", "POST"])
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.lower() or basename.startswith("."):
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
- # Check if file is within a routes directory (Remix / React Router convention)
131
- if "/routes/" in norm_path or norm_path.startswith("routes/"):
132
- # Exclude non-route subdirectories like styles, assets, or types
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 is a route file (e.g. in routes/ or mapped) but defines neither loader nor action nor verbs,
307
- # it is a page component serving GET
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
@@ -87,11 +87,15 @@ def render_big_logo() -> Text:
87
87
 
88
88
  def render_big_banner(animated: bool = False) -> None:
89
89
  """Renders the sleek minimalist TRACE banner in Mint Green and Crisp White."""
90
+ from trace_engine.intelligence.downloader import is_environment_ready
91
+ models_ready = is_environment_ready()
92
+
90
93
  console.print()
91
94
  console.print(render_big_logo())
92
- console.print(f" [{BOLD_WHITE}]Threat Reconnaissance & Attack-path Correlation Engine[/{BOLD_WHITE}] [{GREEN}]v2.1.0[/{GREEN}]")
95
+ console.print(f" [{BOLD_WHITE}]Threat Reconnaissance & Attack-path Correlation Engine[/{BOLD_WHITE}] [{GREEN}]v2.1.8[/{GREEN}]")
93
96
  console.print(f" [{DIM_WHITE}]Autonomous Neuro-Symbolic Security Intelligence & Live Verification[/{DIM_WHITE}]")
94
- console.print(f" [{GREEN}]● 100% Offline[/{GREEN}] [{WHITE}]•[/{WHITE}] [{GREEN}]● Zero External Telemetry[/{GREEN}] [{WHITE}]•[/{WHITE}] [{GREEN}]● SecureBERT & Laya AI Active[/{GREEN}]")
97
+ ai_status = f"[{GREEN}]● SecureBERT & Laya AI Active[/{GREEN}]" if models_ready else f"[{ORANGE}]● Neural Weights: Optional (AST Semantic Engine Active)[/{ORANGE}]"
98
+ console.print(f" [{GREEN}]● 100% Offline[/{GREEN}] [{WHITE}]•[/{WHITE}] [{GREEN}]● Zero External Telemetry[/{GREEN}] [{WHITE}]•[/{WHITE}] {ai_status}")
95
99
  console.print(f" [{DIM_GREEN}]{'─' * 76}[/{DIM_GREEN}]\n")
96
100
  if animated:
97
101
  time.sleep(0.08)
@@ -123,12 +127,19 @@ def ask_input(
123
127
  "QUIT",
124
128
  "Q",
125
129
  ]
126
- val = Prompt.ask(
127
- prompt_str,
128
- choices=allowed_choices,
129
- default=default,
130
- show_default=show_default,
131
- )
130
+ try:
131
+ val = Prompt.ask(
132
+ prompt_str,
133
+ choices=allowed_choices,
134
+ default=default,
135
+ show_default=show_default,
136
+ )
137
+ except (EOFError, KeyboardInterrupt):
138
+ if default is not None:
139
+ return default
140
+ console.print(f"\n [{GREEN}]✓ TRACE session closed. Happy hacking![/{GREEN}]\n")
141
+ sys.exit(0)
142
+
132
143
  if str(val).strip().lower() in ("exit", "quit", "q", ":q"):
133
144
  console.print(
134
145
  f"\n [{GREEN}]✓ TRACE session closed. Happy hacking![/{GREEN}]\n"
@@ -278,9 +289,8 @@ def run_interactive_story() -> None:
278
289
  """Executes the complete interactive, step-by-step security audit story."""
279
290
  render_big_banner(animated=True)
280
291
 
281
- from trace_engine.intelligence.downloader import prompt_and_bootstrap_models
282
- if not prompt_and_bootstrap_models(interactive=True):
283
- return
292
+ from trace_engine.intelligence.downloader import is_environment_ready, ensure_all_models
293
+ models_ready = is_environment_ready()
284
294
 
285
295
  console.print(f" [{WHITE}]Welcome, Security Operator. TRACE stands ready to audit, verify, and heal codebases.[/{WHITE}]")
286
296
  console.print(f" [{DIM_WHITE}]Navigate using the options below (type 'exit' or 'q' at any prompt to quit).[/{DIM_WHITE}]\n")
@@ -289,25 +299,42 @@ def run_interactive_story() -> None:
289
299
  # STEP 1: Mission Selection
290
300
  # =========================================================================
291
301
  console.print(f" [{BOLD_WHITE}]Select Audit Mission:[/{BOLD_WHITE}]")
292
- console.print(f" [{ORANGE}][1][/{ORANGE}] [{BOLD_WHITE}]Full Security Audit[/{BOLD_WHITE}] [{GREEN}]★ RECOMMENDED[/{GREEN}] [{DIM_WHITE}]─ Multi-language AST, APM graph, & Neural AI[/{DIM_WHITE}]")
302
+ console.print(f" [{ORANGE}][1][/{ORANGE}] [{BOLD_WHITE}]Full Security Audit[/{BOLD_WHITE}] [{GREEN}]★ RECOMMENDED[/{GREEN}] [{DIM_WHITE}]─ Multi-language AST, APM graph, & Security Hypotheses[/{DIM_WHITE}]")
293
303
  console.print(f" [{ORANGE}][2][/{ORANGE}] [{BOLD_WHITE}]Targeted Vulnerability Probes[/{BOLD_WHITE}] [{DIM_WHITE}]─ Select specific testpacks (BOLA, SQLi, SSRF, BFLA)[/{DIM_WHITE}]")
294
304
  console.print(f" [{ORANGE}][3][/{ORANGE}] [{BOLD_WHITE}]Autonomous AST Self-Healing[/{BOLD_WHITE}] [{DIM_WHITE}]─ Surgical refactoring with live rollback verification[/{DIM_WHITE}]")
295
305
  console.print(f" [{ORANGE}][4][/{ORANGE}] [{BOLD_WHITE}]Start Claude Code Local MCP Server[/{BOLD_WHITE}] [{GREEN}]★ LOCAL MCP[/{GREEN}] [{DIM_WHITE}]─ Run HTTP/SSE bridge for Claude Code chat[/{DIM_WHITE}]")
296
- console.print(f" [{ORANGE}][5][/{ORANGE}] [{BOLD_WHITE}]How TRACE Works & Architecture Guide[/{BOLD_WHITE}] [{DIM_WHITE}]─ Interactive architectural diagram & neural models[/{DIM_WHITE}]")
306
+ console.print(f" [{ORANGE}][5][/{ORANGE}] [{BOLD_WHITE}]How TRACE Works & Architecture Guide[/{BOLD_WHITE}] [{DIM_WHITE}]─ Interactive architectural diagram & operational FAQ[/{DIM_WHITE}]")
297
307
  console.print(f" [{ORANGE}][6][/{ORANGE}] [{BOLD_WHITE}]Environment Diagnostics (Doctor)[/{BOLD_WHITE}] [{DIM_WHITE}]─ Inspect local security tools and dependencies[/{DIM_WHITE}]")
298
- console.print(f" [{ORANGE}][7][/{ORANGE}] [{BOLD_WHITE}]Exit[/{BOLD_WHITE}]\n")
308
+ if not models_ready:
309
+ console.print(f" [{ORANGE}][7][/{ORANGE}] [{BOLD_WHITE}]Download Neural Weights[/{BOLD_WHITE}] [{DIM_WHITE}]─ Provision ~1 GB SecureBERT 2.0 & Laya AI into ~/.trace/models[/{DIM_WHITE}]")
310
+ console.print(f" [{ORANGE}][8][/{ORANGE}] [{BOLD_WHITE}]Exit[/{BOLD_WHITE}]\n")
311
+ valid_choices = ["1", "2", "3", "4", "5", "6", "7", "8"]
312
+ else:
313
+ console.print(f" [{ORANGE}][7][/{ORANGE}] [{BOLD_WHITE}]Exit[/{BOLD_WHITE}]\n")
314
+ valid_choices = ["1", "2", "3", "4", "5", "6", "7"]
299
315
 
300
316
  choice = ask_input(
301
317
  f" [{ORANGE}]›[/{ORANGE}] [{BOLD_WHITE}]Choose option[/{BOLD_WHITE}]",
302
- choices=["1", "2", "3", "4", "5", "6", "7"],
318
+ choices=valid_choices,
303
319
  default="1",
304
320
  show_default=True,
305
321
  )
306
322
 
307
- if choice == "7":
323
+ if (not models_ready and choice == "8") or (models_ready and choice == "7"):
308
324
  console.print(f"\n [{GREEN}]✓ TRACE session closed. Happy hacking![/{GREEN}]\n")
309
325
  return
310
326
 
327
+ if not models_ready and choice == "7":
328
+ display_story_header(2, 2, "Neural Model Provisioning", "Downloading fine-tuned SecureBERT 2.0 & Laya AI from GitHub LFS Hub")
329
+ console.print(f" [{WHITE}]Downloading fine-tuned neural models into ~/.trace/models/...[/{WHITE}]")
330
+ try:
331
+ ensure_all_models()
332
+ console.print(f"\n [{GREEN}]✓ Neural models provisioned successfully. SecureBERT & Laya are now online![/{GREEN}]\n")
333
+ except Exception as e:
334
+ console.print(f"\n [{ORANGE}][!] Download could not complete:[/{ORANGE}] [{WHITE}]{e}[/{WHITE}]")
335
+ console.print(f" [{DIM_WHITE}]You can continue using TRACE with the built-in deterministic AST & semantic engine.[/{DIM_WHITE}]\n")
336
+ return
337
+
311
338
  if choice == "4":
312
339
  from trace_engine.mcp.server import TraceMCPServer
313
340
  mcp_obj = TraceMCPServer()
@@ -484,7 +511,8 @@ def run_interactive_story() -> None:
484
511
 
485
512
  # Phase 3: Neuro-Symbolic Bayesian Hypotheses with Real Models
486
513
  model_metrics: Dict[str, Any] = {}
487
- with console.status(f" [{GREEN}]Act III:[/{GREEN}] [{WHITE}]Evaluating SecureBERT 2.0 & Laya System 1 Neural Models on GPU...[/{WHITE}]", spinner="dots"):
514
+ act3_status = "Evaluating SecureBERT 2.0 & Laya System 1 Neural Models..." if models_ready else "Evaluating Security Hypotheses with AST Semantic Engine..."
515
+ with console.status(f" [{GREEN}]Act III:[/{GREEN}] [{WHITE}]{act3_status}[/{WHITE}]", spinner="dots"):
488
516
  engine = HypothesisEngine()
489
517
  all_hypotheses = engine.derive_hypotheses(graph_model, discovered_endpoints)
490
518
 
@@ -518,8 +546,9 @@ def run_interactive_story() -> None:
518
546
  "laya": {"inferences": len(discovered_endpoints), "avg_latency_ms": 0.5, "breakdown": "Evaluated across P0/P1/P2/P3", "available": True},
519
547
  }
520
548
 
549
+ engine_label = "via SecureBERT & Laya" if models_ready else "via AST Semantic Engine"
521
550
  console.print(
522
- f" [{GREEN}]✓ Act III (Neural AI & Hypotheses):[/{GREEN}] [{BOLD_WHITE}]Derived {len(hypotheses)} threat hypotheses[/{BOLD_WHITE}] [{DIM_WHITE}]via SecureBERT & Laya[/{DIM_WHITE}]"
551
+ f" [{GREEN}]✓ Act III (Security Hypotheses):[/{GREEN}] [{BOLD_WHITE}]Derived {len(hypotheses)} threat hypotheses[/{BOLD_WHITE}] [{DIM_WHITE}]{engine_label}[/{DIM_WHITE}]"
523
552
  )
524
553
 
525
554
  # Phase 4: Dynamic Exploit Probes
@@ -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
- if not ep.path.endswith("/login") and not ep.path.endswith("/register"):
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 or makes external network calls
113
- if ep.external_network or any("url" in p.name.lower() for p in ep.parameters):
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 handles URL parameter and communicates with outbound HTTP client.",
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 parameter detected: {[p.name for p in ep.parameters if 'url' in p.name.lower()]}",
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 = [p.name for p in ep.parameters if any(term in p.name.lower() for term in ("file", "path", "doc", "dir", "download", "name", "asset", "key"))]
174
- if file_param_names or any(term in ep.path.lower() for term in ("file", "doc", "download", "asset", "static")):
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}",