xploitscan-shared-rules 1.7.4 → 1.8.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/dist/index.cjs +160 -11
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +5 -1
- package/dist/index.d.ts +5 -1
- package/dist/index.js +156 -11
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -162,11 +162,13 @@ __export(index_exports, {
|
|
|
162
162
|
largeBundleImport: () => largeBundleImport,
|
|
163
163
|
llmCallNoMaxTokens: () => llmCallNoMaxTokens,
|
|
164
164
|
llmOutputAsHTML: () => llmOutputAsHTML,
|
|
165
|
+
llmOutputToSink: () => llmOutputToSink,
|
|
165
166
|
llmPromptInjection: () => llmPromptInjection,
|
|
166
167
|
llmSystemPromptInjection: () => llmSystemPromptInjection,
|
|
167
168
|
logInjection: () => logInjection,
|
|
168
169
|
magicNumbers: () => magicNumbers,
|
|
169
170
|
massAssignment: () => massAssignment,
|
|
171
|
+
middlewareMatcherExcludesApi: () => middlewareMatcherExcludesApi,
|
|
170
172
|
missingAIRateLimit: () => missingAIRateLimit,
|
|
171
173
|
missingAuthMiddleware: () => missingAuthMiddleware,
|
|
172
174
|
missingAuthRateLimit: () => missingAuthRateLimit,
|
|
@@ -217,6 +219,7 @@ __export(index_exports, {
|
|
|
217
219
|
secretInCLIArgument: () => secretInCLIArgument,
|
|
218
220
|
secretInErrorResponse: () => secretInErrorResponse,
|
|
219
221
|
secretInHTMLAttribute: () => secretInHTMLAttribute,
|
|
222
|
+
secretInLLMPrompt: () => secretInLLMPrompt,
|
|
220
223
|
secretInURLParam: () => secretInURLParam,
|
|
221
224
|
secretLoggedToConsole: () => secretLoggedToConsole,
|
|
222
225
|
secretsInCI: () => secretsInCI,
|
|
@@ -253,6 +256,7 @@ __export(index_exports, {
|
|
|
253
256
|
weakHashing: () => weakHashing,
|
|
254
257
|
weakPasswordRequirements: () => weakPasswordRequirements,
|
|
255
258
|
weakRSAKeySize: () => weakRSAKeySize,
|
|
259
|
+
webhookMissingIdempotency: () => webhookMissingIdempotency,
|
|
256
260
|
webhookSignatureVerification: () => webhookSignatureVerification,
|
|
257
261
|
xssVulnerability: () => xssVulnerability,
|
|
258
262
|
xxeVulnerability: () => xxeVulnerability
|
|
@@ -478,7 +482,11 @@ var RULE_IMPACTS = {
|
|
|
478
482
|
VC203: "An LLM call without max_tokens lets attackers craft inputs that maximize output length, generating expensive responses on every request \u2014 denial-of-wallet that drains your monthly budget or trips rate limits for legitimate users.",
|
|
479
483
|
VC204: "Without a query depth limit, attackers send 100-level-deep nested queries that explode in resolver and DB cost. The server walks every level, the database does too, and a single crafted query takes the service down.",
|
|
480
484
|
VC205: "Even with a depth limit, queries like users(first:1000){posts(first:1000){comments(first:1000)}} are only three levels deep but resolve a billion items. Without complexity analysis, attackers DoS your GraphQL with a single request.",
|
|
481
|
-
VC206: "Apollo Server's csrfPrevention guards against cross-site form-style requests against GraphQL mutations. Disabling it lets any website trigger mutations in a logged-in user's browser \u2014 buying, deleting, or transferring on the victim's behalf."
|
|
485
|
+
VC206: "Apollo Server's csrfPrevention guards against cross-site form-style requests against GraphQL mutations. Disabling it lets any website trigger mutations in a logged-in user's browser \u2014 buying, deleting, or transferring on the victim's behalf.",
|
|
486
|
+
VC207: "Model output is attacker-influenceable via prompt injection. Feeding it into eval, new Function, a shell command, a raw SQL string, or a filesystem path turns a crafted or hallucinated response into remote code execution, command injection, SQL injection, or path traversal \u2014 your most dangerous sinks, driven by untrusted text.",
|
|
487
|
+
VC208: "Interpolating a secret into a prompt ships your API key, token, or password to a third-party model provider, where it persists in their request logs and training-eligible data. A credential that leaves your infrastructure in prompt text should be considered compromised and rotated.",
|
|
488
|
+
VC209: "Webhooks are delivered at-least-once. Without de-duplicating on the event id, a retried or replayed delivery re-runs the side effect \u2014 a customer is charged twice, a record is duplicated, or an entitlement is granted again. Stripe and Svix both retry on any non-2xx, so this fires in normal operation, not just under attack.",
|
|
489
|
+
VC210: "If your auth middleware skips /api, those routes run with no gate unless each one re-checks auth itself. It is the most common way a Next.js app ends up with publicly callable API routes that everyone assumed the middleware was protecting."
|
|
482
490
|
};
|
|
483
491
|
|
|
484
492
|
// src/exposure.ts
|
|
@@ -3946,9 +3954,9 @@ var xxeVulnerability = {
|
|
|
3946
3954
|
));
|
|
3947
3955
|
}
|
|
3948
3956
|
}
|
|
3949
|
-
if (!/parseXml\s*\(/.test(content)) return matches;
|
|
3957
|
+
if (!/parseXml\s*\(/.test(content)) return filterSilenced(matches, content, "VC081");
|
|
3950
3958
|
const ctx = tryParse(content, filePath);
|
|
3951
|
-
if (!ctx) return matches;
|
|
3959
|
+
if (!ctx) return filterSilenced(matches, content, "VC081");
|
|
3952
3960
|
visitCalls(
|
|
3953
3961
|
ctx.parsed,
|
|
3954
3962
|
(callee) => isCalleeNamed(callee, "parseXml") || isCalleeNamed(callee, "parseXML"),
|
|
@@ -3972,7 +3980,7 @@ var xxeVulnerability = {
|
|
|
3972
3980
|
);
|
|
3973
3981
|
}
|
|
3974
3982
|
);
|
|
3975
|
-
return matches;
|
|
3983
|
+
return filterSilenced(matches, content, "VC081");
|
|
3976
3984
|
}
|
|
3977
3985
|
};
|
|
3978
3986
|
var ssti = {
|
|
@@ -4001,7 +4009,7 @@ var ssti = {
|
|
|
4001
4009
|
));
|
|
4002
4010
|
}
|
|
4003
4011
|
if (!/(?:\.compile|\.render|renderString|render_template_string)\s*\(/.test(content)) {
|
|
4004
|
-
return matches;
|
|
4012
|
+
return filterSilenced(matches, content, "VC082");
|
|
4005
4013
|
}
|
|
4006
4014
|
const ctx = tryParse(content, filePath);
|
|
4007
4015
|
if (!ctx) return matches;
|
|
@@ -4040,7 +4048,7 @@ var ssti = {
|
|
|
4040
4048
|
);
|
|
4041
4049
|
}
|
|
4042
4050
|
);
|
|
4043
|
-
return matches;
|
|
4051
|
+
return filterSilenced(matches, content, "VC082");
|
|
4044
4052
|
}
|
|
4045
4053
|
};
|
|
4046
4054
|
var javaDeserialization = {
|
|
@@ -4361,7 +4369,7 @@ var commandInjection = {
|
|
|
4361
4369
|
matches.push(m);
|
|
4362
4370
|
}
|
|
4363
4371
|
}
|
|
4364
|
-
return matches;
|
|
4372
|
+
return filterSilenced(matches, content, "VC094");
|
|
4365
4373
|
}
|
|
4366
4374
|
};
|
|
4367
4375
|
var corsLocalhost = {
|
|
@@ -4663,8 +4671,18 @@ var complianceMap = {
|
|
|
4663
4671
|
// no query depth limit
|
|
4664
4672
|
VC205: { owasp: "A04:2021", cwe: "CWE-770" },
|
|
4665
4673
|
// no query complexity limit
|
|
4666
|
-
VC206: { owasp: "A01:2021", cwe: "CWE-352" }
|
|
4674
|
+
VC206: { owasp: "A01:2021", cwe: "CWE-352" },
|
|
4667
4675
|
// Apollo csrfPrevention: false
|
|
4676
|
+
// VC207–VC208: AI/LLM data-flow
|
|
4677
|
+
VC207: { owasp: "A03:2021", cwe: "CWE-94" },
|
|
4678
|
+
// model output → code/cmd/query/fs sink
|
|
4679
|
+
VC208: { owasp: "A09:2021", cwe: "CWE-532" },
|
|
4680
|
+
// secret interpolated into LLM prompt
|
|
4681
|
+
// VC209–VC210: advisory heuristics
|
|
4682
|
+
VC209: { owasp: "A04:2021", cwe: "CWE-799" },
|
|
4683
|
+
// webhook missing idempotency
|
|
4684
|
+
VC210: { owasp: "A01:2021", cwe: "CWE-862" }
|
|
4685
|
+
// middleware matcher excludes /api
|
|
4668
4686
|
};
|
|
4669
4687
|
var consoleLogProduction = {
|
|
4670
4688
|
id: "VC097",
|
|
@@ -7046,7 +7064,7 @@ var llmPromptInjection = {
|
|
|
7046
7064
|
});
|
|
7047
7065
|
}
|
|
7048
7066
|
}
|
|
7049
|
-
return findings;
|
|
7067
|
+
return filterSilenced(findings, content, "VC198");
|
|
7050
7068
|
}
|
|
7051
7069
|
};
|
|
7052
7070
|
var llmSystemPromptInjection = {
|
|
@@ -7083,7 +7101,7 @@ var llmSystemPromptInjection = {
|
|
|
7083
7101
|
});
|
|
7084
7102
|
}
|
|
7085
7103
|
}
|
|
7086
|
-
return findings;
|
|
7104
|
+
return filterSilenced(findings, content, "VC199");
|
|
7087
7105
|
}
|
|
7088
7106
|
};
|
|
7089
7107
|
var llmOutputAsHTML = {
|
|
@@ -7127,7 +7145,7 @@ var llmOutputAsHTML = {
|
|
|
7127
7145
|
});
|
|
7128
7146
|
}
|
|
7129
7147
|
}
|
|
7130
|
-
return findings;
|
|
7148
|
+
return filterSilenced(findings, content, "VC200");
|
|
7131
7149
|
}
|
|
7132
7150
|
};
|
|
7133
7151
|
var vectorStoreQueryNoUserFilter = {
|
|
@@ -7359,6 +7377,127 @@ var graphqlCSRFDisabled = {
|
|
|
7359
7377
|
return findings;
|
|
7360
7378
|
}
|
|
7361
7379
|
};
|
|
7380
|
+
var LLM_OUTPUT_SHAPE = "(?:choices\\s*\\[\\s*\\d*\\s*\\]?\\s*\\.\\s*message(?:\\s*\\.\\s*content)?|completion(?:\\.text|\\.content)?|message\\s*\\.\\s*content|content_block|delta\\s*\\.\\s*text|generated_text|output_text)";
|
|
7381
|
+
var llmOutputToSink = {
|
|
7382
|
+
id: "VC207",
|
|
7383
|
+
title: "AI/LLM: model output passed to a code, command, query, or file sink",
|
|
7384
|
+
severity: "critical",
|
|
7385
|
+
category: "Injection",
|
|
7386
|
+
description: "Passing model output into eval(), new Function(), a shell command, a raw SQL string, or a filesystem path treats the model's response as trusted code. A prompt-injected or hallucinated response then becomes RCE, command injection, SQL injection, or path traversal \u2014 the model is an attacker-influenced input wired straight into your most dangerous sinks.",
|
|
7387
|
+
check(content, filePath) {
|
|
7388
|
+
if (!LLM_FILE_RE.test(filePath)) return [];
|
|
7389
|
+
if (isTestFile(filePath)) return [];
|
|
7390
|
+
if (!fileUsesLLMSDK(content)) return [];
|
|
7391
|
+
const sinkGroups = [
|
|
7392
|
+
"eval",
|
|
7393
|
+
"new\\s+Function",
|
|
7394
|
+
"(?:child_process\\s*\\.\\s*)?(?:exec|execSync|spawn|spawnSync|execFile|execFileSync)",
|
|
7395
|
+
"(?:\\$queryRawUnsafe|\\$executeRawUnsafe|\\.\\s*(?:query|execute|raw))",
|
|
7396
|
+
"(?:fs\\s*\\.\\s*)?(?:readFile|readFileSync|writeFile|writeFileSync|createReadStream|createWriteStream|unlink|unlinkSync|rm|rmSync)"
|
|
7397
|
+
];
|
|
7398
|
+
const matches = [];
|
|
7399
|
+
for (const sink of sinkGroups) {
|
|
7400
|
+
const re = new RegExp(`\\b${sink}\\s*\\([^;\\n]{0,120}${LLM_OUTPUT_SHAPE}`, "g");
|
|
7401
|
+
matches.push(...findMatches(
|
|
7402
|
+
content,
|
|
7403
|
+
re,
|
|
7404
|
+
llmOutputToSink,
|
|
7405
|
+
filePath,
|
|
7406
|
+
() => "Never pass model output into eval / new Function / a shell command / a raw SQL string / a filesystem path. Treat it as untrusted input: constrain it to a JSON schema or an allowlist, and use parameterized queries and safe path APIs. If this is a reviewed, sandboxed use, add an inline `// VC207-OK: <reason>` comment to silence."
|
|
7407
|
+
));
|
|
7408
|
+
}
|
|
7409
|
+
return filterSilenced(matches, content, "VC207");
|
|
7410
|
+
}
|
|
7411
|
+
};
|
|
7412
|
+
var secretInLLMPrompt = {
|
|
7413
|
+
id: "VC208",
|
|
7414
|
+
title: "AI/LLM: secret or credential interpolated into a model prompt",
|
|
7415
|
+
severity: "high",
|
|
7416
|
+
category: "Information Leakage",
|
|
7417
|
+
description: "Interpolating an environment secret (API key, token, password) into a prompt sends your credential to a third-party model provider, where it can land in their request logs and training-eligible data. Secrets should never be part of prompt text \u2014 pass only the data the model needs to reason about.",
|
|
7418
|
+
check(content, filePath) {
|
|
7419
|
+
if (!LLM_FILE_RE.test(filePath)) return [];
|
|
7420
|
+
if (isTestFile(filePath)) return [];
|
|
7421
|
+
if (!fileUsesLLMSDK(content)) return [];
|
|
7422
|
+
const SECRET_ENV = /\$\{\s*process\.env\.[A-Z0-9_]*(?:KEY|SECRET|TOKEN|PASSWORD|PASSWD|CREDENTIAL|PRIVATE)[A-Z0-9_]*\s*\}/;
|
|
7423
|
+
const PROMPT_CTX = /\b(?:prompt|content|system|user|assistant|messages|instructions?|systemPrompt|userPrompt)\b/i;
|
|
7424
|
+
const lines = content.split("\n");
|
|
7425
|
+
const matches = [];
|
|
7426
|
+
for (let i = 0; i < lines.length; i++) {
|
|
7427
|
+
const line = lines[i];
|
|
7428
|
+
if (!SECRET_ENV.test(line)) continue;
|
|
7429
|
+
const trimmed = line.trimStart();
|
|
7430
|
+
if (trimmed.startsWith("//") || trimmed.startsWith("*") || trimmed.startsWith("#")) continue;
|
|
7431
|
+
const prev = i > 0 ? lines[i - 1] : "";
|
|
7432
|
+
if (!PROMPT_CTX.test(line) && !PROMPT_CTX.test(prev)) continue;
|
|
7433
|
+
matches.push({
|
|
7434
|
+
rule: "VC208",
|
|
7435
|
+
title: secretInLLMPrompt.title,
|
|
7436
|
+
severity: "high",
|
|
7437
|
+
category: "Information Leakage",
|
|
7438
|
+
file: filePath,
|
|
7439
|
+
line: i + 1,
|
|
7440
|
+
snippet: getSnippet(content, i + 1),
|
|
7441
|
+
fix: "Don't put credentials in prompt text \u2014 they get sent to (and logged by) the model provider. Pass only the data the model needs; keep secrets in headers/SDK config. If this env var is genuinely non-sensitive despite its name, add an inline `// VC208-OK: <reason>` comment."
|
|
7442
|
+
});
|
|
7443
|
+
}
|
|
7444
|
+
return filterSilenced(matches, content, "VC208");
|
|
7445
|
+
}
|
|
7446
|
+
};
|
|
7447
|
+
var webhookMissingIdempotency = {
|
|
7448
|
+
id: "VC209",
|
|
7449
|
+
title: "Webhook handler without idempotency / replay protection",
|
|
7450
|
+
severity: "low",
|
|
7451
|
+
category: "Logic",
|
|
7452
|
+
description: "Payment and event webhooks (Stripe, Svix, GitHub, ...) are delivered at-least-once. A handler that performs a side effect (create / charge / grant) without de-duplicating on the event ID can double-process a retried or replayed delivery \u2014 double charges, duplicate records, repeated entitlement grants.",
|
|
7453
|
+
check(content, filePath) {
|
|
7454
|
+
if (!/\.(js|ts|jsx|tsx)$/.test(filePath)) return [];
|
|
7455
|
+
if (isTestFile(filePath)) return [];
|
|
7456
|
+
if (!/constructEvent|new\s+Webhook\s*\(|svix|verifyHeader|x-signature|stripe-signature/i.test(content)) return [];
|
|
7457
|
+
if (!/\.(?:create|update|upsert|insert|delete)\s*\(|INSERT\s+INTO|charges?\.create|subscriptions?\.create|\bgrant|entitlement/i.test(content)) return [];
|
|
7458
|
+
if (/idempoten|event\.id|evt\.id|delivery_?id|alreadyProcessed|processedEvents|ON\s+CONFLICT|INSERT\s+OR\s+IGNORE|\bseen\b|\bprocessed\b/i.test(content)) return [];
|
|
7459
|
+
const m = content.match(/constructEvent|new\s+Webhook\s*\(|verifyHeader/i);
|
|
7460
|
+
if (!m || m.index === void 0) return [];
|
|
7461
|
+
const line = content.substring(0, m.index).split("\n").length;
|
|
7462
|
+
return filterSilenced([{
|
|
7463
|
+
rule: "VC209",
|
|
7464
|
+
title: webhookMissingIdempotency.title,
|
|
7465
|
+
severity: "low",
|
|
7466
|
+
category: "Logic",
|
|
7467
|
+
file: filePath,
|
|
7468
|
+
line,
|
|
7469
|
+
snippet: getSnippet(content, line),
|
|
7470
|
+
fix: "De-duplicate webhook deliveries: persist each event id and skip if already seen, or use a DB unique constraint (INSERT ... ON CONFLICT DO NOTHING). Webhooks are at-least-once \u2014 assume retries. If dedup is handled elsewhere, add an inline `// VC209-OK: <reason>` comment."
|
|
7471
|
+
}], content, "VC209");
|
|
7472
|
+
}
|
|
7473
|
+
};
|
|
7474
|
+
var middlewareMatcherExcludesApi = {
|
|
7475
|
+
id: "VC210",
|
|
7476
|
+
title: "Auth middleware matcher excludes API routes",
|
|
7477
|
+
severity: "info",
|
|
7478
|
+
category: "Authorization",
|
|
7479
|
+
description: "A Next.js middleware that performs authentication but whose config.matcher excludes /api means API routes bypass the middleware entirely. Unless each API route authenticates on its own, they're unprotected. This is an advisory to verify per-route auth \u2014 not a confirmed vulnerability.",
|
|
7480
|
+
check(content, filePath) {
|
|
7481
|
+
if (!/middleware\.(ts|js|mjs)$/.test(filePath)) return [];
|
|
7482
|
+
if (isTestFile(filePath)) return [];
|
|
7483
|
+
if (!/clerkMiddleware|authMiddleware|getToken|getServerSession|NextResponse\.redirect|withAuth|next-auth|\bauth\s*\(/i.test(content)) return [];
|
|
7484
|
+
if (!/matcher\s*:/.test(content)) return [];
|
|
7485
|
+
if (!/\(\?!\s*[^)]*\bapi\b/.test(content)) return [];
|
|
7486
|
+
const m = content.match(/matcher\s*:/);
|
|
7487
|
+
if (!m || m.index === void 0) return [];
|
|
7488
|
+
const line = content.substring(0, m.index).split("\n").length;
|
|
7489
|
+
return filterSilenced([{
|
|
7490
|
+
rule: "VC210",
|
|
7491
|
+
title: middlewareMatcherExcludesApi.title,
|
|
7492
|
+
severity: "info",
|
|
7493
|
+
category: "Authorization",
|
|
7494
|
+
file: filePath,
|
|
7495
|
+
line,
|
|
7496
|
+
snippet: getSnippet(content, line),
|
|
7497
|
+
fix: "Your middleware's matcher excludes /api, so API routes skip it. Either include /api in the matcher, or confirm every API route authenticates on its own (auth() / requireUser / getServerSession). If routes self-authenticate by design, add an inline `// VC210-OK: <reason>` comment."
|
|
7498
|
+
}], content, "VC210");
|
|
7499
|
+
}
|
|
7500
|
+
};
|
|
7362
7501
|
var secretInURLParam = {
|
|
7363
7502
|
id: "VC146",
|
|
7364
7503
|
title: "Secret Passed in URL Query Parameter",
|
|
@@ -8078,6 +8217,12 @@ var allCustomRules = [
|
|
|
8078
8217
|
vectorStoreQueryNoUserFilter,
|
|
8079
8218
|
vectorStoreUpsertNoMetadata,
|
|
8080
8219
|
llmCallNoMaxTokens,
|
|
8220
|
+
// VC207–VC208: AI/LLM data-flow rules
|
|
8221
|
+
llmOutputToSink,
|
|
8222
|
+
secretInLLMPrompt,
|
|
8223
|
+
// VC209–VC210: advisory heuristics
|
|
8224
|
+
webhookMissingIdempotency,
|
|
8225
|
+
middlewareMatcherExcludesApi,
|
|
8081
8226
|
// VC204–VC206: GraphQL server hardening
|
|
8082
8227
|
graphqlNoDepthLimit,
|
|
8083
8228
|
graphqlNoComplexityLimit,
|
|
@@ -8564,11 +8709,13 @@ function scanEntropy(files) {
|
|
|
8564
8709
|
largeBundleImport,
|
|
8565
8710
|
llmCallNoMaxTokens,
|
|
8566
8711
|
llmOutputAsHTML,
|
|
8712
|
+
llmOutputToSink,
|
|
8567
8713
|
llmPromptInjection,
|
|
8568
8714
|
llmSystemPromptInjection,
|
|
8569
8715
|
logInjection,
|
|
8570
8716
|
magicNumbers,
|
|
8571
8717
|
massAssignment,
|
|
8718
|
+
middlewareMatcherExcludesApi,
|
|
8572
8719
|
missingAIRateLimit,
|
|
8573
8720
|
missingAuthMiddleware,
|
|
8574
8721
|
missingAuthRateLimit,
|
|
@@ -8619,6 +8766,7 @@ function scanEntropy(files) {
|
|
|
8619
8766
|
secretInCLIArgument,
|
|
8620
8767
|
secretInErrorResponse,
|
|
8621
8768
|
secretInHTMLAttribute,
|
|
8769
|
+
secretInLLMPrompt,
|
|
8622
8770
|
secretInURLParam,
|
|
8623
8771
|
secretLoggedToConsole,
|
|
8624
8772
|
secretsInCI,
|
|
@@ -8655,6 +8803,7 @@ function scanEntropy(files) {
|
|
|
8655
8803
|
weakHashing,
|
|
8656
8804
|
weakPasswordRequirements,
|
|
8657
8805
|
weakRSAKeySize,
|
|
8806
|
+
webhookMissingIdempotency,
|
|
8658
8807
|
webhookSignatureVerification,
|
|
8659
8808
|
xssVulnerability,
|
|
8660
8809
|
xxeVulnerability
|