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 +1 -1
- package/pyproject.toml +1 -1
- package/src/trace_engine/cli.py +3 -7
- package/src/trace_engine/findings/correlate.py +7 -1
- package/src/trace_engine/findings/recommendations.py +122 -5
- package/src/trace_engine/framework/express.py +243 -82
- package/src/trace_engine/framework/nextjs.py +54 -15
- package/src/trace_engine/framework/react_router.py +27 -32
- package/src/trace_engine/interactive.py +47 -18
- package/src/trace_engine/security/hypotheses.py +36 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trace-sec",
|
|
3
|
-
"version": "2.1.
|
|
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
package/src/trace_engine/cli.py
CHANGED
|
@@ -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
|
|
72
|
+
"""If no command is provided, launch the interactive TRACE security terminal."""
|
|
73
73
|
if ctx.invoked_subcommand is None:
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
REMEDIATION_MAP_PYTHON = {
|
|
4
7
|
"BOLA": (
|
|
5
8
|
"Enforce strict object ownership validation before returning records. "
|
|
6
9
|
"Query using both the object ID and the authenticated user/tenant ID (e.g. `WHERE id = :id AND user_id = :current_user.id`)."
|
|
7
10
|
),
|
|
8
11
|
"BFLA": (
|
|
9
|
-
"Implement declarative role-based access control (RBAC)
|
|
12
|
+
"Implement declarative role-based access control (RBAC) dependencies (e.g. `Security(require_role('admin'))`). "
|
|
10
13
|
"Validate user privileges on the backend for every privileged/administrative route."
|
|
11
14
|
),
|
|
12
15
|
"AUTHENTICATION": (
|
|
@@ -25,11 +28,125 @@ REMEDIATION_MAP = {
|
|
|
25
28
|
"Define explicit request DTO schemas (e.g. Pydantic models with only allowed mutable fields). "
|
|
26
29
|
"Do not allow raw dictionary binding directly into database models."
|
|
27
30
|
),
|
|
31
|
+
"PATH_TRAVERSAL": (
|
|
32
|
+
"Sanitize file path inputs using `os.path.basename()` or `pathlib.Path.resolve()`, "
|
|
33
|
+
"and verify that the resolved path is strictly within the allowed directory. Disallow path traversal sequences (`..`)."
|
|
34
|
+
),
|
|
35
|
+
"SSTI": (
|
|
36
|
+
"Avoid rendering untrusted user inputs directly inside template engines. "
|
|
37
|
+
"Use contextual output encoding or sandbox template evaluation environments."
|
|
38
|
+
),
|
|
39
|
+
"CORS": (
|
|
40
|
+
"Do not reflect arbitrary Origin headers or use wildcard `*` with credentials. "
|
|
41
|
+
"Configure an explicit allowlist of trusted origins."
|
|
42
|
+
),
|
|
43
|
+
"DESERIALIZATION": (
|
|
44
|
+
"Avoid deserializing untrusted payloads (e.g. `pickle.loads`). "
|
|
45
|
+
"Use safe serialization formats like standard JSON with strict schema validation."
|
|
46
|
+
),
|
|
28
47
|
}
|
|
29
48
|
|
|
49
|
+
REMEDIATION_MAP_NODE = {
|
|
50
|
+
"BOLA": (
|
|
51
|
+
"Enforce strict object ownership validation before returning or modifying records. "
|
|
52
|
+
"Scope queries using both the object ID and the authenticated user/tenant identity (e.g. `findByIdAndUserId(req.params.id, req.user.id)` or `WHERE id = ? AND tenant_id = ?`)."
|
|
53
|
+
),
|
|
54
|
+
"BFLA": (
|
|
55
|
+
"Implement role-based access control (RBAC) middleware (e.g. `requireRole('admin')` or `checkPermission(...)`). "
|
|
56
|
+
"Validate `req.user.role` on the backend before executing privileged operations."
|
|
57
|
+
),
|
|
58
|
+
"AUTHENTICATION": (
|
|
59
|
+
"Mount authentication middleware (e.g. `router.use(verifyToken)` or `app.use('/api/...', verifyToken, router)`) "
|
|
60
|
+
"to ensure unauthorized users cannot trigger state-changing or sensitive operations."
|
|
61
|
+
),
|
|
62
|
+
"SSRF": (
|
|
63
|
+
"Avoid making outbound HTTP requests (e.g. with `axios` or `fetch`) using user-controlled URLs. "
|
|
64
|
+
"Implement a strict destination domain allowlist and block loopback (`127.0.0.1`, `localhost`) and private RFC-1918 IP addresses."
|
|
65
|
+
),
|
|
66
|
+
"INJECTION": (
|
|
67
|
+
"Use parameterized queries, prepared statements, or ORM abstractions (e.g. Prisma, Mongoose, TypeORM, Knex). "
|
|
68
|
+
"Never concatenate or interpolate raw `req.body` or `req.query` into database queries or shell commands."
|
|
69
|
+
),
|
|
70
|
+
"MASS_ASSIGNMENT": (
|
|
71
|
+
"Define explicit request DTO schemas (e.g. using Zod, Joi, or TypeScript DTOs) and pick allowlisted fields from `req.body`. "
|
|
72
|
+
"Never pass raw `req.body` directly into database creation or update functions."
|
|
73
|
+
),
|
|
74
|
+
"PATH_TRAVERSAL": (
|
|
75
|
+
"Sanitize file path inputs using `path.basename()` or `path.resolve()`, "
|
|
76
|
+
"and verify that the target path strictly resides within the intended root directory (e.g. `resolvedPath.startsWith(BASE_DIR)`). Disallow directory traversal sequences (`..`)."
|
|
77
|
+
),
|
|
78
|
+
"SSTI": (
|
|
79
|
+
"Avoid rendering untrusted user inputs directly inside template engines. "
|
|
80
|
+
"Use contextual output encoding or sandbox template evaluation environments."
|
|
81
|
+
),
|
|
82
|
+
"CORS": (
|
|
83
|
+
"Do not reflect the `Origin` request header or allow `Access-Control-Allow-Origin: *` with `Access-Control-Allow-Credentials: true`. "
|
|
84
|
+
"Configure an explicit allowlist of trusted origins."
|
|
85
|
+
),
|
|
86
|
+
"DESERIALIZATION": (
|
|
87
|
+
"Avoid deserializing untrusted payloads (e.g. `node-serialize`). "
|
|
88
|
+
"Use safe data serialization formats like standard `JSON.parse` with strict schema validation."
|
|
89
|
+
),
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
REMEDIATION_MAP_GO = {
|
|
93
|
+
"BOLA": (
|
|
94
|
+
"Enforce strict tenant and user ownership validation. "
|
|
95
|
+
"Scope database queries to the authenticated user ID from context (e.g. `db.Where(\"id = ? AND user_id = ?\", id, currentUserID)`)."
|
|
96
|
+
),
|
|
97
|
+
"BFLA": (
|
|
98
|
+
"Implement role-based authorization checks in Gin/Echo middleware. "
|
|
99
|
+
"Verify user claims from context before proceeding with administrative actions."
|
|
100
|
+
),
|
|
101
|
+
"AUTHENTICATION": (
|
|
102
|
+
"Protect this route with authentication middleware (e.g. Gin/Echo middleware validating JWT/session token into context) "
|
|
103
|
+
"to ensure unauthorized users cannot trigger state-changing operations."
|
|
104
|
+
),
|
|
105
|
+
"SSRF": (
|
|
106
|
+
"Validate target URLs against an allowlist and block private IP ranges before executing `http.Get` or `http.Post`."
|
|
107
|
+
),
|
|
108
|
+
"INJECTION": (
|
|
109
|
+
"Use parameterized SQL placeholders (`?` or `$1`) with `database/sql` or GORM. Never concatenate input into raw SQL queries."
|
|
110
|
+
),
|
|
111
|
+
"MASS_ASSIGNMENT": (
|
|
112
|
+
"Bind incoming requests to dedicated request structs with strictly permitted fields instead of raw map or model structs."
|
|
113
|
+
),
|
|
114
|
+
"PATH_TRAVERSAL": (
|
|
115
|
+
"Use `filepath.Clean` and `filepath.Rel` to verify file paths stay within the target base directory."
|
|
116
|
+
),
|
|
117
|
+
"SSTI": "Ensure HTML templates use context-aware escaping (`html/template`).",
|
|
118
|
+
"CORS": "Configure explicit origins in CORS middleware. Do not allow wildcard origins with credentials.",
|
|
119
|
+
"DESERIALIZATION": "Use standard `encoding/json` with typed Go structs for decoding untrusted client data.",
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
# Default backwards-compatible map
|
|
123
|
+
REMEDIATION_MAP = REMEDIATION_MAP_PYTHON
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def get_remediation_for_category(category: str, filepath: str = "", framework: str = "") -> str:
|
|
127
|
+
"""Returns framework- and language-tailored remediation recommendations."""
|
|
128
|
+
cat_upper = category.upper()
|
|
129
|
+
file_lower = filepath.lower()
|
|
130
|
+
|
|
131
|
+
# Detect language / framework
|
|
132
|
+
if any(file_lower.endswith(ext) for ext in (".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs")) or framework.lower() in ("express", "nextjs", "react_router", "node"):
|
|
133
|
+
return REMEDIATION_MAP_NODE.get(
|
|
134
|
+
cat_upper,
|
|
135
|
+
"Review access control, authentication middleware, and input validation on this attack path.",
|
|
136
|
+
)
|
|
137
|
+
elif file_lower.endswith(".go") or framework.lower() == "go":
|
|
138
|
+
return REMEDIATION_MAP_GO.get(
|
|
139
|
+
cat_upper,
|
|
140
|
+
"Review access control and input validation on this attack path.",
|
|
141
|
+
)
|
|
142
|
+
elif file_lower.endswith(".py") or framework.lower() in ("fastapi", "flask", "django"):
|
|
143
|
+
return REMEDIATION_MAP_PYTHON.get(
|
|
144
|
+
cat_upper,
|
|
145
|
+
"Review access control and input validation on this attack path.",
|
|
146
|
+
)
|
|
30
147
|
|
|
31
|
-
|
|
32
|
-
return
|
|
33
|
-
|
|
148
|
+
# Fallback to general remediation
|
|
149
|
+
return REMEDIATION_MAP_NODE.get(cat_upper) or REMEDIATION_MAP_PYTHON.get(
|
|
150
|
+
cat_upper,
|
|
34
151
|
"Review access control and input validation on this attack path.",
|
|
35
152
|
)
|
|
@@ -1,82 +1,243 @@
|
|
|
1
|
-
"""Express.js framework adapter for endpoint discovery."""
|
|
2
|
-
|
|
3
|
-
import re
|
|
4
|
-
from
|
|
5
|
-
from
|
|
6
|
-
from trace_engine.
|
|
7
|
-
from trace_engine.
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
def
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
#
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
)
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
)
|
|
81
|
-
|
|
82
|
-
|
|
1
|
+
"""Express.js framework adapter for endpoint discovery."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import List, Dict, Tuple, Optional
|
|
6
|
+
from trace_engine.parsing.parser import ParsedFile
|
|
7
|
+
from trace_engine.framework.base import FrameworkAdapter, Endpoint, EndpointParameter
|
|
8
|
+
from trace_engine.parsing.locations import SourceLocation
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ExpressFrameworkAdapter(FrameworkAdapter):
|
|
12
|
+
"""Detects and extracts Express.js routes and middleware."""
|
|
13
|
+
|
|
14
|
+
def __init__(self):
|
|
15
|
+
# Cache for parsed server/entrypoint mount configurations: {server_file_abs_path: {router_stem: (prefix, has_auth)}}
|
|
16
|
+
self._server_mount_cache: Dict[str, Dict[str, Tuple[str, bool]]] = {}
|
|
17
|
+
|
|
18
|
+
def can_handle(self, parsed_file: ParsedFile) -> bool:
|
|
19
|
+
if parsed_file.language not in ("javascript", "typescript"):
|
|
20
|
+
return False
|
|
21
|
+
# Express route files must import express
|
|
22
|
+
return any("express" in imp.module.lower() for imp in parsed_file.imports)
|
|
23
|
+
|
|
24
|
+
def _discover_server_mounts(self, server_path: Path) -> Dict[str, Tuple[str, bool]]:
|
|
25
|
+
"""Parses server.js / app.js to find router mounts like app.use('/api/discovery', discoveryRoutes)."""
|
|
26
|
+
resolved_key = str(server_path.resolve())
|
|
27
|
+
if resolved_key in self._server_mount_cache:
|
|
28
|
+
return self._server_mount_cache[resolved_key]
|
|
29
|
+
|
|
30
|
+
mount_map: Dict[str, Tuple[str, bool]] = {}
|
|
31
|
+
try:
|
|
32
|
+
content = server_path.read_text(encoding="utf-8", errors="replace")
|
|
33
|
+
except Exception:
|
|
34
|
+
self._server_mount_cache[resolved_key] = mount_map
|
|
35
|
+
return mount_map
|
|
36
|
+
|
|
37
|
+
# 1. Map variable names to imported file stems
|
|
38
|
+
# e.g., const discoveryRoutes = require('./routes/discoveryRoutes')
|
|
39
|
+
# or import discoveryRoutes from './routes/discoveryRoutes'
|
|
40
|
+
var_to_stem: Dict[str, str] = {}
|
|
41
|
+
import_pattern = re.compile(
|
|
42
|
+
r"""(?:const|let|var|import)\s+([a-zA-Z0-9_$]+)\s*(?:=\s*require\s*\(\s*['"]([^'"]+)['"]\s*\)|from\s*['"]([^'"]+)['"])""",
|
|
43
|
+
re.IGNORECASE,
|
|
44
|
+
)
|
|
45
|
+
for m in import_pattern.finditer(content):
|
|
46
|
+
var_name = m.group(1).lower()
|
|
47
|
+
imp_path = m.group(2) or m.group(3) or ""
|
|
48
|
+
stem = imp_path.replace("\\", "/").split("/")[-1].split(".")[0].lower()
|
|
49
|
+
if stem:
|
|
50
|
+
var_to_stem[var_name] = stem
|
|
51
|
+
var_to_stem[stem] = stem
|
|
52
|
+
|
|
53
|
+
# 2. Extract app.use('/prefix', ...) statements
|
|
54
|
+
# e.g., app.use('/api/discovery', discoveryRoutes);
|
|
55
|
+
# or app.use('/api/discovery', verifyToken, discoveryRoutes);
|
|
56
|
+
# or app.use('/api/discovery', require('./routes/discoveryRoutes'));
|
|
57
|
+
use_pattern = re.compile(
|
|
58
|
+
r"""(?:app|server|api|router)\.use\s*\(\s*["']([^"']+)["']\s*,\s*([^;]+)\)""",
|
|
59
|
+
re.IGNORECASE | re.DOTALL,
|
|
60
|
+
)
|
|
61
|
+
for m in use_pattern.finditer(content):
|
|
62
|
+
raw_prefix = m.group(1).strip()
|
|
63
|
+
prefix = "/" + raw_prefix.strip("/") if raw_prefix.strip("/") else ""
|
|
64
|
+
args_str = m.group(2)
|
|
65
|
+
|
|
66
|
+
args = [a.strip() for a in args_str.split(",") if a.strip()]
|
|
67
|
+
if not args:
|
|
68
|
+
continue
|
|
69
|
+
|
|
70
|
+
router_arg = args[-1]
|
|
71
|
+
middleware_args = args[:-1]
|
|
72
|
+
|
|
73
|
+
has_auth = any(
|
|
74
|
+
any(sig in m_arg.lower() for sig in ("auth", "jwt", "passport", "verifytoken", "isauthenticated", "requireauth"))
|
|
75
|
+
for m_arg in middleware_args
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
# Determine target router stem
|
|
79
|
+
target_stem = ""
|
|
80
|
+
inline_req = re.search(r"""require\s*\(\s*['"]([^'"]+)['"]\s*\)""", router_arg)
|
|
81
|
+
if inline_req:
|
|
82
|
+
req_path = inline_req.group(1)
|
|
83
|
+
target_stem = req_path.replace("\\", "/").split("/")[-1].split(".")[0].lower()
|
|
84
|
+
else:
|
|
85
|
+
var_key = router_arg.strip().split("(")[0].strip().lower()
|
|
86
|
+
target_stem = var_to_stem.get(var_key, var_key)
|
|
87
|
+
|
|
88
|
+
if target_stem:
|
|
89
|
+
mount_map[target_stem] = (prefix, has_auth)
|
|
90
|
+
core_stem = re.sub(r'routes?$', '', target_stem)
|
|
91
|
+
if core_stem and core_stem != target_stem:
|
|
92
|
+
mount_map[core_stem] = (prefix, has_auth)
|
|
93
|
+
|
|
94
|
+
self._server_mount_cache[resolved_key] = mount_map
|
|
95
|
+
return mount_map
|
|
96
|
+
|
|
97
|
+
def _resolve_mount_prefix_and_auth(self, parsed_file: ParsedFile) -> Tuple[str, bool]:
|
|
98
|
+
"""Resolves the mount prefix and any mount-level authentication middleware for this file."""
|
|
99
|
+
norm_path = parsed_file.file_path.replace("\\", "/")
|
|
100
|
+
filename = norm_path.split("/")[-1]
|
|
101
|
+
stem = filename.rsplit(".", 1)[0].lower()
|
|
102
|
+
core_stem = re.sub(r'routes?$', '', stem)
|
|
103
|
+
|
|
104
|
+
# Candidate directories to look for server files
|
|
105
|
+
start_path = Path(parsed_file.absolute_path or parsed_file.file_path).resolve()
|
|
106
|
+
candidate_dirs = [start_path.parent]
|
|
107
|
+
for p in start_path.parents:
|
|
108
|
+
candidate_dirs.append(p)
|
|
109
|
+
if len(candidate_dirs) > 5:
|
|
110
|
+
break
|
|
111
|
+
|
|
112
|
+
server_names = (
|
|
113
|
+
"server.js", "server.ts", "server.mjs", "server.cjs",
|
|
114
|
+
"app.js", "app.ts", "app.mjs", "app.cjs",
|
|
115
|
+
"index.js", "index.ts", "index.mjs", "index.cjs",
|
|
116
|
+
"main.js", "main.ts",
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
for cdir in candidate_dirs:
|
|
120
|
+
# Check server files directly in cdir
|
|
121
|
+
for sname in server_names:
|
|
122
|
+
candidate_file = cdir / sname
|
|
123
|
+
if candidate_file.is_file():
|
|
124
|
+
mounts = self._discover_server_mounts(candidate_file)
|
|
125
|
+
if stem in mounts:
|
|
126
|
+
return mounts[stem]
|
|
127
|
+
if core_stem in mounts:
|
|
128
|
+
return mounts[core_stem]
|
|
129
|
+
for k, (pref, has_auth) in mounts.items():
|
|
130
|
+
if k in norm_path.lower():
|
|
131
|
+
return (pref, has_auth)
|
|
132
|
+
|
|
133
|
+
# Check subdirectories like backend/ or src/
|
|
134
|
+
for subdir in ("backend", "server", "src"):
|
|
135
|
+
for sname in server_names:
|
|
136
|
+
candidate_file = cdir / subdir / sname
|
|
137
|
+
if candidate_file.is_file():
|
|
138
|
+
mounts = self._discover_server_mounts(candidate_file)
|
|
139
|
+
if stem in mounts:
|
|
140
|
+
return mounts[stem]
|
|
141
|
+
if core_stem in mounts:
|
|
142
|
+
return mounts[core_stem]
|
|
143
|
+
for k, (pref, has_auth) in mounts.items():
|
|
144
|
+
if k in norm_path.lower():
|
|
145
|
+
return (pref, has_auth)
|
|
146
|
+
|
|
147
|
+
return ("", False)
|
|
148
|
+
|
|
149
|
+
def extract_endpoints(self, parsed_file: ParsedFile, content: str) -> List[Endpoint]:
|
|
150
|
+
endpoints: List[Endpoint] = []
|
|
151
|
+
|
|
152
|
+
# Multi-line regex supporting app, router, server, api
|
|
153
|
+
pattern = re.compile(
|
|
154
|
+
r"""(?:app|router|server|api)\.(get|post|put|delete|patch)\s*\(\s*["']([^"']+)["']\s*(?:,\s*([^)]+))?""",
|
|
155
|
+
re.IGNORECASE | re.DOTALL,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
content_lower = content.lower()
|
|
159
|
+
has_db_sink = any(
|
|
160
|
+
term in content_lower
|
|
161
|
+
for term in ("mongoose", "prisma", "sequelize", "knex", "pool.query", "db.query", "db.collection")
|
|
162
|
+
)
|
|
163
|
+
has_ext_sink = any(
|
|
164
|
+
term in content_lower
|
|
165
|
+
for term in ("axios", "node-fetch", "fetch(", "needle", "got(", "http.request", "https.request")
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
# File-level router middleware: e.g. router.use(verifyToken)
|
|
169
|
+
file_has_auth = bool(
|
|
170
|
+
re.search(
|
|
171
|
+
r"""(?:router|app)\.use\s*\(\s*[^)]*(?:auth|jwt|passport|verifytoken|isauthenticated|requireauth)""",
|
|
172
|
+
content,
|
|
173
|
+
re.IGNORECASE,
|
|
174
|
+
)
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
# Discover parent mount prefix and mount-level auth
|
|
178
|
+
mount_prefix, mount_has_auth = self._resolve_mount_prefix_and_auth(parsed_file)
|
|
179
|
+
|
|
180
|
+
for match in pattern.finditer(content):
|
|
181
|
+
method = match.group(1).upper()
|
|
182
|
+
raw_path = match.group(2)
|
|
183
|
+
handler_and_middleware = match.group(3) or ""
|
|
184
|
+
|
|
185
|
+
# Calculate accurate line number
|
|
186
|
+
line_no = content[:match.start()].count("\n") + 1
|
|
187
|
+
|
|
188
|
+
# Convert Express :id into {id}
|
|
189
|
+
norm_sub_path = re.sub(r":([a-zA-Z0-9_]+)", r"{\1}", raw_path)
|
|
190
|
+
path_params = re.findall(r":([a-zA-Z0-9_]+)", raw_path)
|
|
191
|
+
|
|
192
|
+
# Prepend mount prefix if available
|
|
193
|
+
if mount_prefix:
|
|
194
|
+
if norm_sub_path == "/" or not norm_sub_path:
|
|
195
|
+
final_path = mount_prefix
|
|
196
|
+
elif norm_sub_path.startswith(mount_prefix):
|
|
197
|
+
final_path = norm_sub_path
|
|
198
|
+
else:
|
|
199
|
+
final_path = f"{mount_prefix}/{norm_sub_path.lstrip('/')}"
|
|
200
|
+
else:
|
|
201
|
+
final_path = norm_sub_path
|
|
202
|
+
|
|
203
|
+
# Clean duplicate slashes
|
|
204
|
+
final_path = re.sub(r"/+", "/", final_path)
|
|
205
|
+
|
|
206
|
+
route_auth = any(
|
|
207
|
+
term in handler_and_middleware.lower()
|
|
208
|
+
for term in ("auth", "jwt", "passport", "verifytoken", "isauthenticated", "requireauth")
|
|
209
|
+
)
|
|
210
|
+
auth_required = file_has_auth or mount_has_auth or route_auth
|
|
211
|
+
roles = ["admin"] if "admin" in handler_and_middleware.lower() or "admin" in final_path.lower() else []
|
|
212
|
+
|
|
213
|
+
ep_id = f"ep_express_{parsed_file.file_path}_{line_no}_{method}".replace("/", "_").replace(".", "_")
|
|
214
|
+
|
|
215
|
+
endpoints.append(
|
|
216
|
+
Endpoint(
|
|
217
|
+
id=ep_id,
|
|
218
|
+
method=method,
|
|
219
|
+
path=final_path,
|
|
220
|
+
handler_name=f"route_handler_L{line_no}",
|
|
221
|
+
auth_required=auth_required,
|
|
222
|
+
roles=roles,
|
|
223
|
+
parameters=[
|
|
224
|
+
EndpointParameter(name=p, location="path", required=True)
|
|
225
|
+
for p in path_params
|
|
226
|
+
],
|
|
227
|
+
database_access=has_db_sink,
|
|
228
|
+
object_identifier=bool(path_params),
|
|
229
|
+
state_changing=method in ("POST", "PUT", "DELETE", "PATCH"),
|
|
230
|
+
external_network=has_ext_sink,
|
|
231
|
+
sensitive_data=any(
|
|
232
|
+
term in final_path.lower()
|
|
233
|
+
for term in ("admin", "user", "order", "deliverable", "workspace", "account", "profile")
|
|
234
|
+
),
|
|
235
|
+
source=SourceLocation(
|
|
236
|
+
file=parsed_file.file_path,
|
|
237
|
+
line_start=line_no,
|
|
238
|
+
line_end=line_no,
|
|
239
|
+
),
|
|
240
|
+
)
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
return endpoints
|
|
@@ -58,20 +58,44 @@ class NextJSFrameworkAdapter(FrameworkAdapter):
|
|
|
58
58
|
if parsed_file.language not in ("javascript", "typescript"):
|
|
59
59
|
return False
|
|
60
60
|
|
|
61
|
-
norm_path = parsed_file.file_path.replace("\\", "/")
|
|
61
|
+
norm_path = parsed_file.file_path.replace("\\", "/").lower()
|
|
62
62
|
|
|
63
|
-
#
|
|
64
|
-
if re.search(r'(?:^|/)
|
|
63
|
+
# 1. Reject build & tool configurations (e.g. next.config.ts, tailwind.config.js, etc.)
|
|
64
|
+
if re.search(r'(?:^|/)[a-zA-Z0-9_\.-]*\.config\.[a-zA-Z0-9]+$', norm_path):
|
|
65
|
+
return False
|
|
66
|
+
|
|
67
|
+
# 2. Reject UI component directories and non-API code
|
|
68
|
+
non_endpoint_dirs = (
|
|
69
|
+
"/components/", "/ui/", "/layouts/", "/widgets/",
|
|
70
|
+
"/hooks/", "/context/", "/styles/", "/types/",
|
|
71
|
+
"/lib/", "/utils/", "/helpers/", "/assets/", "/icons/"
|
|
72
|
+
)
|
|
73
|
+
if any(d in norm_path for d in non_endpoint_dirs) or any(norm_path.startswith(d.lstrip("/")) for d in non_endpoint_dirs):
|
|
74
|
+
return False
|
|
75
|
+
|
|
76
|
+
# 3. Reject client-side UI component suffixes (e.g. RegisterForm.tsx, DashboardHeader.tsx)
|
|
77
|
+
ui_suffixes = (
|
|
78
|
+
"form.tsx", "form.jsx", "header.tsx", "header.jsx",
|
|
79
|
+
"bar.tsx", "bar.jsx", "modal.tsx", "modal.jsx",
|
|
80
|
+
"card.tsx", "card.jsx", "layout.tsx", "layout.jsx",
|
|
81
|
+
"button.tsx", "button.jsx", "table.tsx", "table.jsx",
|
|
82
|
+
"view.tsx", "view.jsx", "provider.tsx", "provider.jsx"
|
|
83
|
+
)
|
|
84
|
+
if any(norm_path.endswith(s) for s in ui_suffixes):
|
|
85
|
+
return False
|
|
86
|
+
|
|
87
|
+
# 4. App Router: HTTP endpoints are strictly route.ts or route.js
|
|
88
|
+
if re.search(r'(?:^|/)(?:src/)?app/.+/route\.[a-zA-Z0-9]+$', norm_path):
|
|
65
89
|
return True
|
|
66
90
|
|
|
67
|
-
# Pages Router
|
|
68
|
-
if re.search(r'(?:^|/)(?:src/)?pages/
|
|
91
|
+
# 5. Pages Router: HTTP endpoints are strictly inside pages/api/
|
|
92
|
+
if re.search(r'(?:^|/)(?:src/)?pages/api/.+\.[a-zA-Z0-9]+$', norm_path):
|
|
69
93
|
return True
|
|
70
94
|
|
|
71
|
-
#
|
|
95
|
+
# 6. Only handle if explicitly importing next/server (e.g. NextRequest, NextResponse)
|
|
72
96
|
for imp in parsed_file.imports:
|
|
73
97
|
mod_low = imp.module.lower()
|
|
74
|
-
if mod_low == "next
|
|
98
|
+
if mod_low == "next/server":
|
|
75
99
|
return True
|
|
76
100
|
|
|
77
101
|
return False
|
|
@@ -79,16 +103,27 @@ class NextJSFrameworkAdapter(FrameworkAdapter):
|
|
|
79
103
|
def extract_endpoints(self, parsed_file: ParsedFile, content: str) -> List[Endpoint]:
|
|
80
104
|
endpoints: List[Endpoint] = []
|
|
81
105
|
norm_file_path = parsed_file.file_path.replace("\\", "/")
|
|
106
|
+
norm_lower = norm_file_path.lower()
|
|
82
107
|
|
|
83
|
-
|
|
84
|
-
|
|
108
|
+
# Reject client-side components with 'use client'
|
|
109
|
+
if "'use client'" in content or '"use client"' in content:
|
|
110
|
+
return []
|
|
111
|
+
|
|
112
|
+
# Reject build configurations
|
|
113
|
+
if ".config." in norm_lower:
|
|
114
|
+
return []
|
|
115
|
+
|
|
116
|
+
is_app_router = bool(re.search(r'(?:^|/)(?:src/)?app/.+/route\.[a-zA-Z0-9]+$', norm_lower))
|
|
117
|
+
is_pages_router = bool(re.search(r'(?:^|/)(?:src/)?pages/api/.+\.[a-zA-Z0-9]+$', norm_lower))
|
|
118
|
+
|
|
119
|
+
# In Next.js, only App Router route handlers and Pages Router API files are REST endpoints
|
|
120
|
+
if not is_app_router and not is_pages_router:
|
|
121
|
+
return []
|
|
85
122
|
|
|
86
123
|
if is_app_router:
|
|
87
124
|
url_path = self._infer_app_router_path(norm_file_path)
|
|
88
|
-
elif is_pages_router:
|
|
89
|
-
url_path = self._infer_pages_router_path(norm_file_path)
|
|
90
125
|
else:
|
|
91
|
-
url_path =
|
|
126
|
+
url_path = self._infer_pages_router_path(norm_file_path)
|
|
92
127
|
|
|
93
128
|
path_params = re.findall(r'\{([a-zA-Z0-9_]+)\}', url_path)
|
|
94
129
|
|
|
@@ -164,14 +199,18 @@ class NextJSFrameworkAdapter(FrameworkAdapter):
|
|
|
164
199
|
)
|
|
165
200
|
)
|
|
166
201
|
|
|
167
|
-
# Pages Router
|
|
168
|
-
if not endpoints:
|
|
202
|
+
# Pages Router default export handler (strictly pages/api/*)
|
|
203
|
+
if not endpoints and is_pages_router:
|
|
169
204
|
# Check for checked HTTP methods in handler body
|
|
170
205
|
checked_methods = []
|
|
171
206
|
for m in re.finditer(r'''req\.method\s*===?\s*["'](GET|POST|PUT|DELETE|PATCH)["']''', content):
|
|
172
207
|
checked_methods.append(m.group(1).upper())
|
|
173
208
|
|
|
174
|
-
|
|
209
|
+
default_match = re.search(r'''export\s+default\s+(?:async\s+)?(?:function|const|class)?\s*([a-zA-Z0-9_$]+)?''', content)
|
|
210
|
+
if not default_match:
|
|
211
|
+
return endpoints
|
|
212
|
+
|
|
213
|
+
methods_to_add = set(checked_methods) if checked_methods else ["GET", "POST"]
|
|
175
214
|
|
|
176
215
|
default_match = re.search(r'''export\s+default\s+(?:async\s+)?(?:function|const|class)?\s*([a-zA-Z0-9_$]+)?''', content)
|
|
177
216
|
handler_name = default_match.group(1) if (default_match and default_match.group(1)) else "handler"
|
|
@@ -108,11 +108,30 @@ class ReactRouterFrameworkAdapter(FrameworkAdapter):
|
|
|
108
108
|
if parsed_file.language not in ("javascript", "typescript"):
|
|
109
109
|
return False
|
|
110
110
|
|
|
111
|
-
norm_path = parsed_file.file_path.replace("\\", "/")
|
|
111
|
+
norm_path = parsed_file.file_path.replace("\\", "/").lower()
|
|
112
112
|
basename = norm_path.split("/")[-1]
|
|
113
113
|
|
|
114
|
-
# Ignore configuration files and hidden/dot files
|
|
115
|
-
if "config" in basename
|
|
114
|
+
# Ignore configuration files, test files, and hidden/dot files
|
|
115
|
+
if "config" in basename or basename.startswith(".") or ".test." in basename or ".spec." in basename:
|
|
116
|
+
return False
|
|
117
|
+
|
|
118
|
+
# Reject Express / Koa / Fastify backend route files
|
|
119
|
+
for imp in parsed_file.imports:
|
|
120
|
+
imp_low = imp.module.lower()
|
|
121
|
+
if imp_low in ("express", "koa", "fastify", "hapi", "@nestjs/common"):
|
|
122
|
+
return False
|
|
123
|
+
|
|
124
|
+
# Reject backend directories (Express / Node.js backend controllers are not React Router)
|
|
125
|
+
if any(norm_path.startswith(d) or f"/{d}" in norm_path for d in ("backend/", "server/", "api/", "controllers/", "srv/")):
|
|
126
|
+
return False
|
|
127
|
+
|
|
128
|
+
# Reject UI component directories and non-route code
|
|
129
|
+
non_route_dirs = (
|
|
130
|
+
"/components/", "/ui/", "/layouts/", "/widgets/",
|
|
131
|
+
"/hooks/", "/context/", "/styles/", "/types/",
|
|
132
|
+
"/lib/", "/utils/", "/helpers/", "/assets/", "/icons/"
|
|
133
|
+
)
|
|
134
|
+
if any(d in norm_path for d in non_route_dirs) or any(norm_path.startswith(d.lstrip("/")) for d in non_route_dirs):
|
|
116
135
|
return False
|
|
117
136
|
|
|
118
137
|
# If it's the routes configuration file itself
|
|
@@ -127,10 +146,9 @@ class ReactRouterFrameworkAdapter(FrameworkAdapter):
|
|
|
127
146
|
if key in self._route_cache:
|
|
128
147
|
return True
|
|
129
148
|
|
|
130
|
-
#
|
|
131
|
-
if
|
|
132
|
-
|
|
133
|
-
if any(f"/{d}/" in norm_path for d in ("styles", "assets", "types", "+types")):
|
|
149
|
+
# Only match files strictly in frontend routes directory (app/routes/ or src/routes/)
|
|
150
|
+
if re.search(r'(?:^|/)(?:src/|app/)?routes/.+\.[a-zA-Z0-9]+$', norm_path):
|
|
151
|
+
if any(f"/{d}/" in norm_path for d in ("styles", "assets", "types", "+types", "components")):
|
|
134
152
|
return False
|
|
135
153
|
return True
|
|
136
154
|
|
|
@@ -303,29 +321,6 @@ class ReactRouterFrameworkAdapter(FrameworkAdapter):
|
|
|
303
321
|
)
|
|
304
322
|
)
|
|
305
323
|
|
|
306
|
-
# If file
|
|
307
|
-
#
|
|
308
|
-
if not endpoints:
|
|
309
|
-
default_match = re.search(r'''export\s+default\s+(?:function|const|class)?\s*([a-zA-Z0-9_$]+)?''', content)
|
|
310
|
-
handler_name = default_match.group(1) if (default_match and default_match.group(1)) else "default"
|
|
311
|
-
line_no = content[:default_match.start()].count("\n") + 1 if default_match else 1
|
|
312
|
-
ep_id = f"ep_rr_page_{norm_file_path}_{line_no}".replace("/", "_").replace(".", "_")
|
|
313
|
-
endpoints.append(
|
|
314
|
-
Endpoint(
|
|
315
|
-
id=ep_id,
|
|
316
|
-
method="GET",
|
|
317
|
-
path=url_path,
|
|
318
|
-
handler_name=handler_name,
|
|
319
|
-
auth_required=auth_required,
|
|
320
|
-
roles=roles,
|
|
321
|
-
parameters=parameters,
|
|
322
|
-
database_access=has_db_sink,
|
|
323
|
-
object_identifier=bool(path_params),
|
|
324
|
-
state_changing=False,
|
|
325
|
-
external_network=has_ext_sink,
|
|
326
|
-
sensitive_data=sensitive_data,
|
|
327
|
-
source=SourceLocation(file=parsed_file.file_path, line_start=line_no, line_end=line_no),
|
|
328
|
-
)
|
|
329
|
-
)
|
|
330
|
-
|
|
324
|
+
# If file defines neither loader nor action nor explicit HTTP verbs, it is a pure UI component
|
|
325
|
+
# and has no backend server attack surface
|
|
331
326
|
return endpoints
|
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
|
282
|
-
|
|
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, &
|
|
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 &
|
|
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
|
-
|
|
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=
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
92
|
+
path_segs = [s.lower() for s in ep.path.strip("/").split("/")]
|
|
93
|
+
is_public_by_design = any(
|
|
94
|
+
s in ("login", "signin", "register", "signup", "forgot-password", "reset-password", "callback", "oauth", "health", "ping", "status", "public", "docs", "openapi")
|
|
95
|
+
or s.startswith("auth-callback") or s.endswith("-callback")
|
|
96
|
+
for s in path_segs
|
|
97
|
+
)
|
|
98
|
+
if not is_public_by_design:
|
|
93
99
|
hypotheses.append(
|
|
94
100
|
SecurityHypothesis(
|
|
95
101
|
id=f"HYP-AUTH-{hypo_idx:03d}",
|
|
@@ -109,8 +115,17 @@ class HypothesisEngine:
|
|
|
109
115
|
)
|
|
110
116
|
hypo_idx += 1
|
|
111
117
|
|
|
112
|
-
# 4. SSRF Hypothesis: Endpoint takes URL
|
|
113
|
-
|
|
118
|
+
# 4. SSRF Hypothesis: Endpoint takes user-supplied URL/target AND makes outbound HTTP calls
|
|
119
|
+
url_params = [
|
|
120
|
+
p.name for p in ep.parameters
|
|
121
|
+
if any(
|
|
122
|
+
term == p.name.lower()
|
|
123
|
+
or p.name.lower().endswith(f"_{term}")
|
|
124
|
+
or p.name.lower().endswith(term.capitalize())
|
|
125
|
+
for term in ("url", "uri", "target", "webhook", "callback", "dest", "destination", "endpoint", "feed", "proxy", "redirect_uri")
|
|
126
|
+
)
|
|
127
|
+
]
|
|
128
|
+
if ep.external_network and url_params:
|
|
114
129
|
hypotheses.append(
|
|
115
130
|
SecurityHypothesis(
|
|
116
131
|
id=f"HYP-SSRF-{hypo_idx:03d}",
|
|
@@ -118,12 +133,12 @@ class HypothesisEngine:
|
|
|
118
133
|
endpoint_id=ep.id,
|
|
119
134
|
endpoint_display=ep_disp,
|
|
120
135
|
title=f"Potential SSRF on {ep_disp}",
|
|
121
|
-
description="Endpoint
|
|
136
|
+
description="Endpoint accepts user-controlled destination URL and dispatches outbound HTTP requests.",
|
|
122
137
|
recommended_test_pack="ssrf",
|
|
123
138
|
confidence_prior=0.85,
|
|
124
139
|
static_evidence=[
|
|
125
140
|
"Outbound HTTP client calls detected in handler body",
|
|
126
|
-
f"URL
|
|
141
|
+
f"User-controlled URL parameters: {url_params}",
|
|
127
142
|
],
|
|
128
143
|
)
|
|
129
144
|
)
|
|
@@ -169,9 +184,22 @@ class HypothesisEngine:
|
|
|
169
184
|
)
|
|
170
185
|
hypo_idx += 1
|
|
171
186
|
|
|
172
|
-
# 7. Path Traversal Hypothesis: Endpoints accepting file or path parameters
|
|
173
|
-
file_param_names = [
|
|
174
|
-
|
|
187
|
+
# 7. Path Traversal Hypothesis: Endpoints accepting genuine file or directory path parameters
|
|
188
|
+
file_param_names = [
|
|
189
|
+
p.name for p in ep.parameters
|
|
190
|
+
if p.name.lower() in (
|
|
191
|
+
"file", "path", "filename", "file_name", "filepath", "file_path",
|
|
192
|
+
"doc_path", "document", "attachment", "dir_path", "download_path"
|
|
193
|
+
) or any(
|
|
194
|
+
p.name.lower().endswith(f"_{term}")
|
|
195
|
+
for term in ("file", "path", "filename", "filepath")
|
|
196
|
+
)
|
|
197
|
+
]
|
|
198
|
+
path_segments = [seg.lower() for seg in ep.path.strip("/").split("/")]
|
|
199
|
+
has_file_route = any(seg in ("file", "files", "download", "downloads", "static", "assets", "document", "documents") for seg in path_segments)
|
|
200
|
+
|
|
201
|
+
# Must have actual file parameter or file route with parameters
|
|
202
|
+
if file_param_names or (has_file_route and ep.parameters):
|
|
175
203
|
hypotheses.append(
|
|
176
204
|
SecurityHypothesis(
|
|
177
205
|
id=f"HYP-TRAV-{hypo_idx:03d}",
|