trace-sec 2.1.9 → 2.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trace-sec",
3
- "version": "2.1.9",
3
+ "version": "2.1.11",
4
4
  "description": "Threat Reconnaissance & Attack-path Correlation Engine — Security Verification Layer for Coding Agents with Fine-Tuned SecureBERT 2.0 & Laya AI",
5
5
  "type": "module",
6
6
  "main": "bin/trace.js",
package/pyproject.toml CHANGED
@@ -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.11"
8
8
  description = "Threat Reconnaissance & Attack-path Correlation Engine"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -289,22 +289,69 @@ def test(
289
289
  active_tokens={"user-a": "user-a", "user-b": "user-b", "admin": "admin"},
290
290
  )
291
291
 
292
- console.print(f"\n[bold green]Executing Controlled Runtime Tests[/bold green] against [white]{target}[/white]...")
292
+ # Check target connectivity
293
+ target_online = False
294
+ try:
295
+ import httpx
296
+ with httpx.Client(timeout=1.0) as chk:
297
+ chk.get(target)
298
+ target_online = True
299
+ except Exception:
300
+ target_online = False
301
+
302
+ if target_online:
303
+ console.print(f"\n[bold green]Executing Controlled Runtime Tests[/bold green] against [white]{target}[/white]...")
304
+ else:
305
+ console.print(f"\n[yellow]Notice:[/yellow] Target runtime [white]{target}[/white] is offline or unreachable.")
306
+ console.print(f" [dim]• Active HTTP exploit probes require a running service (e.g. localhost or lab testbed).[/dim]")
307
+ console.print(f" [dim]• Auditing codebase via Neuro-Symbolic Attack-Path Model (Safe Offline Mode)...[/dim]\n")
308
+
293
309
  executed = 0
294
310
  confirmed = 0
311
+ correlator = EvidenceCorrelator()
312
+ findings: List[Finding] = []
295
313
 
296
314
  for hyp in hypotheses:
297
- pack = default_registry.get(hyp.recommended_test_pack)
298
- if pack:
299
- executed += 1
300
- res = pack.execute(hyp, client, context)
301
- if res.confirmed:
302
- confirmed += 1
303
- console.print(f" [bold red]CONFIRMED:[/bold red] [{hyp.id}] {res.summary}")
304
- else:
305
- console.print(f" [dim]Passed / Inconclusive:[/dim] [{hyp.id}] {res.summary}")
306
-
307
- console.print(f"\n[dim]Executed {executed} test packs. {confirmed} attack paths confirmed.[/dim]\n")
315
+ test_res = None
316
+ if target_online:
317
+ pack = default_registry.get(hyp.recommended_test_pack)
318
+ if pack:
319
+ executed += 1
320
+ try:
321
+ test_res = pack.execute(hyp, client, context)
322
+ if test_res and test_res.confirmed:
323
+ confirmed += 1
324
+ console.print(f" [bold red]CONFIRMED:[/bold red] [{hyp.id}] {test_res.summary}")
325
+ else:
326
+ console.print(f" [dim]Passed / Inconclusive:[/dim] [{hyp.id}] {test_res.summary}")
327
+ except Exception:
328
+ test_res = None
329
+
330
+ finding = correlator.correlate(hyp, test_res, graph_model)
331
+ if finding:
332
+ findings.append(finding)
333
+
334
+ # Persist findings & APM
335
+ trace_dir = init_trace_dir(project_dir)
336
+ save_apm_sqlite(graph_model, trace_dir / "graph.db")
337
+ store = FindingStore(trace_dir)
338
+ store.save_findings(findings)
339
+
340
+ try:
341
+ root_trace = Path(".trace")
342
+ root_trace.mkdir(parents=True, exist_ok=True)
343
+ (root_trace / "last_scan_repo.txt").write_text(str(project_dir), encoding="utf-8")
344
+ if project_dir != Path(".").resolve():
345
+ root_store = FindingStore(root_trace)
346
+ root_store.save_findings(findings)
347
+ except Exception:
348
+ pass
349
+
350
+ if target_online:
351
+ console.print(f"\n[dim]Executed {executed} test packs. {confirmed} attack paths confirmed at runtime.[/dim]\n")
352
+
353
+ print_findings_table(findings)
354
+ console.print(f"[dim]Total findings identified: {len(findings)} (Saved to {trace_dir / 'findings.json'})[/dim]\n")
308
355
 
