security-mcp 1.1.3 → 1.3.1
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/README.md +164 -185
- package/defaults/checklists/ai.json +20 -1
- package/defaults/checklists/api.json +35 -1
- package/defaults/checklists/infra.json +34 -1
- package/defaults/checklists/mobile.json +23 -1
- package/defaults/checklists/payments.json +15 -1
- package/defaults/checklists/web.json +11 -1
- package/defaults/control-catalog.json +200 -0
- package/defaults/security-policy.json +2 -2
- package/dist/cli/index.js +82 -5
- package/dist/cli/install.js +36 -6
- package/dist/cli/onboarding.js +6 -0
- package/dist/gate/baseline.js +82 -7
- package/dist/gate/catalog.js +10 -2
- package/dist/gate/checks/ai.js +757 -39
- package/dist/gate/checks/auth-deep.js +935 -0
- package/dist/gate/checks/business-logic.js +751 -0
- package/dist/gate/checks/ci-pipeline.js +399 -4
- package/dist/gate/checks/crypto.js +423 -2
- package/dist/gate/checks/dependencies.js +571 -15
- package/dist/gate/checks/graphql.js +201 -19
- package/dist/gate/checks/infra.js +246 -1
- package/dist/gate/checks/injection-deep.js +848 -0
- package/dist/gate/checks/k8s.js +114 -1
- package/dist/gate/checks/mobile-android.js +917 -3
- package/dist/gate/checks/mobile-ios.js +797 -5
- package/dist/gate/checks/required-artifacts.js +194 -0
- package/dist/gate/checks/runtime.js +178 -0
- package/dist/gate/checks/secrets.js +244 -13
- package/dist/gate/checks/supply-chain-deep.js +787 -0
- package/dist/gate/checks/web-nextjs.js +572 -48
- package/dist/gate/diff.js +17 -5
- package/dist/gate/evidence.js +8 -1
- package/dist/gate/exceptions.js +131 -9
- package/dist/gate/policy.js +282 -129
- package/dist/mcp/audit-chain.js +122 -28
- package/dist/mcp/auth.js +169 -0
- package/dist/mcp/learning.js +129 -4
- package/dist/mcp/model-router.js +158 -21
- package/dist/mcp/orchestration.js +186 -51
- package/dist/mcp/server.js +608 -94
- package/dist/repo/fs.js +24 -1
- package/dist/repo/search.js +31 -6
- package/dist/review/store.js +52 -1
- package/package.json +7 -7
- package/prompts/SECURITY_PROMPT.md +73 -0
- package/skills/_TEMPLATE/SKILL.md +99 -0
- package/skills/advanced-dos-tester/SKILL.md +109 -0
- package/skills/agentic-loop-exploiter/SKILL.md +368 -0
- package/skills/ai-llm-redteam/SKILL.md +104 -0
- package/skills/ai-model-supply-chain-agent/SKILL.md +103 -0
- package/skills/algorithm-implementation-reviewer/SKILL.md +98 -0
- package/skills/android-penetration-tester/SKILL.md +455 -46
- package/skills/anti-replay-tester/SKILL.md +106 -0
- package/skills/appsec-code-auditor/SKILL.md +120 -0
- package/skills/artifact-integrity-analyst/SKILL.md +441 -0
- package/skills/attack-navigator/SKILL.md +467 -8
- package/skills/auth-session-hacker/SKILL.md +128 -0
- package/skills/aws-penetration-tester/SKILL.md +456 -0
- package/skills/azure-penetration-tester/SKILL.md +490 -3
- package/skills/binary-auth-validator/SKILL.md +111 -0
- package/skills/bot-detection-specialist/SKILL.md +109 -0
- package/skills/business-logic-attacker/SKILL.md +231 -0
- package/skills/capec-code-mapper/SKILL.md +84 -0
- package/skills/cert-pin-rotation-specialist/SKILL.md +112 -0
- package/skills/cicd-pipeline-hijacker/SKILL.md +405 -0
- package/skills/ciso-orchestrator/SKILL.md +454 -43
- package/skills/cloud-infra-specialist/SKILL.md +118 -0
- package/skills/compliance-gap-analyst/SKILL.md +422 -0
- package/skills/compliance-grc/SKILL.md +85 -0
- package/skills/compliance-lifecycle-tracker/SKILL.md +84 -0
- package/skills/credential-stuffing-specialist/SKILL.md +102 -0
- package/skills/crypto-pki-specialist/SKILL.md +87 -0
- package/skills/csa-ccm-mapper/SKILL.md +84 -0
- package/skills/csf2-governance-mapper/SKILL.md +84 -0
- package/skills/deep-link-fuzzer/SKILL.md +109 -0
- package/skills/dependency-confusion-attacker/SKILL.md +415 -0
- package/skills/device-integrity-aggregator/SKILL.md +108 -0
- package/skills/dos-resilience-tester/SKILL.md +97 -0
- package/skills/dread-scorer/SKILL.md +84 -0
- package/skills/egress-policy-enforcer/SKILL.md +99 -0
- package/skills/evidence-collector/SKILL.md +98 -0
- package/skills/file-upload-attacker/SKILL.md +109 -0
- package/skills/gcp-penetration-tester/SKILL.md +459 -2
- package/skills/git-history-secret-scanner/SKILL.md +106 -0
- package/skills/iam-privesc-graph-builder/SKILL.md +152 -0
- package/skills/incident-responder/SKILL.md +111 -0
- package/skills/injection-specialist/SKILL.md +131 -0
- package/skills/ios-security-auditor/SKILL.md +282 -0
- package/skills/json-ambiguity-tester/SKILL.md +0 -0
- package/skills/k8s-container-escaper/SKILL.md +384 -0
- package/skills/key-management-lifecycle-analyst/SKILL.md +98 -0
- package/skills/kill-switch-engineer/SKILL.md +102 -0
- package/skills/linddun-privacy-analyst/SKILL.md +102 -0
- package/skills/logic-race-fuzzer/SKILL.md +443 -0
- package/skills/mobile-api-network-attacker/SKILL.md +421 -0
- package/skills/mobile-binary-hardener/SKILL.md +102 -0
- package/skills/mobile-security-specialist/SKILL.md +85 -0
- package/skills/mobile-webview-auditor/SKILL.md +96 -0
- package/skills/model-extraction-attacker/SKILL.md +219 -0
- package/skills/multipart-abuse-tester/SKILL.md +84 -0
- package/skills/oauth-pkce-specialist/SKILL.md +104 -0
- package/skills/parser-exhaustion-tester/SKILL.md +142 -0
- package/skills/pentest-infra/SKILL.md +141 -0
- package/skills/pentest-social/SKILL.md +201 -0
- package/skills/pentest-team/SKILL.md +134 -0
- package/skills/pentest-web-api/SKILL.md +151 -0
- package/skills/privacy-flow-analyst/SKILL.md +234 -0
- package/skills/prompt-injection-specialist/SKILL.md +394 -0
- package/skills/quantum-migration-planner/SKILL.md +96 -0
- package/skills/rag-poisoning-specialist/SKILL.md +358 -0
- package/skills/registry-mirror-enforcer/SKILL.md +84 -0
- package/skills/rotation-validation-agent/SKILL.md +112 -0
- package/skills/samm-assessor/SKILL.md +85 -0
- package/skills/secrets-mask-bypass-tester/SKILL.md +100 -0
- package/skills/senior-security-engineer/SKILL.md +370 -2
- package/skills/serialization-memory-attacker/SKILL.md +332 -0
- package/skills/session-timeout-tester/SKILL.md +161 -0
- package/skills/slsa-level3-enforcer/SKILL.md +112 -0
- package/skills/slsa-provenance-enforcer/SKILL.md +102 -0
- package/skills/ssrf-detection-validator/SKILL.md +108 -0
- package/skills/step-up-auth-enforcer/SKILL.md +84 -0
- package/skills/stride-pasta-analyst/SKILL.md +420 -0
- package/skills/supply-chain-devsecops/SKILL.md +98 -0
- package/skills/threat-infrastructure-analyst/SKILL.md +84 -0
- package/skills/threat-modeler/SKILL.md +85 -0
- package/skills/tls-certificate-auditor/SKILL.md +573 -18
- package/skills/token-reuse-detector/SKILL.md +95 -0
- package/skills/trike-risk-modeler/SKILL.md +84 -0
- package/skills/unicode-homograph-tester/SKILL.md +84 -0
- package/skills/waf-rule-lifecycle-agent/SKILL.md +97 -0
- package/skills/webhook-security-tester/SKILL.md +102 -0
- package/skills/zero-trust-architect/SKILL.md +109 -0
|
@@ -1,76 +1,600 @@
|
|
|
1
1
|
import { searchRepo } from "../../repo/search.js";
|
|
2
2
|
import fg from "fast-glob";
|
|
3
3
|
import { readFileSafe } from "../../repo/fs.js";
|
|
4
|
-
|
|
5
|
-
const
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
4
|
+
async function runAll(checks) {
|
|
5
|
+
const results = await Promise.all(checks.map((fn) => fn()));
|
|
6
|
+
return results.flat();
|
|
7
|
+
}
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// 1. CSP and security headers (EXISTING)
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
async function checkSecurityHeaders() {
|
|
12
|
+
const headerFiles = await fg(["middleware.ts", "middleware.tsx", "src/middleware.ts", "next.config.*"], { dot: true });
|
|
10
13
|
if (headerFiles.length === 0) {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
requiredActions: [
|
|
16
|
-
"Add strict security headers: CSP (no inline JS), HSTS, X-Frame-Options, Referrer-Policy, Permissions-Policy.",
|
|
17
|
-
"Enforce secure cookies: HttpOnly, Secure, SameSite, short-lived tokens."
|
|
18
|
-
]
|
|
19
|
-
});
|
|
20
|
-
}
|
|
21
|
-
else {
|
|
22
|
-
const combined = (await Promise.all(headerFiles.map((f) => readFileSafe(f).catch(() => "")))).join("\n");
|
|
23
|
-
const mustContain = [
|
|
24
|
-
"content-security-policy",
|
|
25
|
-
"strict-transport-security",
|
|
26
|
-
"referrer-policy",
|
|
27
|
-
"permissions-policy"
|
|
28
|
-
];
|
|
29
|
-
const missing = mustContain.filter((k) => !combined.toLowerCase().includes(k));
|
|
30
|
-
if (missing.length > 0) {
|
|
31
|
-
findings.push({
|
|
32
|
-
id: "WEB_HEADERS_INCOMPLETE",
|
|
33
|
-
title: "Security headers exist but appear incomplete",
|
|
14
|
+
return [
|
|
15
|
+
{
|
|
16
|
+
id: "WEB_HEADERS_MISSING",
|
|
17
|
+
title: "Security headers not found (CSP/HSTS/etc.)",
|
|
34
18
|
severity: "HIGH",
|
|
35
|
-
evidence: [`Missing: ${missing.join(", ")}`],
|
|
36
19
|
requiredActions: [
|
|
37
|
-
"Add
|
|
38
|
-
"
|
|
20
|
+
"Add strict security headers: CSP (no inline JS), HSTS, X-Frame-Options, Referrer-Policy, Permissions-Policy.",
|
|
21
|
+
"Enforce secure cookies: HttpOnly, Secure, SameSite, short-lived tokens."
|
|
39
22
|
]
|
|
40
|
-
}
|
|
41
|
-
|
|
23
|
+
}
|
|
24
|
+
];
|
|
42
25
|
}
|
|
43
|
-
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
26
|
+
const combined = (await Promise.all(headerFiles.map((f) => readFileSafe(f).catch(() => "")))).join("\n");
|
|
27
|
+
const mustContain = [
|
|
28
|
+
"content-security-policy",
|
|
29
|
+
"strict-transport-security",
|
|
30
|
+
"referrer-policy",
|
|
31
|
+
"permissions-policy"
|
|
32
|
+
];
|
|
33
|
+
const missing = mustContain.filter((k) => !combined.toLowerCase().includes(k));
|
|
34
|
+
if (missing.length === 0)
|
|
35
|
+
return [];
|
|
36
|
+
return [
|
|
37
|
+
{
|
|
38
|
+
id: "WEB_HEADERS_INCOMPLETE",
|
|
39
|
+
title: "Security headers exist but appear incomplete",
|
|
40
|
+
severity: "HIGH",
|
|
41
|
+
evidence: [`Missing: ${missing.join(", ")}`],
|
|
42
|
+
requiredActions: [
|
|
43
|
+
"Add missing headers and ensure CSP forbids inline scripts (no 'unsafe-inline').",
|
|
44
|
+
"Add a CSP nonce strategy if you must load dynamic scripts."
|
|
45
|
+
]
|
|
46
|
+
}
|
|
47
|
+
];
|
|
48
|
+
}
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
// 2. dangerouslySetInnerHTML (EXISTING)
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
async function checkDangerouslySetInnerHTML() {
|
|
53
|
+
const hits = await searchRepo({
|
|
54
|
+
query: "dangerouslySetInnerHTML",
|
|
55
|
+
isRegex: false,
|
|
56
|
+
maxMatches: 200
|
|
57
|
+
});
|
|
58
|
+
if (hits.length === 0)
|
|
59
|
+
return [];
|
|
60
|
+
return [
|
|
61
|
+
{
|
|
47
62
|
id: "DANGEROUSLY_SET_INNER_HTML",
|
|
48
63
|
title: "dangerouslySetInnerHTML usage detected",
|
|
49
64
|
severity: "HIGH",
|
|
50
|
-
evidence:
|
|
65
|
+
evidence: hits.slice(0, 20).map((m) => `${m.file}:${m.line}:${m.preview}`),
|
|
51
66
|
requiredActions: [
|
|
52
67
|
"Remove dangerouslySetInnerHTML where possible.",
|
|
53
68
|
"If unavoidable: sanitize with a proven HTML sanitizer and add unit tests with XSS payloads."
|
|
54
69
|
]
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
70
|
+
}
|
|
71
|
+
];
|
|
72
|
+
}
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
// 3. SSRF guard (EXISTING)
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
async function checkSsrf() {
|
|
77
|
+
const hits = await searchRepo({
|
|
59
78
|
query: String.raw `\bfetch\(|axios\(|got\(|undici\b`,
|
|
60
79
|
isRegex: true,
|
|
61
80
|
maxMatches: 200
|
|
62
81
|
});
|
|
63
|
-
if (
|
|
64
|
-
|
|
82
|
+
if (hits.length === 0)
|
|
83
|
+
return [];
|
|
84
|
+
return [
|
|
85
|
+
{
|
|
65
86
|
id: "SSRF_GUARD_REQUIRED",
|
|
66
87
|
title: "Server-side fetch patterns detected. SSRF protections must be enforced.",
|
|
67
88
|
severity: "HIGH",
|
|
68
|
-
evidence:
|
|
89
|
+
evidence: hits.slice(0, 15).map((m) => `${m.file}:${m.line}:${m.preview}`),
|
|
69
90
|
requiredActions: [
|
|
70
91
|
"Implement SSRF guard for any server-side HTTP client: block localhost, private IP ranges, and cloud metadata endpoints.",
|
|
71
92
|
"Require URL allowlists for outbound calls. Add tests for 127.0.0.1, 10/8, 172.16/12, 192.168/16, 169.254.169.254, metadata.google.internal."
|
|
72
93
|
]
|
|
73
|
-
}
|
|
94
|
+
}
|
|
95
|
+
];
|
|
96
|
+
}
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
// 4. WEB_OPEN_REDIRECT — unvalidated redirects with user-controlled input
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
async function checkOpenRedirect() {
|
|
101
|
+
const hits = await searchRepo({
|
|
102
|
+
query: String.raw `redirect\(|res\.redirect\(`,
|
|
103
|
+
isRegex: true,
|
|
104
|
+
maxMatches: 200
|
|
105
|
+
});
|
|
106
|
+
// Filter to lines that also reference common user-input sources
|
|
107
|
+
const suspicious = hits.filter((m) => /req\.(query|body)|searchParams|\.get\(/.test(m.preview));
|
|
108
|
+
if (suspicious.length === 0)
|
|
109
|
+
return [];
|
|
110
|
+
return [
|
|
111
|
+
{
|
|
112
|
+
id: "WEB_OPEN_REDIRECT",
|
|
113
|
+
title: "Unvalidated redirect with user-controlled input detected",
|
|
114
|
+
severity: "HIGH",
|
|
115
|
+
evidence: suspicious.slice(0, 15).map((m) => `${m.file}:${m.line}:${m.preview}`),
|
|
116
|
+
requiredActions: [
|
|
117
|
+
"Validate redirect destinations against a strict allowlist of trusted origins.",
|
|
118
|
+
"Never pass raw req.query, req.body, or searchParams values directly to redirect().",
|
|
119
|
+
"Return a 400 if the destination is not in the allowlist."
|
|
120
|
+
]
|
|
121
|
+
}
|
|
122
|
+
];
|
|
123
|
+
}
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
// 5. WEB_IDOR_RISK — direct object reference from URL params without auth check
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
async function checkIdorRisk() {
|
|
128
|
+
const hits = await searchRepo({
|
|
129
|
+
query: String.raw `params\.(id|userId|user_id|accountId|account_id)\b`,
|
|
130
|
+
isRegex: true,
|
|
131
|
+
maxMatches: 200
|
|
132
|
+
});
|
|
133
|
+
// Keep only hits that don't have an obvious auth guard on the same or adjacent line
|
|
134
|
+
const suspicious = hits.filter((m) => !/auth|session|getServerSession|currentUser|requireAuth|userId\s*===/.test(m.preview));
|
|
135
|
+
if (suspicious.length === 0)
|
|
136
|
+
return [];
|
|
137
|
+
return [
|
|
138
|
+
{
|
|
139
|
+
id: "WEB_IDOR_RISK",
|
|
140
|
+
title: "Direct object reference from URL params without visible ownership check",
|
|
141
|
+
severity: "HIGH",
|
|
142
|
+
evidence: suspicious.slice(0, 15).map((m) => `${m.file}:${m.line}:${m.preview}`),
|
|
143
|
+
requiredActions: [
|
|
144
|
+
"After fetching a resource by URL param, verify the authenticated user owns or is authorised to access it.",
|
|
145
|
+
"Never rely on obscurity of IDs — enforce ownership checks server-side.",
|
|
146
|
+
"Use opaque, non-guessable IDs (UUIDs) and still enforce access control."
|
|
147
|
+
]
|
|
148
|
+
}
|
|
149
|
+
];
|
|
150
|
+
}
|
|
151
|
+
// ---------------------------------------------------------------------------
|
|
152
|
+
// 6. WEB_SERVER_ACTION_UNVALIDATED — Server Actions without Zod validation
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
async function checkServerActionValidation() {
|
|
155
|
+
// Find all files that contain "use server"
|
|
156
|
+
const useServerHits = await searchRepo({
|
|
157
|
+
query: '"use server"',
|
|
158
|
+
isRegex: false,
|
|
159
|
+
maxMatches: 200
|
|
160
|
+
});
|
|
161
|
+
if (useServerHits.length === 0)
|
|
162
|
+
return [];
|
|
163
|
+
// For each unique file, check whether it also contains a Zod parse call
|
|
164
|
+
const serverActionFiles = [...new Set(useServerHits.map((m) => m.file))];
|
|
165
|
+
const unvalidated = [];
|
|
166
|
+
for (const file of serverActionFiles) {
|
|
167
|
+
const content = await readFileSafe(file).catch(() => "");
|
|
168
|
+
if (!content.includes(".parse(") && !content.includes(".safeParse(")) {
|
|
169
|
+
unvalidated.push(file);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
if (unvalidated.length === 0)
|
|
173
|
+
return [];
|
|
174
|
+
return [
|
|
175
|
+
{
|
|
176
|
+
id: "WEB_SERVER_ACTION_UNVALIDATED",
|
|
177
|
+
title: 'Next.js Server Actions found without Zod input validation',
|
|
178
|
+
severity: "HIGH",
|
|
179
|
+
evidence: unvalidated.slice(0, 15).map((f) => `${f}: no .parse() or .safeParse() found`),
|
|
180
|
+
requiredActions: [
|
|
181
|
+
'Add a Zod schema and call schema.parse() or schema.safeParse() at the top of every Server Action.',
|
|
182
|
+
"Never trust FormData or action arguments directly — validate shape, type, and constraints.",
|
|
183
|
+
"Throw or return an error object when validation fails; never proceed with unvalidated data."
|
|
184
|
+
]
|
|
185
|
+
}
|
|
186
|
+
];
|
|
187
|
+
}
|
|
188
|
+
// ---------------------------------------------------------------------------
|
|
189
|
+
// 7. WEB_API_NO_AUTH — route.ts files without auth middleware
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
async function checkApiRouteAuth() {
|
|
192
|
+
const routeFiles = await fg(["**/route.ts", "**/route.tsx"], { dot: true });
|
|
193
|
+
if (routeFiles.length === 0)
|
|
194
|
+
return [];
|
|
195
|
+
const unprotected = [];
|
|
196
|
+
for (const file of routeFiles) {
|
|
197
|
+
const content = await readFileSafe(file).catch(() => "");
|
|
198
|
+
if (!/auth\(|session\(|getServerSession|currentUser|requireAuth/.test(content)) {
|
|
199
|
+
unprotected.push(file);
|
|
200
|
+
}
|
|
74
201
|
}
|
|
75
|
-
|
|
202
|
+
if (unprotected.length === 0)
|
|
203
|
+
return [];
|
|
204
|
+
return [
|
|
205
|
+
{
|
|
206
|
+
id: "WEB_API_NO_AUTH",
|
|
207
|
+
title: "API route handlers found without authentication middleware",
|
|
208
|
+
severity: "HIGH",
|
|
209
|
+
evidence: unprotected.slice(0, 15).map((f) => `${f}: no auth guard detected`),
|
|
210
|
+
requiredActions: [
|
|
211
|
+
"Add authentication to every route handler: call auth(), getServerSession(), or a custom requireAuth() wrapper.",
|
|
212
|
+
"Return HTTP 401 for unauthenticated requests before touching any business logic.",
|
|
213
|
+
"If the route is intentionally public, add a comment // PUBLIC ROUTE so this check can be tuned to ignore it."
|
|
214
|
+
]
|
|
215
|
+
}
|
|
216
|
+
];
|
|
217
|
+
}
|
|
218
|
+
// ---------------------------------------------------------------------------
|
|
219
|
+
// 8. WEB_CORS_WILDCARD — Access-Control-Allow-Origin: * in API responses
|
|
220
|
+
// ---------------------------------------------------------------------------
|
|
221
|
+
async function checkCorsWildcard() {
|
|
222
|
+
const hits = await searchRepo({
|
|
223
|
+
query: "Access-Control-Allow-Origin",
|
|
224
|
+
isRegex: false,
|
|
225
|
+
maxMatches: 200
|
|
226
|
+
});
|
|
227
|
+
const wildcards = hits.filter((m) => /:\s*['"]\*['"]|,\s*['"]\*['"]/.test(m.preview));
|
|
228
|
+
if (wildcards.length === 0)
|
|
229
|
+
return [];
|
|
230
|
+
return [
|
|
231
|
+
{
|
|
232
|
+
id: "WEB_CORS_WILDCARD",
|
|
233
|
+
title: "CORS wildcard (Access-Control-Allow-Origin: *) found in API response",
|
|
234
|
+
severity: "CRITICAL",
|
|
235
|
+
evidence: wildcards.slice(0, 15).map((m) => `${m.file}:${m.line}:${m.preview}`),
|
|
236
|
+
requiredActions: [
|
|
237
|
+
"Replace the wildcard origin with an explicit allowlist of trusted origins.",
|
|
238
|
+
"Never use * on endpoints that handle authenticated sessions or sensitive data.",
|
|
239
|
+
"Use environment-specific origin lists (dev vs prod)."
|
|
240
|
+
]
|
|
241
|
+
}
|
|
242
|
+
];
|
|
243
|
+
}
|
|
244
|
+
// ---------------------------------------------------------------------------
|
|
245
|
+
// 9. WEB_JWT_HARDCODED_SECRET — jwt.sign / jwt.verify with string literal secret
|
|
246
|
+
// ---------------------------------------------------------------------------
|
|
247
|
+
async function checkJwtHardcodedSecret() {
|
|
248
|
+
const hits = await searchRepo({
|
|
249
|
+
query: String.raw `jwt\.(sign|verify)\(`,
|
|
250
|
+
isRegex: true,
|
|
251
|
+
maxMatches: 200
|
|
252
|
+
});
|
|
253
|
+
// Flag lines where the secret argument looks like a string literal rather than
|
|
254
|
+
// a reference to process.env or a variable.
|
|
255
|
+
const suspicious = hits.filter((m) => /jwt\.(sign|verify)\([^)]*["'][A-Za-z0-9+/=_\-!@#$%^&*]{8,}["']/.test(m.preview));
|
|
256
|
+
if (suspicious.length === 0)
|
|
257
|
+
return [];
|
|
258
|
+
return [
|
|
259
|
+
{
|
|
260
|
+
id: "WEB_JWT_HARDCODED_SECRET",
|
|
261
|
+
title: "JWT sign/verify called with what appears to be a hardcoded secret",
|
|
262
|
+
severity: "CRITICAL",
|
|
263
|
+
evidence: suspicious.slice(0, 15).map((m) => `${m.file}:${m.line}:${m.preview}`),
|
|
264
|
+
requiredActions: [
|
|
265
|
+
"Move the JWT secret to an environment variable (e.g. process.env.JWT_SECRET).",
|
|
266
|
+
"Rotate any secret that was ever hardcoded in source — treat it as compromised.",
|
|
267
|
+
"Use a minimum 256-bit secret for HMAC-SHA256 signed tokens."
|
|
268
|
+
]
|
|
269
|
+
}
|
|
270
|
+
];
|
|
271
|
+
}
|
|
272
|
+
// ---------------------------------------------------------------------------
|
|
273
|
+
// 10. WEB_RATE_LIMIT_MISSING — auth/payment routes without rate limiting
|
|
274
|
+
// ---------------------------------------------------------------------------
|
|
275
|
+
async function checkRateLimitMissing() {
|
|
276
|
+
// Find route handlers for sensitive operations
|
|
277
|
+
const sensitiveRoutes = await fg([
|
|
278
|
+
"**/auth**/route.ts",
|
|
279
|
+
"**/login**/route.ts",
|
|
280
|
+
"**/register**/route.ts",
|
|
281
|
+
"**/payment**/route.ts",
|
|
282
|
+
"**/checkout**/route.ts",
|
|
283
|
+
"**/signin**/route.ts",
|
|
284
|
+
"**/signup**/route.ts"
|
|
285
|
+
], { dot: true });
|
|
286
|
+
if (sensitiveRoutes.length === 0)
|
|
287
|
+
return [];
|
|
288
|
+
const unprotected = [];
|
|
289
|
+
for (const file of sensitiveRoutes) {
|
|
290
|
+
const content = await readFileSafe(file).catch(() => "");
|
|
291
|
+
if (!/rateLimit|upstash|rate.limit|rateLimiter/.test(content)) {
|
|
292
|
+
unprotected.push(file);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
if (unprotected.length === 0)
|
|
296
|
+
return [];
|
|
297
|
+
return [
|
|
298
|
+
{
|
|
299
|
+
id: "WEB_RATE_LIMIT_MISSING",
|
|
300
|
+
title: "Auth/payment route handlers found without rate limiting",
|
|
301
|
+
severity: "HIGH",
|
|
302
|
+
evidence: unprotected.slice(0, 15).map((f) => `${f}: no rate-limit guard detected`),
|
|
303
|
+
requiredActions: [
|
|
304
|
+
"Apply rate limiting to all auth, login, register, and payment endpoints.",
|
|
305
|
+
"Use Upstash Rate Limit or a similar sliding-window implementation.",
|
|
306
|
+
"Return HTTP 429 with a Retry-After header when the limit is exceeded.",
|
|
307
|
+
"Set tight limits: e.g. 5 attempts / 15 minutes for login, 3 / 60 min for registration."
|
|
308
|
+
]
|
|
309
|
+
}
|
|
310
|
+
];
|
|
311
|
+
}
|
|
312
|
+
// ---------------------------------------------------------------------------
|
|
313
|
+
// 11. WEB_ENV_EXPOSED_CLIENT — server secrets in NEXT_PUBLIC_ vars
|
|
314
|
+
// ---------------------------------------------------------------------------
|
|
315
|
+
async function checkEnvExposedClient() {
|
|
316
|
+
const envFiles = await fg([".env*", "**/env.js", "**/env.ts", "**/env.mjs"], { dot: true });
|
|
317
|
+
const hits = await searchRepo({
|
|
318
|
+
query: "NEXT_PUBLIC_SECRET|NEXT_PUBLIC_API_KEY|NEXT_PUBLIC_TOKEN|NEXT_PUBLIC_PASSWORD",
|
|
319
|
+
isRegex: false,
|
|
320
|
+
maxMatches: 200
|
|
321
|
+
});
|
|
322
|
+
// Also scan env files directly for the patterns
|
|
323
|
+
const envHits = [];
|
|
324
|
+
for (const file of envFiles) {
|
|
325
|
+
const content = await readFileSafe(file).catch(() => "");
|
|
326
|
+
if (/NEXT_PUBLIC_(SECRET|API_KEY|TOKEN|PASSWORD)/.test(content)) {
|
|
327
|
+
envHits.push(file);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
if (hits.length === 0 && envHits.length === 0)
|
|
331
|
+
return [];
|
|
332
|
+
const evidence = [
|
|
333
|
+
...hits.slice(0, 10).map((m) => `${m.file}:${m.line}:${m.preview}`),
|
|
334
|
+
...envHits.map((f) => `${f}: contains NEXT_PUBLIC_ secret variable`)
|
|
335
|
+
];
|
|
336
|
+
return [
|
|
337
|
+
{
|
|
338
|
+
id: "WEB_ENV_EXPOSED_CLIENT",
|
|
339
|
+
title: "Server-side secrets detected in NEXT_PUBLIC_ environment variables",
|
|
340
|
+
severity: "CRITICAL",
|
|
341
|
+
evidence: evidence.slice(0, 20),
|
|
342
|
+
requiredActions: [
|
|
343
|
+
"Remove NEXT_PUBLIC_ prefix from any variable containing a secret, API key, token, or password.",
|
|
344
|
+
"NEXT_PUBLIC_ variables are bundled into the client JS and visible to all users.",
|
|
345
|
+
"Use server-only env vars (no NEXT_PUBLIC_ prefix) and access them in Server Components or API routes.",
|
|
346
|
+
"Rotate any secret that was ever exposed as NEXT_PUBLIC_ — treat it as compromised."
|
|
347
|
+
]
|
|
348
|
+
}
|
|
349
|
+
];
|
|
350
|
+
}
|
|
351
|
+
// ---------------------------------------------------------------------------
|
|
352
|
+
// 12. WEB_GRAPHQL_INTROSPECTION — introspection enabled without NODE_ENV guard
|
|
353
|
+
// ---------------------------------------------------------------------------
|
|
354
|
+
async function checkGraphqlIntrospection() {
|
|
355
|
+
const hits = await searchRepo({
|
|
356
|
+
query: "introspection: true",
|
|
357
|
+
isRegex: false,
|
|
358
|
+
maxMatches: 100
|
|
359
|
+
});
|
|
360
|
+
const unguarded = hits.filter((m) => !/NODE_ENV|process\.env/.test(m.preview));
|
|
361
|
+
if (unguarded.length === 0)
|
|
362
|
+
return [];
|
|
363
|
+
return [
|
|
364
|
+
{
|
|
365
|
+
id: "WEB_GRAPHQL_INTROSPECTION",
|
|
366
|
+
title: "GraphQL introspection enabled without NODE_ENV guard",
|
|
367
|
+
severity: "MEDIUM",
|
|
368
|
+
evidence: unguarded.slice(0, 10).map((m) => `${m.file}:${m.line}:${m.preview}`),
|
|
369
|
+
requiredActions: [
|
|
370
|
+
"Disable GraphQL introspection in production: `introspection: process.env.NODE_ENV !== 'production'`.",
|
|
371
|
+
"Introspection exposes the full API schema to attackers and aids targeted exploitation.",
|
|
372
|
+
"Consider also disabling GraphQL Playground / Sandbox in production."
|
|
373
|
+
]
|
|
374
|
+
}
|
|
375
|
+
];
|
|
376
|
+
}
|
|
377
|
+
// ---------------------------------------------------------------------------
|
|
378
|
+
// 13. WEB_PATH_TRAVERSAL — user-controlled input passed to fs / path.join
|
|
379
|
+
// ---------------------------------------------------------------------------
|
|
380
|
+
async function checkPathTraversal() {
|
|
381
|
+
const hits = await searchRepo({
|
|
382
|
+
query: String.raw `fs\.readFile|fs\.readFileSync|path\.join`,
|
|
383
|
+
isRegex: true,
|
|
384
|
+
maxMatches: 200
|
|
385
|
+
});
|
|
386
|
+
const suspicious = hits.filter((m) => /req\.(query|params|body)|searchParams|\.get\(/.test(m.preview));
|
|
387
|
+
if (suspicious.length === 0)
|
|
388
|
+
return [];
|
|
389
|
+
return [
|
|
390
|
+
{
|
|
391
|
+
id: "WEB_PATH_TRAVERSAL",
|
|
392
|
+
title: "Potential path traversal — user input passed to fs or path.join",
|
|
393
|
+
severity: "HIGH",
|
|
394
|
+
evidence: suspicious.slice(0, 15).map((m) => `${m.file}:${m.line}:${m.preview}`),
|
|
395
|
+
requiredActions: [
|
|
396
|
+
"Never pass user-supplied path segments directly to fs.readFile / path.join.",
|
|
397
|
+
"Resolve the full path and assert it starts with the expected base directory (path.resolve check).",
|
|
398
|
+
"Use an allowlist of valid filenames instead of accepting arbitrary paths from user input."
|
|
399
|
+
]
|
|
400
|
+
}
|
|
401
|
+
];
|
|
402
|
+
}
|
|
403
|
+
// ---------------------------------------------------------------------------
|
|
404
|
+
// 14. WEB_LOG_PII — PII fields near console.log / logger calls
|
|
405
|
+
// ---------------------------------------------------------------------------
|
|
406
|
+
async function checkLogPii() {
|
|
407
|
+
const hits = await searchRepo({
|
|
408
|
+
query: String.raw `console\.(log|error|warn|info|debug)|logger\.(log|error|warn|info|debug)`,
|
|
409
|
+
isRegex: true,
|
|
410
|
+
maxMatches: 400
|
|
411
|
+
});
|
|
412
|
+
const piiFields = /email|password|token|ssn|cardNumber|card_number|cvv|dob|dateOfBirth/i;
|
|
413
|
+
const suspicious = hits.filter((m) => piiFields.test(m.preview));
|
|
414
|
+
if (suspicious.length === 0)
|
|
415
|
+
return [];
|
|
416
|
+
return [
|
|
417
|
+
{
|
|
418
|
+
id: "WEB_LOG_PII",
|
|
419
|
+
title: "Potential PII or sensitive fields logged in server-side code",
|
|
420
|
+
severity: "HIGH",
|
|
421
|
+
evidence: suspicious.slice(0, 15).map((m) => `${m.file}:${m.line}:${m.preview}`),
|
|
422
|
+
requiredActions: [
|
|
423
|
+
"Never log PII (email, password, token, SSN, card number, CVV, date-of-birth) at any log level.",
|
|
424
|
+
"Strip sensitive fields before logging: log only IDs, timestamps, and non-sensitive metadata.",
|
|
425
|
+
"Replace logged secrets with [REDACTED] and add a lint rule (eslint-plugin-no-secrets) to enforce this."
|
|
426
|
+
]
|
|
427
|
+
}
|
|
428
|
+
];
|
|
429
|
+
}
|
|
430
|
+
// ---------------------------------------------------------------------------
|
|
431
|
+
// 15. WEB_SESSION_WEAK_CONFIG — session config without secure/httpOnly/sameSite
|
|
432
|
+
// ---------------------------------------------------------------------------
|
|
433
|
+
async function checkSessionWeakConfig() {
|
|
434
|
+
const hits = await searchRepo({
|
|
435
|
+
query: String.raw `express-session|iron-session|session\(\{`,
|
|
436
|
+
isRegex: true,
|
|
437
|
+
maxMatches: 200
|
|
438
|
+
});
|
|
439
|
+
if (hits.length === 0)
|
|
440
|
+
return [];
|
|
441
|
+
// Gather unique files and inspect their full content for secure config flags
|
|
442
|
+
const sessionFiles = [...new Set(hits.map((m) => m.file))];
|
|
443
|
+
const weakFiles = [];
|
|
444
|
+
for (const file of sessionFiles) {
|
|
445
|
+
const content = await readFileSafe(file).catch(() => "");
|
|
446
|
+
const hasSecure = /secure\s*:\s*true/.test(content);
|
|
447
|
+
const hasHttpOnly = /httpOnly\s*:\s*true/.test(content);
|
|
448
|
+
const hasSameSite = /sameSite\s*:/.test(content);
|
|
449
|
+
if (!hasSecure || !hasHttpOnly || !hasSameSite) {
|
|
450
|
+
weakFiles.push(file);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
if (weakFiles.length === 0)
|
|
454
|
+
return [];
|
|
455
|
+
return [
|
|
456
|
+
{
|
|
457
|
+
id: "WEB_SESSION_WEAK_CONFIG",
|
|
458
|
+
title: "Session configuration missing secure: true, httpOnly: true, or sameSite",
|
|
459
|
+
severity: "HIGH",
|
|
460
|
+
evidence: weakFiles.slice(0, 10).map((f) => `${f}: incomplete session cookie config`),
|
|
461
|
+
requiredActions: [
|
|
462
|
+
"Set secure: true so cookies are only sent over HTTPS.",
|
|
463
|
+
"Set httpOnly: true to prevent JavaScript access to session cookies (mitigates XSS theft).",
|
|
464
|
+
"Set sameSite: 'strict' or 'lax' to prevent CSRF attacks.",
|
|
465
|
+
"Also set a short maxAge (e.g. 15–60 minutes for sensitive sessions) and regenerate the session ID after login."
|
|
466
|
+
]
|
|
467
|
+
}
|
|
468
|
+
];
|
|
469
|
+
}
|
|
470
|
+
// ---------------------------------------------------------------------------
|
|
471
|
+
// 16. WEB_DANGLING_MARKUP — user input reflected in HTML attribute values
|
|
472
|
+
// ---------------------------------------------------------------------------
|
|
473
|
+
async function checkDanglingMarkup() {
|
|
474
|
+
const hits = await searchRepo({
|
|
475
|
+
query: String.raw `(?:res\.send\s*\(\s*['"][^'"]*<[a-z]+[^>]*(?:src|href|action)\s*=\s*['"][^'"]*\$\{|ejs\.render[^)]*\{[^}]*(?:req\.|body\.|params\.|query\.))`,
|
|
476
|
+
isRegex: true,
|
|
477
|
+
maxMatches: 200
|
|
478
|
+
});
|
|
479
|
+
if (hits.length === 0)
|
|
480
|
+
return [];
|
|
481
|
+
return [
|
|
482
|
+
{
|
|
483
|
+
id: "WEB_DANGLING_MARKUP",
|
|
484
|
+
title: "User input reflected in HTML attribute value — dangling markup injection risk",
|
|
485
|
+
severity: "HIGH",
|
|
486
|
+
evidence: hits.slice(0, 15).map((m) => `${m.file}:${m.line}:${m.preview}`),
|
|
487
|
+
requiredActions: [
|
|
488
|
+
"User input reflected in HTML attribute value — dangling markup injection enables data exfiltration (CWE-79/CWE-116).",
|
|
489
|
+
"Never interpolate user-controlled values directly into HTML attribute values.",
|
|
490
|
+
"Use a proper HTML templating engine with context-aware escaping or a sanitizer.",
|
|
491
|
+
"Apply output encoding appropriate to the context (HTML attribute, URL, JS, CSS)."
|
|
492
|
+
]
|
|
493
|
+
}
|
|
494
|
+
];
|
|
495
|
+
}
|
|
496
|
+
// ---------------------------------------------------------------------------
|
|
497
|
+
// 17. WEB_POSTMESSAGE_WILDCARD — postMessage with wildcard targetOrigin
|
|
498
|
+
// ---------------------------------------------------------------------------
|
|
499
|
+
async function checkPostMessageWildcard() {
|
|
500
|
+
const hits = await searchRepo({
|
|
501
|
+
query: String.raw `(?:postMessage|parent\.postMessage|window\.postMessage)\s*\([^,)]+,\s*['"]\*['"]`,
|
|
502
|
+
isRegex: true,
|
|
503
|
+
maxMatches: 200
|
|
504
|
+
});
|
|
505
|
+
if (hits.length === 0)
|
|
506
|
+
return [];
|
|
507
|
+
return [
|
|
508
|
+
{
|
|
509
|
+
id: "WEB_POSTMESSAGE_WILDCARD",
|
|
510
|
+
title: "postMessage with wildcard targetOrigin '*' detected",
|
|
511
|
+
severity: "MEDIUM",
|
|
512
|
+
evidence: hits.slice(0, 15).map((m) => `${m.file}:${m.line}:${m.preview}`),
|
|
513
|
+
requiredActions: [
|
|
514
|
+
"postMessage with wildcard targetOrigin '*' — data sent to any listening origin (CWE-346).",
|
|
515
|
+
"Replace '*' with an explicit trusted origin (e.g. 'https://example.com').",
|
|
516
|
+
"Validate the sender's origin in the message receiver with event.origin checks."
|
|
517
|
+
]
|
|
518
|
+
}
|
|
519
|
+
];
|
|
520
|
+
}
|
|
521
|
+
// ---------------------------------------------------------------------------
|
|
522
|
+
// 18. WEB_CACHE_POISONING — X-Forwarded-Host or unkeyed header reflected
|
|
523
|
+
// ---------------------------------------------------------------------------
|
|
524
|
+
async function checkCachePoisoningHeaders() {
|
|
525
|
+
const hits = await searchRepo({
|
|
526
|
+
query: String.raw `req\.headers\s*\[\s*['"]x-forwarded-host['"]]|req\.headers\.(?:host|x-forwarded-host|x-original-url)`,
|
|
527
|
+
isRegex: true,
|
|
528
|
+
maxMatches: 200
|
|
529
|
+
});
|
|
530
|
+
const suspicious = hits.filter((m) => !/allowlist|===.*TRUSTED_HOST|ALLOWED_HOSTS/.test(m.preview));
|
|
531
|
+
if (suspicious.length === 0)
|
|
532
|
+
return [];
|
|
533
|
+
return [
|
|
534
|
+
{
|
|
535
|
+
id: "WEB_CACHE_POISONING",
|
|
536
|
+
title: "X-Forwarded-Host or unkeyed header reflected in response — cache poisoning risk",
|
|
537
|
+
severity: "MEDIUM",
|
|
538
|
+
evidence: suspicious.slice(0, 15).map((m) => `${m.file}:${m.line}:${m.preview}`),
|
|
539
|
+
requiredActions: [
|
|
540
|
+
"X-Forwarded-Host or unkeyed header reflected in response — web cache poisoning risk (CWE-444).",
|
|
541
|
+
"Validate X-Forwarded-Host against a strict allowlist of trusted hostnames before use.",
|
|
542
|
+
"Never reflect raw Host or X-Forwarded-Host headers into cached responses (e.g. URLs, redirects, links).",
|
|
543
|
+
"Configure your reverse proxy / CDN to strip or normalise forwarding headers before they reach the app."
|
|
544
|
+
]
|
|
545
|
+
}
|
|
546
|
+
];
|
|
547
|
+
}
|
|
548
|
+
// ---------------------------------------------------------------------------
|
|
549
|
+
// 19. WEB_MISSING_SRI — external scripts without Subresource Integrity
|
|
550
|
+
// ---------------------------------------------------------------------------
|
|
551
|
+
async function checkMissingSri() {
|
|
552
|
+
const hits = await searchRepo({
|
|
553
|
+
query: String.raw `<script[^>]+src\s*=\s*['"]https?://(?!localhost|127\.)[^'"]+['"][^>]*>`,
|
|
554
|
+
isRegex: true,
|
|
555
|
+
maxMatches: 200
|
|
556
|
+
});
|
|
557
|
+
const suspicious = hits.filter((m) => !/integrity=/.test(m.preview));
|
|
558
|
+
if (suspicious.length === 0)
|
|
559
|
+
return [];
|
|
560
|
+
return [
|
|
561
|
+
{
|
|
562
|
+
id: "WEB_MISSING_SRI",
|
|
563
|
+
title: "External script loaded without Subresource Integrity (SRI)",
|
|
564
|
+
severity: "MEDIUM",
|
|
565
|
+
evidence: suspicious.slice(0, 15).map((m) => `${m.file}:${m.line}:${m.preview}`),
|
|
566
|
+
requiredActions: [
|
|
567
|
+
"External script loaded without Subresource Integrity (SRI) — CDN compromise risk (CWE-829).",
|
|
568
|
+
"Add integrity and crossorigin attributes to all external <script> tags.",
|
|
569
|
+
"Generate SRI hashes at build time (e.g. using the SRI Hash Generator or webpack-subresource-integrity).",
|
|
570
|
+
"Consider self-hosting critical third-party scripts to eliminate CDN supply-chain risk."
|
|
571
|
+
]
|
|
572
|
+
}
|
|
573
|
+
];
|
|
574
|
+
}
|
|
575
|
+
// ---------------------------------------------------------------------------
|
|
576
|
+
// Main export
|
|
577
|
+
// ---------------------------------------------------------------------------
|
|
578
|
+
export async function checkWebNextjs(_) {
|
|
579
|
+
return runAll([
|
|
580
|
+
checkSecurityHeaders,
|
|
581
|
+
checkDangerouslySetInnerHTML,
|
|
582
|
+
checkSsrf,
|
|
583
|
+
checkOpenRedirect,
|
|
584
|
+
checkIdorRisk,
|
|
585
|
+
checkServerActionValidation,
|
|
586
|
+
checkApiRouteAuth,
|
|
587
|
+
checkCorsWildcard,
|
|
588
|
+
checkJwtHardcodedSecret,
|
|
589
|
+
checkRateLimitMissing,
|
|
590
|
+
checkEnvExposedClient,
|
|
591
|
+
checkGraphqlIntrospection,
|
|
592
|
+
checkPathTraversal,
|
|
593
|
+
checkLogPii,
|
|
594
|
+
checkSessionWeakConfig,
|
|
595
|
+
checkDanglingMarkup,
|
|
596
|
+
checkPostMessageWildcard,
|
|
597
|
+
checkCachePoisoningHeaders,
|
|
598
|
+
checkMissingSri
|
|
599
|
+
]);
|
|
76
600
|
}
|