xploitscan-shared-rules 1.7.3 → 1.8.0

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 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
@@ -898,6 +906,12 @@ function isInlineSilenced(content, matchIndex, ruleId) {
898
906
  );
899
907
  return marker.test(matchLine) || marker.test(prevLine);
900
908
  }
909
+ function filterSilenced(matches, content, ruleId) {
910
+ if (matches.length === 0) return matches;
911
+ const lines = content.split("\n");
912
+ const lineStartIndex = (line) => lines.slice(0, line - 1).reduce((acc, l) => acc + l.length + 1, 0);
913
+ return matches.filter((m) => !isInlineSilenced(content, lineStartIndex(m.line), ruleId));
914
+ }
901
915
  function findMatches(content, pattern, rule, filePath, fixTemplate) {
902
916
  const matches = [];
903
917
  const lines = content.split("\n");
@@ -4181,10 +4195,10 @@ var sensitiveURLParams = {
4181
4195
  p,
4182
4196
  sensitiveURLParams,
4183
4197
  filePath,
4184
- () => "Never pass sensitive data in URL parameters. Use request headers (Authorization: Bearer ...) or POST body instead."
4198
+ () => "Never pass sensitive data in URL parameters. Use request headers (Authorization: Bearer ...) or POST body instead. If this value is intentionally URL-safe (e.g. a one-time, server-verified reference like a Stripe checkout session_id), add an inline `// VC088-OK: <reason>` comment to silence."
4185
4199
  ));
4186
4200
  }
4187
- return matches;
4201
+ return filterSilenced(matches, content, "VC088");
4188
4202
  }
4189
4203
  };
4190
4204
  var missingContentDisposition = {
@@ -4657,8 +4671,18 @@ var complianceMap = {
4657
4671
  // no query depth limit
4658
4672
  VC205: { owasp: "A04:2021", cwe: "CWE-770" },
4659
4673
  // no query complexity limit
4660
- VC206: { owasp: "A01:2021", cwe: "CWE-352" }
4674
+ VC206: { owasp: "A01:2021", cwe: "CWE-352" },
4661
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
4662
4686
  };
4663
4687
  var consoleLogProduction = {
4664
4688
  id: "VC097",
@@ -7353,6 +7377,127 @@ var graphqlCSRFDisabled = {
7353
7377
  return findings;
7354
7378
  }
7355
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
+ };
7356
7501
  var secretInURLParam = {
7357
7502
  id: "VC146",
7358
7503
  title: "Secret Passed in URL Query Parameter",
@@ -8072,6 +8217,12 @@ var allCustomRules = [
8072
8217
  vectorStoreQueryNoUserFilter,
8073
8218
  vectorStoreUpsertNoMetadata,
8074
8219
  llmCallNoMaxTokens,
8220
+ // VC207–VC208: AI/LLM data-flow rules
8221
+ llmOutputToSink,
8222
+ secretInLLMPrompt,
8223
+ // VC209–VC210: advisory heuristics
8224
+ webhookMissingIdempotency,
8225
+ middlewareMatcherExcludesApi,
8075
8226
  // VC204–VC206: GraphQL server hardening
8076
8227
  graphqlNoDepthLimit,
8077
8228
  graphqlNoComplexityLimit,
@@ -8558,11 +8709,13 @@ function scanEntropy(files) {
8558
8709
  largeBundleImport,
8559
8710
  llmCallNoMaxTokens,
8560
8711
  llmOutputAsHTML,
8712
+ llmOutputToSink,
8561
8713
  llmPromptInjection,
8562
8714
  llmSystemPromptInjection,
8563
8715
  logInjection,
8564
8716
  magicNumbers,
8565
8717
  massAssignment,
8718
+ middlewareMatcherExcludesApi,
8566
8719
  missingAIRateLimit,
8567
8720
  missingAuthMiddleware,
8568
8721
  missingAuthRateLimit,
@@ -8613,6 +8766,7 @@ function scanEntropy(files) {
8613
8766
  secretInCLIArgument,
8614
8767
  secretInErrorResponse,
8615
8768
  secretInHTMLAttribute,
8769
+ secretInLLMPrompt,
8616
8770
  secretInURLParam,
8617
8771
  secretLoggedToConsole,
8618
8772
  secretsInCI,
@@ -8649,6 +8803,7 @@ function scanEntropy(files) {
8649
8803
  weakHashing,
8650
8804
  weakPasswordRequirements,
8651
8805
  weakRSAKeySize,
8806
+ webhookMissingIdempotency,
8652
8807
  webhookSignatureVerification,
8653
8808
  xssVulnerability,
8654
8809
  xxeVulnerability