309
356
 
310
357
  @app.command()
@@ -67,6 +67,10 @@ class EvidenceCorrelator:
67
67
  runtime_evidence.append(
68
68
  f"HTTP {obs.request_method} {obs.request_url} -> Status {obs.response_status} ({obs.latency_ms}ms)"
69
69
  )
70
+ else:
71
+ runtime_evidence.append(
72
+ "Static Attack-Path Model verified vulnerable control boundary. Active dynamic probe was not executed (offline scan mode or live target host not specified)."
73
+ )
70
74
 
71
75
  # Correlation explanation
72
76
  if test_result and test_result.confirmed:
@@ -85,6 +89,8 @@ class EvidenceCorrelator:
85
89
  ep_node = apm.get_node(hypothesis.endpoint_id)
86
90
  source_loc = ep_node.location if ep_node else None
87
91
 
92
+ file_path = source_loc.file if source_loc else ""
93
+
88
94
  return Finding(
89
95
  id=finding_id,
90
96
  title=hypothesis.title,
@@ -97,7 +103,7 @@ class EvidenceCorrelator:
97
103
  static_evidence=hypothesis.static_evidence,
98
104
  runtime_evidence=runtime_evidence,
99
105
  correlation_notes=correlation_notes,
100
- remediation=get_remediation_for_category(category_str),
106
+ remediation=get_remediation_for_category(category_str, filepath=file_path),
101
107
  reproduction_steps=test_result.reproduction_steps if test_result else [],
102
108
  observations=test_result.observations if test_result else [],
103
109
  )
@@ -1,12 +1,15 @@
1
1
  """Remediation recommendations for security findings."""
2
2
 
3
- 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
@@ -438,13 +438,14 @@ def run_interactive_story() -> None:
438
438
  display_story_header(4, 4, "Attack Vector Selection", "Select specific vulnerability testpacks to deploy")
439
439
 
440
440
  pack_mapping = {
441
- "1": ("BOLA / IDOR", "bola", "Multi-tenant object access & IDOR cross-account authorization leak"),
442
- "2": ("SQL & Command Injection", "injection", "Statistical timing oracle (p < 0.01) & error extraction"),
443
- "3": ("SSRF ScopeGuard", "ssrf", "Egress network validation against RFC 1918 loopback & metadata IPs"),
444
- "4": ("BFLA / RBAC Elevation", "bfla", "Broken function-level authorization & administrative privilege check"),
445
- "5": ("Path Traversal", "path_traversal", "Canonical directory escape & arbitrary file disclosure probes"),
446
- "6": ("CORS Misconfiguration", "cors", "Wildcard Access-Control-Allow-Origin & credential reflection"),
447
- "7": ("Mass Assignment", "mass_assignment", "Unbounded payload parameter binding to persistent entities"),
441
+ "1": ("Authentication & Auth Gates", "authentication", "Missing token validation & unauthenticated state-changing routes"),
442
+ "2": ("BOLA / IDOR", "bola", "Multi-tenant object access & IDOR cross-account authorization leak"),
443
+ "3": ("SQL & Command Injection", "injection", "Statistical timing oracle (p < 0.01) & error extraction"),
444
+ "4": ("SSRF ScopeGuard", "ssrf", "Egress network validation against RFC 1918 loopback & metadata IPs"),
445
+ "5": ("BFLA / RBAC Elevation", "bfla", "Broken function-level authorization & administrative privilege check"),
446
+ "6": ("Path Traversal", "path_traversal", "Canonical directory escape & arbitrary file disclosure probes"),
447
+ "7": ("CORS Misconfiguration", "cors", "Wildcard Access-Control-Allow-Origin & credential reflection"),
448
+ "8": ("Mass Assignment", "mass_assignment", "Unbounded payload parameter binding to persistent entities"),
448
449
  }
449
450
 
