xploitscan-shared-rules 1.6.2 → 1.7.2

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
@@ -887,6 +887,17 @@ function isInsideFixMessage(content, matchIndex) {
887
887
  const lineText = content.substring(lineStart, content.indexOf("\n", matchIndex));
888
888
  return /(?:fix|description|message|suggestion|hint|help|example|doc|comment)\s*[:=(]/i.test(lineText) || /return\s*["'`].*\b(?:Use|Replace|Add|Move|Set|Enable|Disable|Never|Don't|Do not|Instead)\b/i.test(lineText);
889
889
  }
890
+ function isInlineSilenced(content, matchIndex, ruleId) {
891
+ const lines = content.split("\n");
892
+ const matchLineNum = content.substring(0, matchIndex).split("\n").length - 1;
893
+ const matchLine = lines[matchLineNum] ?? "";
894
+ const prevLine = matchLineNum > 0 ? lines[matchLineNum - 1] ?? "" : "";
895
+ const marker = new RegExp(
896
+ `(?://|/\\*|#)\\s*(?:${ruleId}|scanner)-OK\\b`,
897
+ "i"
898
+ );
899
+ return marker.test(matchLine) || marker.test(prevLine);
900
+ }
890
901
  function findMatches(content, pattern, rule, filePath, fixTemplate) {
891
902
  const matches = [];
892
903
  const lines = content.split("\n");
@@ -1015,6 +1026,16 @@ var hardcodedSecrets = {
1015
1026
  const secretMatch = lineText.match(/[:=]\s*["'`]([^"'`]*)["'`]/);
1016
1027
  if (secretMatch && secretMatch[1].length < floor) continue;
1017
1028
  }
1029
+ if (pi === 6) {
1030
+ const valMatch = lineText.match(/[:=]\s*["'`]([^"'`]+)["'`]/);
1031
+ const nameMatch = lineText.match(/\b([A-Z][A-Z0-9_]*_(?:SECRET|TOKEN|KEY|PASSWORD|PASSWD))\b/);
1032
+ if (valMatch && nameMatch) {
1033
+ const value = valMatch[1];
1034
+ const isKeySuffix = nameMatch[1].endsWith("_KEY");
1035
+ const isKebabIdentifier = value.length < 40 && /^[a-z0-9]+(-[a-z0-9]+)+$/.test(value);
1036
+ if (isKeySuffix && isKebabIdentifier) continue;
1037
+ }
1038
+ }
1018
1039
  matches.push(rm);
1019
1040
  }
1020
1041
  }
@@ -1204,11 +1225,19 @@ var sqlInjection = {
1204
1225
  // The older `[^"']*` class bailed out at the first inner quote and
1205
1226
  // missed every realistic SQL-containing concat.
1206
1227
  /(?:query|execute|raw|sql|queryRaw|queryRawUnsafe|executeRaw|executeRawUnsafe)\s*\(\s*(?:"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')\s*\+/gi,
1207
- // Direct variable interpolation in a SQL verb context
1208
- /(?:SELECT|INSERT|UPDATE|DELETE|WHERE)\s+.*\$\{(?!.*parameterized)/gi
1228
+ // Direct variable interpolation in a SQL string literal. Requires the
1229
+ // verb to START a quoted/backtick literal AND be followed — within the
1230
+ // same literal — by a SQL structural keyword (FROM/SET/WHERE/…), then a
1231
+ // `${` interpolation. The structural keyword is what distinguishes a
1232
+ // real query from ordinary prose: the previous pattern matched any
1233
+ // SELECT/UPDATE/… word anywhere on a line followed by `${`, so UI copy
1234
+ // like `UPDATE your profile settings ${name}` flagged as a CRITICAL SQL
1235
+ // injection. Real queries ("SELECT … FROM …", "UPDATE … SET …") always
1236
+ // carry a second structural keyword; sentences almost never do.
1237
+ /["'`]\s*(?:SELECT|INSERT|UPDATE|DELETE)\b[^"'`\n]*\b(?:FROM|INTO|SET|VALUES|WHERE|JOIN|RETURNING)\b[^"'`\n]*\$\{(?!.*parameterized)/gi
1209
1238
  ];
1210
1239
  const matches = [];
1211
- const usesParams = /\?\s*,|\$\d+|:[\w]+|\bprepare\b|\bplaceholder\b/i.test(content);
1240
+ const usesParams = /\?\s*,|\?[^?,;]{1,20}\?|\$\d+|:[\w]+|\bprepare\b|\bplaceholders?\b/i.test(content);
1212
1241
  if (usesParams) return [];
1213
1242
  for (const pattern of patterns) {
1214
1243
  const raw = findMatches(
@@ -1251,6 +1280,7 @@ var xssVulnerability = {
1251
1280
  // {@html} in Svelte
1252
1281
  /\{@html\s/g
1253
1282
  ];
1283
+ const allLines = content.split("\n");
1254
1284
  const matches = [];
1255
1285
  for (const pattern of patterns) {
1256
1286
  const raw = findMatches(
@@ -1258,12 +1288,18 @@ var xssVulnerability = {
1258
1288
  pattern,
1259
1289
  xssVulnerability,
1260
1290
  filePath,
1261
- () => "Sanitize user input before rendering as HTML. Use a library like DOMPurify: DOMPurify.sanitize(userInput)"
1291
+ () => "Sanitize user input before rendering as HTML. Use a library like DOMPurify: DOMPurify.sanitize(userInput). If this site is intentional (JSON-LD structured data, a const boot script, a sandboxed preview), add an inline `// VC007-OK: <reason>` comment above the line to silence."
1262
1292
  );
1263
1293
  for (const m of raw) {
1264
- const lineText = content.split("\n")[m.line - 1] || "";
1294
+ const lineText = allLines[m.line - 1] || "";
1265
1295
  if (/\.innerHTML\s*=\s*['"]/.test(lineText) && !/\$\{/.test(lineText)) continue;
1266
1296
  if (/\.innerHTML\s*=\s*['"][^'"]*['"]\s*$/.test(lineText)) continue;
1297
+ if (/dangerouslySetInnerHTML/.test(lineText)) {
1298
+ const windowText = allLines.slice(m.line - 1, m.line + 2).join("\n");
1299
+ if (/JSON\.stringify/i.test(windowText)) continue;
1300
+ const lineStartIdx = allLines.slice(0, m.line - 1).reduce((acc, l) => acc + l.length + 1, 0);
1301
+ if (isInlineSilenced(content, lineStartIdx, "VC007")) continue;
1302
+ }
1267
1303
  matches.push(m);
1268
1304
  }
1269
1305
  }
@@ -1539,23 +1575,35 @@ var unvalidatedRedirect = {
1539
1575
  check(content, filePath) {
1540
1576
  if (isTestFile(filePath)) return [];
1541
1577
  if (/isAllowedRedirect|validateRedirect|isSafeRedirect|allowedDomains|trustedDomains|whitelist.*url|allowlist.*url/i.test(content)) return [];
1542
- const patterns = [
1543
- /window\.location\s*=\s*(?!["'`]https?:\/\/)/g,
1544
- /window\.location\.href\s*=\s*(?!["'`]https?:\/\/)/g,
1545
- /window\.location\.assign\s*\(\s*(?!["'`]https?:\/\/)/g,
1546
- /window\.location\.replace\s*\(\s*(?!["'`]https?:\/\/)/g,
1547
- /res\.redirect\s*\(\s*(?:req\.|params\.|query\.)/gi
1548
- ];
1549
1578
  const matches = [];
1550
- for (const p of patterns) {
1551
- matches.push(...findMatches(
1552
- content,
1553
- p,
1554
- unvalidatedRedirect,
1555
- filePath,
1556
- () => "Validate redirect URLs against an allowlist of trusted domains. Never redirect to user-supplied URLs directly."
1557
- ));
1579
+ const navPattern = /(?:window\.location(?:\.href)?\s*=\s*|window\.location\.(?:assign|replace)\s*\(\s*)([^;\n)]+)/g;
1580
+ let m;
1581
+ while ((m = navPattern.exec(content)) !== null) {
1582
+ if (isCommentLine(content, m.index)) continue;
1583
+ const value = m[1].trim();
1584
+ const hasInterpolation = /\$\{/.test(value);
1585
+ const isPureLiteral = /^["'`][^"'`]*["'`]$/.test(value);
1586
+ if (!hasInterpolation && isPureLiteral) continue;
1587
+ if (isInlineSilenced(content, m.index, "VC016")) continue;
1588
+ const line = content.substring(0, m.index).split("\n").length;
1589
+ matches.push({
1590
+ rule: "VC016",
1591
+ title: unvalidatedRedirect.title,
1592
+ severity: "high",
1593
+ category: "Injection",
1594
+ file: filePath,
1595
+ line,
1596
+ snippet: getSnippet(content, line),
1597
+ fix: "Validate redirect URLs against an allowlist of trusted domains. Never redirect to user-supplied URLs directly. If the target is a hardcoded/internal path you control, add an inline `// VC016-OK: <reason>` comment to silence."
1598
+ });
1558
1599
  }
1600
+ matches.push(...findMatches(
1601
+ content,
1602
+ /res\.redirect\s*\(\s*(?:req\.|params\.|query\.)/gi,
1603
+ unvalidatedRedirect,
1604
+ filePath,
1605
+ () => "Validate redirect URLs against an allowlist of trusted domains. Never redirect to user-supplied URLs directly."
1606
+ ));
1559
1607
  return matches;
1560
1608
  }
1561
1609
  };
@@ -3299,6 +3347,7 @@ var dangerousInnerHTML = {
3299
3347
  let m;
3300
3348
  while ((m = re.exec(content)) !== null) {
3301
3349
  if (isCommentLine(content, m.index)) continue;
3350
+ if (isInlineSilenced(content, m.index, "VC063")) continue;
3302
3351
  const value = m[1].trim();
3303
3352
  if (/^[A-Z][A-Z0-9_]+$/.test(value)) continue;
3304
3353
  if (/^["'`][^$]*["'`]$/.test(value)) continue;
@@ -3312,7 +3361,7 @@ var dangerousInnerHTML = {
3312
3361
  file: filePath,
3313
3362
  line: lineNum,
3314
3363
  snippet: getSnippet(content, lineNum),
3315
- fix: "Sanitize HTML before using dangerouslySetInnerHTML: dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(content) }}. Install: npm install dompurify"
3364
+ fix: "Sanitize HTML before using dangerouslySetInnerHTML: dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(content) }}. Install: npm install dompurify. If this site is intentional (server-built template, nonce-attribute boot script, etc.), add an inline `// VC063-OK: <reason>` comment above the line to silence."
3316
3365
  });
3317
3366
  }
3318
3367
  return findings;
@@ -7314,6 +7363,7 @@ var secretInURLParam = {
7314
7363
  while ((m = re.exec(content)) !== null) {
7315
7364
  if (isCommentLine(content, m.index)) continue;
7316
7365
  if (isInsideFixMessage(content, m.index)) continue;
7366
+ if (isInlineSilenced(content, m.index, "VC146")) continue;
7317
7367
  const lineNum = content.substring(0, m.index).split("\n").length;
7318
7368
  findings.push({
7319
7369
  rule: "VC146",
@@ -7323,7 +7373,7 @@ var secretInURLParam = {
7323
7373
  file: filePath,
7324
7374
  line: lineNum,
7325
7375
  snippet: getSnippet(content, lineNum),
7326
- fix: "Pass secrets in the Authorization header (Bearer token) or request body, never in URL parameters. URL parameters are logged in server access logs, browser history, and referrer headers."
7376
+ fix: "Pass secrets in the Authorization header (Bearer token) or request body, never in URL parameters. URL parameters are logged in server access logs, browser history, and referrer headers. If this finding is legitimate (magic-link / claim-token flow), add an inline `// VC146-OK: <reason>` comment above the line to silence it."
7327
7377
  });
7328
7378
  }
7329
7379
  }
@@ -7514,10 +7564,14 @@ var webhookSignatureVerification = {
7514
7564
  check(content, filePath) {
7515
7565
  if (!filePath.match(/\.(js|ts|jsx|tsx)$/)) return [];
7516
7566
  if (isTestFile(filePath)) return [];
7517
- if (!/\/api\/|\/webhook/i.test(filePath)) return [];
7567
+ if (!/webhook/i.test(filePath)) return [];
7518
7568
  if (!/export\s+(?:async\s+)?function\s+POST/i.test(content)) return [];
7519
7569
  const services = [
7520
- { name: "Clerk", content: /clerk/i, events: /user\.created|user\.updated|user\.deleted|session\./i, verify: /svix|Webhook\(\)\.verify|webhook-id|wh_secret/i },
7570
+ // Tightened Clerk events: require the specific subevent suffix
7571
+ // rather than bare `session.`. The bare-prefix version matched
7572
+ // Stripe Checkout `session.url`, which has nothing to do with
7573
+ // Clerk webhooks.
7574
+ { name: "Clerk", content: /clerk/i, events: /user\.(created|updated|deleted)|session\.(created|removed|ended|revoked)/i, verify: /svix|Webhook\(\)\.verify|webhook-id|wh_secret/i },
7521
7575
  { name: "GitHub", content: /github/i, events: /push|pull_request|issues|installation/i, verify: /createHmac|x-hub-signature|verify.*signature|GITHUB_WEBHOOK_SECRET/i },
7522
7576
  { name: "Resend", content: /resend/i, events: /email\.sent|email\.delivered|email\.bounced/i, verify: /svix|webhook.*verify|x-webhook-signature|RESEND_WEBHOOK_SECRET/i },
7523
7577
  { name: "SendGrid", content: /sendgrid/i, events: /inbound.*parse|event.*webhook/i, verify: /EventWebhook|x-twilio-email|verifySignature|SENDGRID_WEBHOOK_VERIFICATION/i },
@@ -8269,11 +8323,20 @@ var SAFE_PATTERNS = [
8269
8323
  /^'sha\d+-/,
8270
8324
  /^nonce-/i,
8271
8325
  // Embedded data URIs
8272
- /^data:[a-z]+\//i
8326
+ /^data:[a-z]+\//i,
8327
+ // Sentry DSN — a publishable, client-side ingest key (it ships to the
8328
+ // browser), not a secret. Shape: https://<hash>@o<orgid>.ingest.<region.>
8329
+ // sentry.io/<projectid>. The generic URL allowlist above deliberately
8330
+ // excludes anything with an `@`, so the DSN would otherwise fire.
8331
+ /^https?:\/\/[0-9a-f]+@o\d+\.ingest\.[a-z0-9.]*sentry\.io\//i,
8332
+ // HTTP security-header directive values (HSTS, etc.) — `max-age=63072000;
8333
+ // includeSubDomains; preload`. Config strings a security scanner actively
8334
+ // tells users to add; never secrets.
8335
+ /^max-age=\d+/i
8273
8336
  ];
8274
8337
  var SKIP_FILES = /\.(css|scss|less|svg|md|txt|html?|xml|yml|yaml|toml|lock|map|woff2?|ttf|eot|ico|png|jpg|gif|webp)$/i;
8275
8338
  var SKIP_FILENAMES = /(?:package-lock\.json|pnpm-lock\.yaml|yarn\.lock|composer\.lock|Gemfile\.lock|Cargo\.lock|poetry\.lock|Pipfile\.lock|shrinkwrap\.json)$/i;
8276
- var SAFE_VAR_NAMES = /(?:description|message|text|label|title|content|template|html|svg|css|style|class(?:Name)?|query|mutation|schema|regex|pattern|format|placeholder|comment|url|path|route|endpoint|href|src|alt|name|type|version|encoding|charset|locale|translation|copy|prose|markdown|slug|handle)/i;
8339
+ var SAFE_VAR_NAMES = /(?:description|message|text|label|title|content|template|html|svg|css|style|class(?:Name)?|query|mutation|schema|regex|pattern|format|placeholder|comment|url|path|route|endpoint|href|src|alt|name|type|version|encoding|charset|chars|alphabet|locale|translation|copy|prose|markdown|slug|handle)/i;
8277
8340
  var HASH_LIKE_VAR_NAMES = /(?:^|[^a-z])(?:hash|digest|checksum|fingerprint|etag|crc|md5|sha1|sha256|sha512|contenthash|buildid|revision|commit|sri|integrity|cacheKey|fileHash|assetId|versionId)(?:$|[^a-z])/i;
8278
8341
  var HASH_PREFIX_RE = /^(?:sha\d+|md5|crc32|base64|bcrypt|argon2|pbkdf2|blake2b?)[:_]/i;
8279
8342
  var SECRET_VAR_NAMES = /(?:^|[^a-z])(?:key|secret|token|password|passwd|pwd|api[_-]?key|auth|credential|private|signing|bearer|access[_-]?token|refresh[_-]?token|session[_-]?id)(?:$|[^a-z])/i;
@@ -8307,6 +8370,7 @@ function scanEntropy(files) {
8307
8370
  let match;
8308
8371
  while ((match = stringPattern.exec(line)) !== null) {
8309
8372
  const value = match[2];
8373
+ if (value.includes("${")) continue;
8310
8374
  if (SAFE_PATTERNS.some((p) => p.test(value))) continue;
8311
8375
  if (HASH_PREFIX_RE.test(value)) continue;
8312
8376
  const beforeAssign = line.substring(0, match.index);