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 +90 -26
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +90 -26
- package/dist/index.js.map +1 -1
- package/package.json +5 -5
package/dist/index.js
CHANGED
|
@@ -626,6 +626,17 @@ function isInsideFixMessage(content, matchIndex) {
|
|
|
626
626
|
const lineText = content.substring(lineStart, content.indexOf("\n", matchIndex));
|
|
627
627
|
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);
|
|
628
628
|
}
|
|
629
|
+
function isInlineSilenced(content, matchIndex, ruleId) {
|
|
630
|
+
const lines = content.split("\n");
|
|
631
|
+
const matchLineNum = content.substring(0, matchIndex).split("\n").length - 1;
|
|
632
|
+
const matchLine = lines[matchLineNum] ?? "";
|
|
633
|
+
const prevLine = matchLineNum > 0 ? lines[matchLineNum - 1] ?? "" : "";
|
|
634
|
+
const marker = new RegExp(
|
|
635
|
+
`(?://|/\\*|#)\\s*(?:${ruleId}|scanner)-OK\\b`,
|
|
636
|
+
"i"
|
|
637
|
+
);
|
|
638
|
+
return marker.test(matchLine) || marker.test(prevLine);
|
|
639
|
+
}
|
|
629
640
|
function findMatches(content, pattern, rule, filePath, fixTemplate) {
|
|
630
641
|
const matches = [];
|
|
631
642
|
const lines = content.split("\n");
|
|
@@ -754,6 +765,16 @@ var hardcodedSecrets = {
|
|
|
754
765
|
const secretMatch = lineText.match(/[:=]\s*["'`]([^"'`]*)["'`]/);
|
|
755
766
|
if (secretMatch && secretMatch[1].length < floor) continue;
|
|
756
767
|
}
|
|
768
|
+
if (pi === 6) {
|
|
769
|
+
const valMatch = lineText.match(/[:=]\s*["'`]([^"'`]+)["'`]/);
|
|
770
|
+
const nameMatch = lineText.match(/\b([A-Z][A-Z0-9_]*_(?:SECRET|TOKEN|KEY|PASSWORD|PASSWD))\b/);
|
|
771
|
+
if (valMatch && nameMatch) {
|
|
772
|
+
const value = valMatch[1];
|
|
773
|
+
const isKeySuffix = nameMatch[1].endsWith("_KEY");
|
|
774
|
+
const isKebabIdentifier = value.length < 40 && /^[a-z0-9]+(-[a-z0-9]+)+$/.test(value);
|
|
775
|
+
if (isKeySuffix && isKebabIdentifier) continue;
|
|
776
|
+
}
|
|
777
|
+
}
|
|
757
778
|
matches.push(rm);
|
|
758
779
|
}
|
|
759
780
|
}
|
|
@@ -943,11 +964,19 @@ var sqlInjection = {
|
|
|
943
964
|
// The older `[^"']*` class bailed out at the first inner quote and
|
|
944
965
|
// missed every realistic SQL-containing concat.
|
|
945
966
|
/(?:query|execute|raw|sql|queryRaw|queryRawUnsafe|executeRaw|executeRawUnsafe)\s*\(\s*(?:"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')\s*\+/gi,
|
|
946
|
-
// Direct variable interpolation in a SQL
|
|
947
|
-
/
|
|
967
|
+
// Direct variable interpolation in a SQL string literal. Requires the
|
|
968
|
+
// verb to START a quoted/backtick literal AND be followed — within the
|
|
969
|
+
// same literal — by a SQL structural keyword (FROM/SET/WHERE/…), then a
|
|
970
|
+
// `${` interpolation. The structural keyword is what distinguishes a
|
|
971
|
+
// real query from ordinary prose: the previous pattern matched any
|
|
972
|
+
// SELECT/UPDATE/… word anywhere on a line followed by `${`, so UI copy
|
|
973
|
+
// like `UPDATE your profile settings ${name}` flagged as a CRITICAL SQL
|
|
974
|
+
// injection. Real queries ("SELECT … FROM …", "UPDATE … SET …") always
|
|
975
|
+
// carry a second structural keyword; sentences almost never do.
|
|
976
|
+
/["'`]\s*(?:SELECT|INSERT|UPDATE|DELETE)\b[^"'`\n]*\b(?:FROM|INTO|SET|VALUES|WHERE|JOIN|RETURNING)\b[^"'`\n]*\$\{(?!.*parameterized)/gi
|
|
948
977
|
];
|
|
949
978
|
const matches = [];
|
|
950
|
-
const usesParams = /\?\s
|
|
979
|
+
const usesParams = /\?\s*,|\?[^?,;]{1,20}\?|\$\d+|:[\w]+|\bprepare\b|\bplaceholders?\b/i.test(content);
|
|
951
980
|
if (usesParams) return [];
|
|
952
981
|
for (const pattern of patterns) {
|
|
953
982
|
const raw = findMatches(
|
|
@@ -990,6 +1019,7 @@ var xssVulnerability = {
|
|
|
990
1019
|
// {@html} in Svelte
|
|
991
1020
|
/\{@html\s/g
|
|
992
1021
|
];
|
|
1022
|
+
const allLines = content.split("\n");
|
|
993
1023
|
const matches = [];
|
|
994
1024
|
for (const pattern of patterns) {
|
|
995
1025
|
const raw = findMatches(
|
|
@@ -997,12 +1027,18 @@ var xssVulnerability = {
|
|
|
997
1027
|
pattern,
|
|
998
1028
|
xssVulnerability,
|
|
999
1029
|
filePath,
|
|
1000
|
-
() => "Sanitize user input before rendering as HTML. Use a library like DOMPurify: DOMPurify.sanitize(userInput)"
|
|
1030
|
+
() => "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."
|
|
1001
1031
|
);
|
|
1002
1032
|
for (const m of raw) {
|
|
1003
|
-
const lineText =
|
|
1033
|
+
const lineText = allLines[m.line - 1] || "";
|
|
1004
1034
|
if (/\.innerHTML\s*=\s*['"]/.test(lineText) && !/\$\{/.test(lineText)) continue;
|
|
1005
1035
|
if (/\.innerHTML\s*=\s*['"][^'"]*['"]\s*$/.test(lineText)) continue;
|
|
1036
|
+
if (/dangerouslySetInnerHTML/.test(lineText)) {
|
|
1037
|
+
const windowText = allLines.slice(m.line - 1, m.line + 2).join("\n");
|
|
1038
|
+
if (/JSON\.stringify/i.test(windowText)) continue;
|
|
1039
|
+
const lineStartIdx = allLines.slice(0, m.line - 1).reduce((acc, l) => acc + l.length + 1, 0);
|
|
1040
|
+
if (isInlineSilenced(content, lineStartIdx, "VC007")) continue;
|
|
1041
|
+
}
|
|
1006
1042
|
matches.push(m);
|
|
1007
1043
|
}
|
|
1008
1044
|
}
|
|
@@ -1278,23 +1314,35 @@ var unvalidatedRedirect = {
|
|
|
1278
1314
|
check(content, filePath) {
|
|
1279
1315
|
if (isTestFile(filePath)) return [];
|
|
1280
1316
|
if (/isAllowedRedirect|validateRedirect|isSafeRedirect|allowedDomains|trustedDomains|whitelist.*url|allowlist.*url/i.test(content)) return [];
|
|
1281
|
-
const patterns = [
|
|
1282
|
-
/window\.location\s*=\s*(?!["'`]https?:\/\/)/g,
|
|
1283
|
-
/window\.location\.href\s*=\s*(?!["'`]https?:\/\/)/g,
|
|
1284
|
-
/window\.location\.assign\s*\(\s*(?!["'`]https?:\/\/)/g,
|
|
1285
|
-
/window\.location\.replace\s*\(\s*(?!["'`]https?:\/\/)/g,
|
|
1286
|
-
/res\.redirect\s*\(\s*(?:req\.|params\.|query\.)/gi
|
|
1287
|
-
];
|
|
1288
1317
|
const matches = [];
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
)
|
|
1318
|
+
const navPattern = /(?:window\.location(?:\.href)?\s*=\s*|window\.location\.(?:assign|replace)\s*\(\s*)([^;\n)]+)/g;
|
|
1319
|
+
let m;
|
|
1320
|
+
while ((m = navPattern.exec(content)) !== null) {
|
|
1321
|
+
if (isCommentLine(content, m.index)) continue;
|
|
1322
|
+
const value = m[1].trim();
|
|
1323
|
+
const hasInterpolation = /\$\{/.test(value);
|
|
1324
|
+
const isPureLiteral = /^["'`][^"'`]*["'`]$/.test(value);
|
|
1325
|
+
if (!hasInterpolation && isPureLiteral) continue;
|
|
1326
|
+
if (isInlineSilenced(content, m.index, "VC016")) continue;
|
|
1327
|
+
const line = content.substring(0, m.index).split("\n").length;
|
|
1328
|
+
matches.push({
|
|
1329
|
+
rule: "VC016",
|
|
1330
|
+
title: unvalidatedRedirect.title,
|
|
1331
|
+
severity: "high",
|
|
1332
|
+
category: "Injection",
|
|
1333
|
+
file: filePath,
|
|
1334
|
+
line,
|
|
1335
|
+
snippet: getSnippet(content, line),
|
|
1336
|
+
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."
|
|
1337
|
+
});
|
|
1297
1338
|
}
|
|
1339
|
+
matches.push(...findMatches(
|
|
1340
|
+
content,
|
|
1341
|
+
/res\.redirect\s*\(\s*(?:req\.|params\.|query\.)/gi,
|
|
1342
|
+
unvalidatedRedirect,
|
|
1343
|
+
filePath,
|
|
1344
|
+
() => "Validate redirect URLs against an allowlist of trusted domains. Never redirect to user-supplied URLs directly."
|
|
1345
|
+
));
|
|
1298
1346
|
return matches;
|
|
1299
1347
|
}
|
|
1300
1348
|
};
|
|
@@ -3038,6 +3086,7 @@ var dangerousInnerHTML = {
|
|
|
3038
3086
|
let m;
|
|
3039
3087
|
while ((m = re.exec(content)) !== null) {
|
|
3040
3088
|
if (isCommentLine(content, m.index)) continue;
|
|
3089
|
+
if (isInlineSilenced(content, m.index, "VC063")) continue;
|
|
3041
3090
|
const value = m[1].trim();
|
|
3042
3091
|
if (/^[A-Z][A-Z0-9_]+$/.test(value)) continue;
|
|
3043
3092
|
if (/^["'`][^$]*["'`]$/.test(value)) continue;
|
|
@@ -3051,7 +3100,7 @@ var dangerousInnerHTML = {
|
|
|
3051
3100
|
file: filePath,
|
|
3052
3101
|
line: lineNum,
|
|
3053
3102
|
snippet: getSnippet(content, lineNum),
|
|
3054
|
-
fix: "Sanitize HTML before using dangerouslySetInnerHTML: dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(content) }}. Install: npm install dompurify"
|
|
3103
|
+
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."
|
|
3055
3104
|
});
|
|
3056
3105
|
}
|
|
3057
3106
|
return findings;
|
|
@@ -7053,6 +7102,7 @@ var secretInURLParam = {
|
|
|
7053
7102
|
while ((m = re.exec(content)) !== null) {
|
|
7054
7103
|
if (isCommentLine(content, m.index)) continue;
|
|
7055
7104
|
if (isInsideFixMessage(content, m.index)) continue;
|
|
7105
|
+
if (isInlineSilenced(content, m.index, "VC146")) continue;
|
|
7056
7106
|
const lineNum = content.substring(0, m.index).split("\n").length;
|
|
7057
7107
|
findings.push({
|
|
7058
7108
|
rule: "VC146",
|
|
@@ -7062,7 +7112,7 @@ var secretInURLParam = {
|
|
|
7062
7112
|
file: filePath,
|
|
7063
7113
|
line: lineNum,
|
|
7064
7114
|
snippet: getSnippet(content, lineNum),
|
|
7065
|
-
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."
|
|
7115
|
+
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."
|
|
7066
7116
|
});
|
|
7067
7117
|
}
|
|
7068
7118
|
}
|
|
@@ -7253,10 +7303,14 @@ var webhookSignatureVerification = {
|
|
|
7253
7303
|
check(content, filePath) {
|
|
7254
7304
|
if (!filePath.match(/\.(js|ts|jsx|tsx)$/)) return [];
|
|
7255
7305
|
if (isTestFile(filePath)) return [];
|
|
7256
|
-
if (
|
|
7306
|
+
if (!/webhook/i.test(filePath)) return [];
|
|
7257
7307
|
if (!/export\s+(?:async\s+)?function\s+POST/i.test(content)) return [];
|
|
7258
7308
|
const services = [
|
|
7259
|
-
|
|
7309
|
+
// Tightened Clerk events: require the specific subevent suffix
|
|
7310
|
+
// rather than bare `session.`. The bare-prefix version matched
|
|
7311
|
+
// Stripe Checkout `session.url`, which has nothing to do with
|
|
7312
|
+
// Clerk webhooks.
|
|
7313
|
+
{ 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 },
|
|
7260
7314
|
{ name: "GitHub", content: /github/i, events: /push|pull_request|issues|installation/i, verify: /createHmac|x-hub-signature|verify.*signature|GITHUB_WEBHOOK_SECRET/i },
|
|
7261
7315
|
{ name: "Resend", content: /resend/i, events: /email\.sent|email\.delivered|email\.bounced/i, verify: /svix|webhook.*verify|x-webhook-signature|RESEND_WEBHOOK_SECRET/i },
|
|
7262
7316
|
{ name: "SendGrid", content: /sendgrid/i, events: /inbound.*parse|event.*webhook/i, verify: /EventWebhook|x-twilio-email|verifySignature|SENDGRID_WEBHOOK_VERIFICATION/i },
|
|
@@ -8008,11 +8062,20 @@ var SAFE_PATTERNS = [
|
|
|
8008
8062
|
/^'sha\d+-/,
|
|
8009
8063
|
/^nonce-/i,
|
|
8010
8064
|
// Embedded data URIs
|
|
8011
|
-
/^data:[a-z]+\//i
|
|
8065
|
+
/^data:[a-z]+\//i,
|
|
8066
|
+
// Sentry DSN — a publishable, client-side ingest key (it ships to the
|
|
8067
|
+
// browser), not a secret. Shape: https://<hash>@o<orgid>.ingest.<region.>
|
|
8068
|
+
// sentry.io/<projectid>. The generic URL allowlist above deliberately
|
|
8069
|
+
// excludes anything with an `@`, so the DSN would otherwise fire.
|
|
8070
|
+
/^https?:\/\/[0-9a-f]+@o\d+\.ingest\.[a-z0-9.]*sentry\.io\//i,
|
|
8071
|
+
// HTTP security-header directive values (HSTS, etc.) — `max-age=63072000;
|
|
8072
|
+
// includeSubDomains; preload`. Config strings a security scanner actively
|
|
8073
|
+
// tells users to add; never secrets.
|
|
8074
|
+
/^max-age=\d+/i
|
|
8012
8075
|
];
|
|
8013
8076
|
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;
|
|
8014
8077
|
var SKIP_FILENAMES = /(?:package-lock\.json|pnpm-lock\.yaml|yarn\.lock|composer\.lock|Gemfile\.lock|Cargo\.lock|poetry\.lock|Pipfile\.lock|shrinkwrap\.json)$/i;
|
|
8015
|
-
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;
|
|
8078
|
+
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;
|
|
8016
8079
|
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;
|
|
8017
8080
|
var HASH_PREFIX_RE = /^(?:sha\d+|md5|crc32|base64|bcrypt|argon2|pbkdf2|blake2b?)[:_]/i;
|
|
8018
8081
|
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;
|
|
@@ -8046,6 +8109,7 @@ function scanEntropy(files) {
|
|
|
8046
8109
|
let match;
|
|
8047
8110
|
while ((match = stringPattern.exec(line)) !== null) {
|
|
8048
8111
|
const value = match[2];
|
|
8112
|
+
if (value.includes("${")) continue;
|
|
8049
8113
|
if (SAFE_PATTERNS.some((p) => p.test(value))) continue;
|
|
8050
8114
|
if (HASH_PREFIX_RE.test(value)) continue;
|
|
8051
8115
|
const beforeAssign = line.substring(0, match.index);
|