450
451
  console.print(f" [{BOLD_WHITE}]Available Testpacks:[/{BOLD_WHITE}]")
@@ -518,6 +519,15 @@ def run_interactive_story() -> None:
518
519
 
519
520
  if selected_packs:
520
521
  hypotheses = [h for h in all_hypotheses if h.recommended_test_pack in selected_packs]
522
+ if not hypotheses and all_hypotheses:
523
+ avail_cats = list({h.category.value for h in all_hypotheses})
524
+ console.print(
525
+ f"\n [{ORANGE}]Notice:[/{ORANGE}] [{WHITE}]No endpoints matched the selected testpack filter ({', '.join(selected_packs)}).[/{WHITE}]"
526
+ )
527
+ console.print(
528
+ f" [{DIM_WHITE}]Codebase contains attack vectors for: {', '.join(avail_cats)}. Auditing all codebase attack paths to prevent false negatives...[/{DIM_WHITE}]\n"
529
+ )
530
+ hypotheses = all_hypotheses
521
531
  else:
522
532
  hypotheses = all_hypotheses
523
533
 
@@ -89,7 +89,13 @@ class HypothesisEngine:
89
89
 
90
90
  # 3. Authentication Bypass / Missing Auth: State-changing endpoint without auth
91
91
  if not ep.auth_required and (ep.state_changing or ep.sensitive_data):
92
- 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}",
@@ -48,12 +48,18 @@ class AuthenticationTestPack(TestPack):
48
48
  f"Send unauthenticated {method} request to {target_url}",
49
49
  f"Received HTTP {obs.response_status} rather than expected 401/403",
50
50
  ]
51
+ elif obs.response_status == 0:
52
+ confirmed = False
53
+ summary = f"Target host unreachable ({obs.response_body}). Live probe inconclusive."
54
+ else:
55
+ confirmed = False
56
+ summary = f"Authentication enforced (server responded with HTTP {obs.response_status})."
51
57
 
52
58
  return TestExecutionResult(
53
59
  testpack_name=self.name,
54
60
  hypothesis_id=hypothesis.id,
55
61
  confirmed=confirmed,
56
- confidence=0.90 if confirmed else 0.15,
62
+ confidence=0.90 if confirmed else (None if obs.response_status == 0 else 0.15),
57
63
  summary=summary,
58
64
  observations=observations,
59
65
  reproduction_steps=steps,
@@ -58,12 +58,18 @@ class BflaTestPack(TestPack):
58
58
  f"Invoke privileged endpoint {method} {target_url}",
59
59
  f"Received HTTP {obs.response_status} indicating unauthorized function execution",
60
60
  ]
61
+ elif obs.response_status == 0:
62
+ confirmed = False
63
+ summary = f"Target host unreachable ({obs.response_body}). Live probe inconclusive."
64
+ else:
65
+ confirmed = False
66
+ summary = f"Function-level authorization enforced (received HTTP {obs.response_status})."
61
67
 
62
68
  return TestExecutionResult(
63
69
  testpack_name=self.name,
64
70
  hypothesis_id=hypothesis.id,
65
71
  confirmed=confirmed,
66
- confidence=0.92 if confirmed else 0.20,
72
+ confidence=0.92 if confirmed else (None if obs.response_status == 0 else 0.20),
67
73
  summary=summary,
68
74
  observations=observations,
69
75
  reproduction_steps=steps,
@@ -83,11 +83,15 @@ class BolaTestPack(TestPack):
83
83
  ]
84
84
  break
85
85
 
86
+ any_reachable = any(obs.response_status != 0 for obs in observations)
87
+ if not confirmed and not any_reachable:
88
+ summary = "Target host unreachable. Dynamic probe inconclusive."
89
+
86
90
  return TestExecutionResult(
87
91
  testpack_name=self.name,
88
92
  hypothesis_id=hypothesis.id,
89
93
  confirmed=confirmed,
90
- confidence=0.98 if confirmed else 0.20,
94
+ confidence=0.98 if confirmed else (None if not any_reachable else 0.20),
91
95
  summary=summary,
92
96
  observations=observations,
93
97
  reproduction_steps=steps,