xploitscan-shared-rules 1.2.1 → 1.3.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/LICENSE +1 -1
- package/README.md +58 -0
- package/dist/index.cjs +2837 -313
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +163 -1
- package/dist/index.d.ts +163 -1
- package/dist/index.js +2781 -313
- package/dist/index.js.map +1 -1
- package/package.json +10 -3
package/dist/index.cjs
CHANGED
|
@@ -34,7 +34,9 @@ __export(index_exports, {
|
|
|
34
34
|
allRules: () => allRules,
|
|
35
35
|
androidDebuggable: () => androidDebuggable,
|
|
36
36
|
blockingMainThread: () => blockingMainThread,
|
|
37
|
+
buildTaintMap: () => buildTaintMap,
|
|
37
38
|
calculateGrade: () => calculateGrade,
|
|
39
|
+
callSpreads: () => callSpreads,
|
|
38
40
|
callbackHell: () => callbackHell,
|
|
39
41
|
clickjacking: () => clickjacking,
|
|
40
42
|
clientComponentSecret: () => clientComponentSecret,
|
|
@@ -54,6 +56,9 @@ __export(index_exports, {
|
|
|
54
56
|
dockerLatestTag: () => dockerLatestTag,
|
|
55
57
|
dockerRunAsRoot: () => dockerRunAsRoot,
|
|
56
58
|
dockerTooManyPorts: () => dockerTooManyPorts,
|
|
59
|
+
dockerfileADDInsteadOfCOPY: () => dockerfileADDInsteadOfCOPY,
|
|
60
|
+
dockerfileMissingHealthcheck: () => dockerfileMissingHealthcheck,
|
|
61
|
+
dockerfileUnverifiedShellPipe: () => dockerfileUnverifiedShellPipe,
|
|
57
62
|
ecbModeEncryption: () => ecbModeEncryption,
|
|
58
63
|
electronNavigationUnrestricted: () => electronNavigationUnrestricted,
|
|
59
64
|
emptyCatchBlock: () => emptyCatchBlock,
|
|
@@ -75,28 +80,61 @@ __export(index_exports, {
|
|
|
75
80
|
firebaseClientConfig: () => firebaseClientConfig,
|
|
76
81
|
flaskSecretKey: () => flaskSecretKey,
|
|
77
82
|
freeRules: () => freeRules,
|
|
83
|
+
getObjectProperty: () => getObjectProperty,
|
|
78
84
|
getSnippet: () => getSnippet,
|
|
85
|
+
ghaExpressionInjection: () => ghaExpressionInjection,
|
|
86
|
+
ghaPermissionsWriteAll: () => ghaPermissionsWriteAll,
|
|
87
|
+
ghaPullRequestTargetCheckout: () => ghaPullRequestTargetCheckout,
|
|
88
|
+
ghaThirdPartyActionWithSecrets: () => ghaThirdPartyActionWithSecrets,
|
|
79
89
|
githubActionsInjection: () => githubActionsInjection,
|
|
90
|
+
graphqlCSRFDisabled: () => graphqlCSRFDisabled,
|
|
80
91
|
graphqlIntrospection: () => graphqlIntrospection,
|
|
92
|
+
graphqlNoComplexityLimit: () => graphqlNoComplexityLimit,
|
|
93
|
+
graphqlNoDepthLimit: () => graphqlNoDepthLimit,
|
|
94
|
+
hardcodedAlgoliaAdminKey: () => hardcodedAlgoliaAdminKey,
|
|
81
95
|
hardcodedAnthropicKey: () => hardcodedAnthropicKey,
|
|
96
|
+
hardcodedCloudflareToken: () => hardcodedCloudflareToken,
|
|
97
|
+
hardcodedCohereKey: () => hardcodedCohereKey,
|
|
82
98
|
hardcodedDatadogKey: () => hardcodedDatadogKey,
|
|
99
|
+
hardcodedDiscordToken: () => hardcodedDiscordToken,
|
|
83
100
|
hardcodedEncryptionKey: () => hardcodedEncryptionKey,
|
|
101
|
+
hardcodedFastlyToken: () => hardcodedFastlyToken,
|
|
102
|
+
hardcodedFireworksKey: () => hardcodedFireworksKey,
|
|
103
|
+
hardcodedFlyToken: () => hardcodedFlyToken,
|
|
84
104
|
hardcodedGCPServiceAccount: () => hardcodedGCPServiceAccount,
|
|
85
105
|
hardcodedGitHubPAT: () => hardcodedGitHubPAT,
|
|
86
106
|
hardcodedGitLabToken: () => hardcodedGitLabToken,
|
|
107
|
+
hardcodedGroqKey: () => hardcodedGroqKey,
|
|
108
|
+
hardcodedHighlightKey: () => hardcodedHighlightKey,
|
|
87
109
|
hardcodedIPAllowlist: () => hardcodedIPAllowlist,
|
|
110
|
+
hardcodedIntercomToken: () => hardcodedIntercomToken,
|
|
88
111
|
hardcodedJWTSecret: () => hardcodedJWTSecret,
|
|
112
|
+
hardcodedLinearKey: () => hardcodedLinearKey,
|
|
113
|
+
hardcodedLogtailToken: () => hardcodedLogtailToken,
|
|
114
|
+
hardcodedLoopsKey: () => hardcodedLoopsKey,
|
|
89
115
|
hardcodedMailgunKey: () => hardcodedMailgunKey,
|
|
116
|
+
hardcodedMistralKey: () => hardcodedMistralKey,
|
|
117
|
+
hardcodedNetlifyToken: () => hardcodedNetlifyToken,
|
|
118
|
+
hardcodedNotionKey: () => hardcodedNotionKey,
|
|
90
119
|
hardcodedOAuthSecret: () => hardcodedOAuthSecret,
|
|
91
120
|
hardcodedPineconeKey: () => hardcodedPineconeKey,
|
|
121
|
+
hardcodedPlivoToken: () => hardcodedPlivoToken,
|
|
122
|
+
hardcodedPostmarkKey: () => hardcodedPostmarkKey,
|
|
123
|
+
hardcodedQdrantKey: () => hardcodedQdrantKey,
|
|
124
|
+
hardcodedRailwayToken: () => hardcodedRailwayToken,
|
|
125
|
+
hardcodedReplicateKey: () => hardcodedReplicateKey,
|
|
126
|
+
hardcodedResendKey: () => hardcodedResendKey,
|
|
92
127
|
hardcodedSecrets: () => hardcodedSecrets,
|
|
93
128
|
hardcodedSendGridKey: () => hardcodedSendGridKey,
|
|
129
|
+
hardcodedSentryAuthToken: () => hardcodedSentryAuthToken,
|
|
94
130
|
hardcodedShopifyToken: () => hardcodedShopifyToken,
|
|
95
131
|
hardcodedSlackToken: () => hardcodedSlackToken,
|
|
96
132
|
hardcodedSupabaseServiceRole: () => hardcodedSupabaseServiceRole,
|
|
133
|
+
hardcodedTogetherKey: () => hardcodedTogetherKey,
|
|
97
134
|
hardcodedTwilioKey: () => hardcodedTwilioKey,
|
|
98
135
|
hardcodedVaultToken: () => hardcodedVaultToken,
|
|
99
136
|
hardcodedVercelToken: () => hardcodedVercelToken,
|
|
137
|
+
hardcodedWeaviateKey: () => hardcodedWeaviateKey,
|
|
100
138
|
hostHeaderRedirect: () => hostHeaderRedirect,
|
|
101
139
|
httpRequestSmuggling: () => httpRequestSmuggling,
|
|
102
140
|
insecureCookies: () => insecureCookies,
|
|
@@ -111,6 +149,8 @@ __export(index_exports, {
|
|
|
111
149
|
insecureRandomness: () => insecureRandomness,
|
|
112
150
|
insecureWebSocket: () => insecureWebSocket,
|
|
113
151
|
ipcPathTraversal: () => ipcPathTraversal,
|
|
152
|
+
isCalleeNamed: () => isCalleeNamed,
|
|
153
|
+
isMethodCall: () => isMethodCall,
|
|
114
154
|
javaDeserialization: () => javaDeserialization,
|
|
115
155
|
jwtAlgConfusion: () => jwtAlgConfusion,
|
|
116
156
|
k8sNoResourceLimits: () => k8sNoResourceLimits,
|
|
@@ -118,6 +158,10 @@ __export(index_exports, {
|
|
|
118
158
|
k8sSecretNotEncrypted: () => k8sSecretNotEncrypted,
|
|
119
159
|
lambdaWithoutVPC: () => lambdaWithoutVPC,
|
|
120
160
|
largeBundleImport: () => largeBundleImport,
|
|
161
|
+
llmCallNoMaxTokens: () => llmCallNoMaxTokens,
|
|
162
|
+
llmOutputAsHTML: () => llmOutputAsHTML,
|
|
163
|
+
llmPromptInjection: () => llmPromptInjection,
|
|
164
|
+
llmSystemPromptInjection: () => llmSystemPromptInjection,
|
|
121
165
|
logInjection: () => logInjection,
|
|
122
166
|
magicNumbers: () => magicNumbers,
|
|
123
167
|
massAssignment: () => massAssignment,
|
|
@@ -148,10 +192,18 @@ __export(index_exports, {
|
|
|
148
192
|
nosqlInjection: () => nosqlInjection,
|
|
149
193
|
openRedirectParams: () => openRedirectParams,
|
|
150
194
|
overlyPermissiveIAM: () => overlyPermissiveIAM,
|
|
195
|
+
parseFile: () => parseFile,
|
|
151
196
|
pathTraversal: () => pathTraversal,
|
|
152
197
|
pickleDeserialization: () => pickleDeserialization,
|
|
153
198
|
piiInLogs: () => piiInLogs,
|
|
154
199
|
prototypePollution: () => prototypePollution,
|
|
200
|
+
pyDjangoAllowedHostsWildcard: () => pyDjangoAllowedHostsWildcard,
|
|
201
|
+
pyDjangoMarkSafe: () => pyDjangoMarkSafe,
|
|
202
|
+
pyJWTDecodeWeakConfig: () => pyJWTDecodeWeakConfig,
|
|
203
|
+
pyJinja2AutoescapeOff: () => pyJinja2AutoescapeOff,
|
|
204
|
+
pyParamikoAutoAdd: () => pyParamikoAutoAdd,
|
|
205
|
+
pyRequestsVerifyFalse: () => pyRequestsVerifyFalse,
|
|
206
|
+
pyTempfileMktemp: () => pyTempfileMktemp,
|
|
155
207
|
raceCondition: () => raceCondition,
|
|
156
208
|
rdsPubliclyAccessible: () => rdsPubliclyAccessible,
|
|
157
209
|
reflectedCORSOrigin: () => reflectedCORSOrigin,
|
|
@@ -191,6 +243,10 @@ __export(index_exports, {
|
|
|
191
243
|
unvalidatedAPIParams: () => unvalidatedAPIParams,
|
|
192
244
|
unvalidatedEventData: () => unvalidatedEventData,
|
|
193
245
|
unvalidatedRedirect: () => unvalidatedRedirect,
|
|
246
|
+
vectorStoreQueryNoUserFilter: () => vectorStoreQueryNoUserFilter,
|
|
247
|
+
vectorStoreUpsertNoMetadata: () => vectorStoreUpsertNoMetadata,
|
|
248
|
+
visitBinary: () => visitBinary,
|
|
249
|
+
visitCalls: () => visitCalls,
|
|
194
250
|
vulnerableDependencies: () => vulnerableDependencies,
|
|
195
251
|
weakHashing: () => weakHashing,
|
|
196
252
|
weakPasswordRequirements: () => weakPasswordRequirements,
|
|
@@ -213,11 +269,381 @@ function getSnippet(content, line, contextLines = 2) {
|
|
|
213
269
|
}).join("\n");
|
|
214
270
|
}
|
|
215
271
|
|
|
272
|
+
// src/ast/parse.ts
|
|
273
|
+
var import_parser = require("@babel/parser");
|
|
274
|
+
var MAX_CACHE = 256;
|
|
275
|
+
var cache = /* @__PURE__ */ new Map();
|
|
276
|
+
function cacheKey(filename, contentHash) {
|
|
277
|
+
return `${filename}:${contentHash}`;
|
|
278
|
+
}
|
|
279
|
+
function quickHash(s) {
|
|
280
|
+
let h = 5381;
|
|
281
|
+
const step = Math.max(1, Math.floor(s.length / 32));
|
|
282
|
+
for (let i = 0; i < s.length; i += step) {
|
|
283
|
+
h = (h << 5) + h + s.charCodeAt(i) | 0;
|
|
284
|
+
}
|
|
285
|
+
return h * 31 + s.length | 0;
|
|
286
|
+
}
|
|
287
|
+
function pickLanguage(filename) {
|
|
288
|
+
if (/\.tsx$/i.test(filename)) return "tsx";
|
|
289
|
+
if (/\.jsx$/i.test(filename)) return "jsx";
|
|
290
|
+
if (/\.(ts|cts|mts)$/i.test(filename)) return "ts";
|
|
291
|
+
if (/\.(js|cjs|mjs)$/i.test(filename)) return "js";
|
|
292
|
+
return null;
|
|
293
|
+
}
|
|
294
|
+
function parseFile(content, filename) {
|
|
295
|
+
const lang = pickLanguage(filename);
|
|
296
|
+
if (!lang) return null;
|
|
297
|
+
if (!content || content.length === 0) return null;
|
|
298
|
+
const key = cacheKey(filename, quickHash(content));
|
|
299
|
+
if (cache.has(key)) return cache.get(key) ?? null;
|
|
300
|
+
const plugins = [];
|
|
301
|
+
if (lang === "ts" || lang === "tsx") plugins.push("typescript");
|
|
302
|
+
if (lang === "jsx" || lang === "tsx") plugins.push("jsx");
|
|
303
|
+
plugins.push("decorators-legacy", "classProperties", "dynamicImport", "topLevelAwait");
|
|
304
|
+
let ast = null;
|
|
305
|
+
try {
|
|
306
|
+
ast = (0, import_parser.parse)(content, {
|
|
307
|
+
sourceType: "unambiguous",
|
|
308
|
+
allowImportExportEverywhere: true,
|
|
309
|
+
allowReturnOutsideFunction: true,
|
|
310
|
+
allowAwaitOutsideFunction: true,
|
|
311
|
+
allowUndeclaredExports: true,
|
|
312
|
+
errorRecovery: true,
|
|
313
|
+
plugins
|
|
314
|
+
});
|
|
315
|
+
} catch {
|
|
316
|
+
cache.set(key, null);
|
|
317
|
+
if (cache.size > MAX_CACHE) {
|
|
318
|
+
const firstKey = cache.keys().next().value;
|
|
319
|
+
if (firstKey !== void 0) cache.delete(firstKey);
|
|
320
|
+
}
|
|
321
|
+
return null;
|
|
322
|
+
}
|
|
323
|
+
const entry = { ast, language: lang };
|
|
324
|
+
cache.set(key, entry);
|
|
325
|
+
if (cache.size > MAX_CACHE) {
|
|
326
|
+
const firstKey = cache.keys().next().value;
|
|
327
|
+
if (firstKey !== void 0) cache.delete(firstKey);
|
|
328
|
+
}
|
|
329
|
+
return entry;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// src/ast/taint.ts
|
|
333
|
+
var import_traverse = __toESM(require("@babel/traverse"), 1);
|
|
334
|
+
var traverse = typeof import_traverse.default === "function" ? import_traverse.default : import_traverse.default.default;
|
|
335
|
+
var TAINTED_PROP_SUFFIXES = /* @__PURE__ */ new Set([
|
|
336
|
+
"body",
|
|
337
|
+
"query",
|
|
338
|
+
"params",
|
|
339
|
+
"headers",
|
|
340
|
+
"cookies",
|
|
341
|
+
"queryStringParameters",
|
|
342
|
+
"pathParameters",
|
|
343
|
+
"rawBody",
|
|
344
|
+
"searchParams"
|
|
345
|
+
]);
|
|
346
|
+
var TAINTED_REQUEST_OBJECTS = /* @__PURE__ */ new Set([
|
|
347
|
+
"req",
|
|
348
|
+
"request",
|
|
349
|
+
"ctx",
|
|
350
|
+
// Koa
|
|
351
|
+
"context",
|
|
352
|
+
// Koa alt
|
|
353
|
+
"event"
|
|
354
|
+
// Lambda
|
|
355
|
+
]);
|
|
356
|
+
function buildTaintMap(parsed) {
|
|
357
|
+
const tainted = /* @__PURE__ */ new Set();
|
|
358
|
+
function reachesRequestIdent(node) {
|
|
359
|
+
if (!node) return false;
|
|
360
|
+
if (node.type === "Identifier") return TAINTED_REQUEST_OBJECTS.has(node.name);
|
|
361
|
+
if (node.type === "MemberExpression") {
|
|
362
|
+
if (node.property.type === "Identifier" && node.property.name === "request" && node.object.type === "Identifier" && TAINTED_REQUEST_OBJECTS.has(node.object.name)) {
|
|
363
|
+
return true;
|
|
364
|
+
}
|
|
365
|
+
return reachesRequestIdent(node.object);
|
|
366
|
+
}
|
|
367
|
+
return false;
|
|
368
|
+
}
|
|
369
|
+
function nodeIsTaintedSource(node) {
|
|
370
|
+
if (!node) return false;
|
|
371
|
+
if (node.type === "MemberExpression") {
|
|
372
|
+
const prop = node.property;
|
|
373
|
+
const propName = prop.type === "Identifier" ? prop.name : prop.type === "StringLiteral" ? prop.value : "";
|
|
374
|
+
if (TAINTED_PROP_SUFFIXES.has(propName)) {
|
|
375
|
+
const obj = node.object;
|
|
376
|
+
if (obj.type === "Identifier" && TAINTED_REQUEST_OBJECTS.has(obj.name)) {
|
|
377
|
+
return true;
|
|
378
|
+
}
|
|
379
|
+
if (reachesRequestIdent(obj)) return true;
|
|
380
|
+
if (nodeIsTaintedSource(obj)) return true;
|
|
381
|
+
}
|
|
382
|
+
if (node.object.type === "Identifier" && node.object.name === "process" && prop.type === "Identifier" && prop.name === "argv") {
|
|
383
|
+
return true;
|
|
384
|
+
}
|
|
385
|
+
if (nodeIsTaintedSource(node.object)) return true;
|
|
386
|
+
return false;
|
|
387
|
+
}
|
|
388
|
+
if (node.type === "CallExpression") {
|
|
389
|
+
const callee = node.callee;
|
|
390
|
+
if (callee.type === "MemberExpression" && callee.property.type === "Identifier" && callee.property.name === "get" && nodeIsTaintedSource(callee.object)) {
|
|
391
|
+
return true;
|
|
392
|
+
}
|
|
393
|
+
const BODY_READERS = /* @__PURE__ */ new Set(["json", "formData", "text", "arrayBuffer", "blob"]);
|
|
394
|
+
if (callee.type === "MemberExpression" && callee.property.type === "Identifier" && BODY_READERS.has(callee.property.name)) {
|
|
395
|
+
const obj = callee.object;
|
|
396
|
+
if (obj.type === "Identifier" && TAINTED_REQUEST_OBJECTS.has(obj.name)) {
|
|
397
|
+
return true;
|
|
398
|
+
}
|
|
399
|
+
if (reachesRequestIdent(obj)) return true;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
if (node.type === "AwaitExpression") {
|
|
403
|
+
return nodeIsTaintedSource(node.argument);
|
|
404
|
+
}
|
|
405
|
+
return false;
|
|
406
|
+
}
|
|
407
|
+
function exprIsTainted(node) {
|
|
408
|
+
if (!node) return false;
|
|
409
|
+
if (nodeIsTaintedSource(node)) return true;
|
|
410
|
+
if (node.type === "Identifier") return tainted.has(node.name);
|
|
411
|
+
if (node.type === "TemplateLiteral") {
|
|
412
|
+
return node.expressions.some((e) => exprIsTainted(e));
|
|
413
|
+
}
|
|
414
|
+
if (node.type === "BinaryExpression" && node.operator === "+") {
|
|
415
|
+
return exprIsTainted(node.left) || exprIsTainted(node.right);
|
|
416
|
+
}
|
|
417
|
+
if (node.type === "LogicalExpression" && (node.operator === "||" || node.operator === "??")) {
|
|
418
|
+
return exprIsTainted(node.left) || exprIsTainted(node.right);
|
|
419
|
+
}
|
|
420
|
+
if (node.type === "ConditionalExpression") {
|
|
421
|
+
return exprIsTainted(node.consequent) || exprIsTainted(node.alternate);
|
|
422
|
+
}
|
|
423
|
+
if (node.type === "MemberExpression") {
|
|
424
|
+
return exprIsTainted(node.object);
|
|
425
|
+
}
|
|
426
|
+
if (node.type === "CallExpression") {
|
|
427
|
+
if (nodeIsTaintedSource(node)) return true;
|
|
428
|
+
if (node.callee.type === "MemberExpression") {
|
|
429
|
+
if (exprIsTainted(node.callee.object)) return true;
|
|
430
|
+
const obj = node.callee.object;
|
|
431
|
+
const prop = node.callee.property;
|
|
432
|
+
if (prop.type === "Identifier" && obj.type === "Identifier" && obj.name === "path" && ["join", "resolve", "normalize", "format", "parse", "relative"].includes(prop.name)) {
|
|
433
|
+
return node.arguments.some((a) => a.type !== "SpreadElement" && exprIsTainted(a));
|
|
434
|
+
}
|
|
435
|
+
if (prop.type === "Identifier" && obj.type === "Identifier" && obj.name === "Buffer" && ["from", "concat", "alloc", "allocUnsafe"].includes(prop.name)) {
|
|
436
|
+
return node.arguments.some((a) => a.type !== "SpreadElement" && exprIsTainted(a));
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
if (node.callee.type === "Identifier") {
|
|
440
|
+
if (["String", "Number", "Boolean", "URL", "URLSearchParams"].includes(node.callee.name)) {
|
|
441
|
+
return node.arguments.some((a) => a.type !== "SpreadElement" && exprIsTainted(a));
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
if (node.type === "AwaitExpression") {
|
|
446
|
+
return exprIsTainted(node.argument);
|
|
447
|
+
}
|
|
448
|
+
return false;
|
|
449
|
+
}
|
|
450
|
+
traverse(parsed.ast, {
|
|
451
|
+
VariableDeclarator(path) {
|
|
452
|
+
const node = path.node;
|
|
453
|
+
if (node.type !== "VariableDeclarator") return;
|
|
454
|
+
const init = node.init;
|
|
455
|
+
if (!init) return;
|
|
456
|
+
if (node.id.type === "Identifier") {
|
|
457
|
+
if (exprIsTainted(init)) {
|
|
458
|
+
tainted.add(node.id.name);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
if (node.id.type === "ObjectPattern") {
|
|
462
|
+
const isTainted2 = nodeIsTaintedSource(init) || init.type === "Identifier" && tainted.has(init.name);
|
|
463
|
+
if (isTainted2) {
|
|
464
|
+
for (const prop of node.id.properties) {
|
|
465
|
+
if (prop.type === "ObjectProperty") {
|
|
466
|
+
if (prop.value.type === "Identifier") tainted.add(prop.value.name);
|
|
467
|
+
} else if (prop.type === "RestElement") {
|
|
468
|
+
if (prop.argument.type === "Identifier") tainted.add(prop.argument.name);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
},
|
|
474
|
+
AssignmentExpression(path) {
|
|
475
|
+
const node = path.node;
|
|
476
|
+
if (node.type !== "AssignmentExpression") return;
|
|
477
|
+
if (node.operator !== "=" && node.operator !== "||=" && node.operator !== "??=") return;
|
|
478
|
+
if (node.left.type !== "Identifier") return;
|
|
479
|
+
if (exprIsTainted(node.right)) {
|
|
480
|
+
tainted.add(node.left.name);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
});
|
|
484
|
+
const isTainted = (node) => {
|
|
485
|
+
if (!node) return false;
|
|
486
|
+
if (node.type === "Identifier") return tainted.has(node.name);
|
|
487
|
+
if (nodeIsTaintedSource(node)) return true;
|
|
488
|
+
if (node.type === "TemplateLiteral") {
|
|
489
|
+
return node.expressions.some((e) => isTainted(e));
|
|
490
|
+
}
|
|
491
|
+
if (node.type === "BinaryExpression" && node.operator === "+") {
|
|
492
|
+
return isTainted(node.left) || isTainted(node.right);
|
|
493
|
+
}
|
|
494
|
+
if (node.type === "LogicalExpression" && (node.operator === "||" || node.operator === "??" || node.operator === "&&")) {
|
|
495
|
+
return isTainted(node.left) || isTainted(node.right);
|
|
496
|
+
}
|
|
497
|
+
if (node.type === "ConditionalExpression") {
|
|
498
|
+
return isTainted(node.consequent) || isTainted(node.alternate);
|
|
499
|
+
}
|
|
500
|
+
if (node.type === "AwaitExpression") {
|
|
501
|
+
return isTainted(node.argument);
|
|
502
|
+
}
|
|
503
|
+
if (node.type === "MemberExpression") {
|
|
504
|
+
return isTainted(node.object);
|
|
505
|
+
}
|
|
506
|
+
if (node.type === "CallExpression") {
|
|
507
|
+
if (node.callee.type === "MemberExpression") {
|
|
508
|
+
if (isTainted(node.callee.object)) return true;
|
|
509
|
+
const obj = node.callee.object;
|
|
510
|
+
const prop = node.callee.property;
|
|
511
|
+
if (prop.type === "Identifier" && obj.type === "Identifier" && obj.name === "path" && ["join", "resolve", "normalize", "format", "relative", "parse"].includes(prop.name)) {
|
|
512
|
+
return node.arguments.some((a) => a.type !== "SpreadElement" && isTainted(a));
|
|
513
|
+
}
|
|
514
|
+
if (prop.type === "Identifier" && obj.type === "Identifier" && obj.name === "Buffer" && ["from", "concat", "alloc", "allocUnsafe"].includes(prop.name)) {
|
|
515
|
+
return node.arguments.some((a) => a.type !== "SpreadElement" && isTainted(a));
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
if (node.callee.type === "Identifier") {
|
|
519
|
+
if (["String", "Number", "Boolean", "URL", "URLSearchParams"].includes(node.callee.name)) {
|
|
520
|
+
return node.arguments.some((a) => a.type !== "SpreadElement" && isTainted(a));
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
return false;
|
|
525
|
+
};
|
|
526
|
+
return {
|
|
527
|
+
isTainted,
|
|
528
|
+
isTaintedIdent: (name) => tainted.has(name),
|
|
529
|
+
taintedNames: () => Array.from(tainted)
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// src/ast/traverse.ts
|
|
534
|
+
var import_traverse2 = __toESM(require("@babel/traverse"), 1);
|
|
535
|
+
var traverse2 = typeof import_traverse2.default === "function" ? import_traverse2.default : import_traverse2.default.default;
|
|
536
|
+
function visitBinary(parsed, visit) {
|
|
537
|
+
traverse2(parsed.ast, {
|
|
538
|
+
BinaryExpression(path) {
|
|
539
|
+
const line = path.node.loc?.start.line ?? 1;
|
|
540
|
+
visit(path.node, line);
|
|
541
|
+
}
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
function visitCalls(parsed, matchCallee, visit) {
|
|
545
|
+
traverse2(parsed.ast, {
|
|
546
|
+
CallExpression(path) {
|
|
547
|
+
const node = path.node;
|
|
548
|
+
if (!matchCallee(node.callee)) return;
|
|
549
|
+
const line = node.loc?.start.line ?? 1;
|
|
550
|
+
visit(node, line);
|
|
551
|
+
}
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
function isCalleeNamed(callee, name) {
|
|
555
|
+
if (callee.type === "Identifier") return callee.name === name;
|
|
556
|
+
if (callee.type === "MemberExpression" && callee.property.type === "Identifier") {
|
|
557
|
+
return callee.property.name === name;
|
|
558
|
+
}
|
|
559
|
+
if (callee.type === "OptionalMemberExpression" && callee.property.type === "Identifier") {
|
|
560
|
+
return callee.property.name === name;
|
|
561
|
+
}
|
|
562
|
+
return false;
|
|
563
|
+
}
|
|
564
|
+
function isMethodCall(callee, objName, methodName) {
|
|
565
|
+
if (callee.type !== "MemberExpression" && callee.type !== "OptionalMemberExpression") {
|
|
566
|
+
return false;
|
|
567
|
+
}
|
|
568
|
+
if (callee.property.type !== "Identifier") return false;
|
|
569
|
+
if (callee.property.name !== methodName) return false;
|
|
570
|
+
if (callee.object.type !== "Identifier") return false;
|
|
571
|
+
return callee.object.name === objName;
|
|
572
|
+
}
|
|
573
|
+
function getObjectProperty(node, key) {
|
|
574
|
+
for (const prop of node.properties) {
|
|
575
|
+
if (prop.type === "ObjectProperty") {
|
|
576
|
+
if (prop.key.type === "Identifier" && prop.key.name === key) {
|
|
577
|
+
return { value: prop.value };
|
|
578
|
+
}
|
|
579
|
+
if (prop.key.type === "StringLiteral" && prop.key.value === key) {
|
|
580
|
+
return { value: prop.value };
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
return null;
|
|
585
|
+
}
|
|
586
|
+
function callSpreads(call, matcher) {
|
|
587
|
+
for (const arg of call.arguments) {
|
|
588
|
+
if (arg.type === "SpreadElement") {
|
|
589
|
+
if (matcher(arg.argument)) return true;
|
|
590
|
+
}
|
|
591
|
+
if (arg.type === "ObjectExpression") {
|
|
592
|
+
for (const prop of arg.properties) {
|
|
593
|
+
if (prop.type === "SpreadElement") {
|
|
594
|
+
if (matcher(prop.argument)) return true;
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
return false;
|
|
600
|
+
}
|
|
601
|
+
|
|
216
602
|
// src/rules.ts
|
|
217
603
|
var TEST_FILE_PATTERN = /(?:\.test\.|\.spec\.|__tests__|__mocks__|\.stories\.|\.story\.|\/test\/|\/tests\/|\/fixtures?\/|\/mocks?\/|\.mock\.|test-utils|testing|\.cy\.|\.e2e\.)/i;
|
|
218
604
|
function isTestFile(filePath) {
|
|
219
605
|
return TEST_FILE_PATTERN.test(filePath);
|
|
220
606
|
}
|
|
607
|
+
var SERVER_SIDE_PATH_RE = new RegExp(
|
|
608
|
+
[
|
|
609
|
+
// Directory-style anchors — match with OR without leading slash so
|
|
610
|
+
// relative paths (`routes/users.js`) work as well as absolute.
|
|
611
|
+
"(?:^|/)api/",
|
|
612
|
+
"(?:^|/)routes?/",
|
|
613
|
+
"(?:^|/)controllers?/",
|
|
614
|
+
"(?:^|/)endpoints?/",
|
|
615
|
+
"(?:^|/)handlers?/",
|
|
616
|
+
"(?:^|/)middleware/",
|
|
617
|
+
"(?:^|/)webhooks?/",
|
|
618
|
+
"(?:^|/)services?/",
|
|
619
|
+
"(?:^|/)lambda/",
|
|
620
|
+
"(?:^|/)functions?/",
|
|
621
|
+
"(?:^|/)pages/api/",
|
|
622
|
+
"(?:^|/)app/.*/route\\.(?:m?[jt]sx?|cjs)$",
|
|
623
|
+
// Bare-filename anchors (with or without leading directory).
|
|
624
|
+
// Allows compound names like webhook-handler.js, cron-runner.js,
|
|
625
|
+
// user-service.ts via the optional (?:[-_]\w+)* suffix group.
|
|
626
|
+
"(?:^|/)(?:api|routes?|controllers?|endpoints?|handlers?|middleware|webhooks?|services?|server|app|index|main|lambda|function|ingest|runner|worker|resolvers?)(?:[-_]\\w+)*\\.(?:m?[jt]sx?|cjs|py|rb|go)$",
|
|
627
|
+
// Common functional names that imply request handling.
|
|
628
|
+
"(?:^|/)(?:auth|login|logout|signup|signin|register|password|reset|callback|oauth|admin|dashboard|profile|account|checkout|payment|webhook|audit|cron|search|upload|download|errors?|create-?\\w*|update-?\\w*|delete-?\\w*)(?:[-_]\\w+)*\\.(?:m?[jt]sx?|cjs|py|rb|go)$"
|
|
629
|
+
].join("|"),
|
|
630
|
+
"i"
|
|
631
|
+
);
|
|
632
|
+
function isServerSideFile(filePath) {
|
|
633
|
+
if (isTestFile(filePath)) return false;
|
|
634
|
+
return SERVER_SIDE_PATH_RE.test(filePath);
|
|
635
|
+
}
|
|
636
|
+
var CONFIG_FILE_PATTERN = new RegExp(
|
|
637
|
+
[
|
|
638
|
+
"\\.config\\.(?:m?[jt]sx?|cjs)$",
|
|
639
|
+
"(?:^|/)config\\.(?:m?[jt]sx?|cjs|py|rb|json|ya?ml)$",
|
|
640
|
+
"(?:^|/)settings\\.(?:m?[jt]sx?|cjs|py)$",
|
|
641
|
+
"(?:^|/)\\.env(?:\\.|$)",
|
|
642
|
+
"(?:^|/)(?:knex|drizzle|next|vite|rollup|webpack|tailwind|postcss|jest|vitest|tsup|babel)\\.config\\.(?:m?[jt]sx?|cjs)$",
|
|
643
|
+
"(?:^|/)(?:db|database|connection|pool)\\.(?:config\\.)?(?:m?[jt]sx?|cjs|py|rb)$"
|
|
644
|
+
].join("|"),
|
|
645
|
+
"i"
|
|
646
|
+
);
|
|
221
647
|
function isCommentLine(content, matchIndex) {
|
|
222
648
|
const lineStart = content.lastIndexOf("\n", matchIndex - 1) + 1;
|
|
223
649
|
const lineText = content.substring(lineStart, content.indexOf("\n", matchIndex)).trimStart();
|
|
@@ -226,7 +652,7 @@ function isCommentLine(content, matchIndex) {
|
|
|
226
652
|
function isInsideFixMessage(content, matchIndex) {
|
|
227
653
|
const lineStart = content.lastIndexOf("\n", matchIndex - 1) + 1;
|
|
228
654
|
const lineText = content.substring(lineStart, content.indexOf("\n", matchIndex));
|
|
229
|
-
return /(?:fix|description|message|suggestion|hint|help|example|doc|comment)\s*[:=(]/i.test(lineText) || /return\s*["'`]
|
|
655
|
+
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);
|
|
230
656
|
}
|
|
231
657
|
function findMatches(content, pattern, rule, filePath, fixTemplate) {
|
|
232
658
|
const matches = [];
|
|
@@ -250,6 +676,23 @@ function findMatches(content, pattern, rule, filePath, fixTemplate) {
|
|
|
250
676
|
}
|
|
251
677
|
return matches;
|
|
252
678
|
}
|
|
679
|
+
function astMatch(content, filePath, line, rule, fix) {
|
|
680
|
+
return {
|
|
681
|
+
rule: rule.id,
|
|
682
|
+
title: rule.title,
|
|
683
|
+
severity: rule.severity,
|
|
684
|
+
category: rule.category,
|
|
685
|
+
file: filePath,
|
|
686
|
+
line,
|
|
687
|
+
snippet: getSnippet(content, line),
|
|
688
|
+
fix
|
|
689
|
+
};
|
|
690
|
+
}
|
|
691
|
+
function tryParse(content, filePath) {
|
|
692
|
+
const parsed = parseFile(content, filePath);
|
|
693
|
+
if (!parsed) return null;
|
|
694
|
+
return { parsed, taint: buildTaintMap(parsed) };
|
|
695
|
+
}
|
|
253
696
|
var hardcodedSecrets = {
|
|
254
697
|
id: "VC001",
|
|
255
698
|
title: "Hardcoded API Key or Secret",
|
|
@@ -273,6 +716,17 @@ var hardcodedSecrets = {
|
|
|
273
716
|
/sk-[a-zA-Z0-9]{20,}T3BlbkFJ[a-zA-Z0-9]{20,}/g,
|
|
274
717
|
// Generic tokens in assignments — require standalone word and longer min length
|
|
275
718
|
/(?:^|[\s,({])(?:token|secret|password|passwd|pwd)\s*[:=]\s*["'`]([a-zA-Z0-9_\-!@#$%^&*]{20,})["'`]/gim,
|
|
719
|
+
// SCREAMING_CASE identifiers ending in SECRET/TOKEN/KEY/PASSWORD
|
|
720
|
+
// (OAUTH_CLIENT_SECRET, JWT_PRIVATE_KEY, STRIPE_WEBHOOK_SECRET, etc.)
|
|
721
|
+
// that are assigned a string literal, not an env var or function call.
|
|
722
|
+
/[A-Z][A-Z0-9_]*_(?:SECRET|TOKEN|KEY|PASSWORD|PASSWD)\s*[:=]\s*["'`]([a-zA-Z0-9_\-!@#$%^&*.\/]{12,})["'`]/g,
|
|
723
|
+
// camelCase / snake_case object properties that look like credentials:
|
|
724
|
+
// apiKey: "...", webhookUrl: "https://...", accountSid: "...", authToken: "..."
|
|
725
|
+
// password: "..." caught here separately with a lower threshold (many
|
|
726
|
+
// real passwords are 8-16 chars, below the 12-char floor of the
|
|
727
|
+
// generic `secret = "..."` pattern).
|
|
728
|
+
/\b(?:apiKey|api_key|authToken|auth_token|webhookUrl|webhook_url|accountSid|account_sid|clientSecret|client_secret|refreshToken|refresh_token)\s*:\s*["'`]([a-zA-Z0-9_\-!@#$%^&*.\/:\-]{12,})["'`]/gi,
|
|
729
|
+
/\b(?:password|passwd|pwd)\s*:\s*["'`]([a-zA-Z0-9_\-!@#$%^&*.\/:\-]{8,})["'`]/gi,
|
|
276
730
|
// Private keys
|
|
277
731
|
/-----BEGIN (?:RSA |EC |DSA )?PRIVATE KEY-----/g,
|
|
278
732
|
// Database URLs with credentials
|
|
@@ -285,19 +739,49 @@ var hardcodedSecrets = {
|
|
|
285
739
|
"Supabase key detected. Service role keys bypass Row Level Security and grant full database read/write access. Move to a server-side environment variable immediately.",
|
|
286
740
|
"OpenAI API key detected \u2014 grants full API access and can incur charges. Rotate at platform.openai.com \u2192 API Keys.",
|
|
287
741
|
"Hardcoded token or password detected. Move to an environment variable and rotate the credential if it has been committed to version control.",
|
|
742
|
+
"Named secret constant detected (XXX_SECRET / XXX_TOKEN / XXX_KEY). Move to an environment variable: `const NAME = process.env.NAME;`. Rotate the credential if this file has been committed to version control.",
|
|
743
|
+
"Credential assigned to a config object property. Move to an environment variable \u2014 `apiKey: process.env.API_KEY` \u2014 and rotate the value if this file has been committed.",
|
|
744
|
+
"Hardcoded password detected in a config object. Move to an environment variable (`password: process.env.DB_PASSWORD`) and rotate the password if this file has been committed.",
|
|
288
745
|
"Private key found in source code. If this has been committed to version control, consider the key compromised \u2014 generate a new key pair and revoke the old one.",
|
|
289
746
|
"Database credentials in connection string. An attacker with this URL has full database access. Move to an environment variable, restrict network access, and rotate the password."
|
|
290
747
|
];
|
|
748
|
+
const minLen = [
|
|
749
|
+
12,
|
|
750
|
+
// [0] generic API key
|
|
751
|
+
16,
|
|
752
|
+
// [1] AWS — already rigid in the regex, length floor is defensive
|
|
753
|
+
20,
|
|
754
|
+
// [2] Stripe
|
|
755
|
+
50,
|
|
756
|
+
// [3] Supabase JWT
|
|
757
|
+
40,
|
|
758
|
+
// [4] OpenAI
|
|
759
|
+
20,
|
|
760
|
+
// [5] generic tokens with {20,} in the regex
|
|
761
|
+
12,
|
|
762
|
+
// [6] SCREAMING_CASE named constants
|
|
763
|
+
12,
|
|
764
|
+
// [7] camelCase config props (apiKey/webhookUrl/etc.)
|
|
765
|
+
8,
|
|
766
|
+
// [8] password: "..." — lower floor per the pattern comment above
|
|
767
|
+
0,
|
|
768
|
+
// [9] private keys — no length check
|
|
769
|
+
0
|
|
770
|
+
// [10] DB URLs — no length check
|
|
771
|
+
];
|
|
291
772
|
const matches = [];
|
|
292
773
|
for (let pi = 0; pi < patterns.length; pi++) {
|
|
293
774
|
const pattern = patterns[pi];
|
|
294
775
|
const rawMatches = findMatches(content, pattern, hardcodedSecrets, filePath, () => fixMessages[pi]);
|
|
776
|
+
const floor = minLen[pi] ?? 12;
|
|
295
777
|
for (const rm of rawMatches) {
|
|
296
778
|
const lineText = content.split("\n")[rm.line - 1] || "";
|
|
297
779
|
const trimmed = lineText.trimStart();
|
|
298
780
|
if (trimmed.startsWith("//") || trimmed.startsWith("#")) continue;
|
|
299
|
-
|
|
300
|
-
|
|
781
|
+
if (floor > 0) {
|
|
782
|
+
const secretMatch = lineText.match(/[:=]\s*["'`]([^"'`]*)["'`]/);
|
|
783
|
+
if (secretMatch && secretMatch[1].length < floor) continue;
|
|
784
|
+
}
|
|
301
785
|
matches.push(rm);
|
|
302
786
|
}
|
|
303
787
|
}
|
|
@@ -333,8 +817,7 @@ var missingAuthMiddleware = {
|
|
|
333
817
|
category: "Authentication",
|
|
334
818
|
description: "API routes without authentication checks allow unauthorized access.",
|
|
335
819
|
check(content, filePath) {
|
|
336
|
-
|
|
337
|
-
if (!isApiRoute) return [];
|
|
820
|
+
if (!isServerSideFile(filePath)) return [];
|
|
338
821
|
const routePatterns = [
|
|
339
822
|
// Express/Hono style
|
|
340
823
|
/\.(get|post|put|patch|delete)\s*\(\s*["'`][^"'`]+["'`]\s*,\s*(?:async\s+)?\(?(?:req|c|ctx)/gi,
|
|
@@ -347,35 +830,36 @@ var missingAuthMiddleware = {
|
|
|
347
830
|
if (isAuthRoute) return [];
|
|
348
831
|
const isWebhookRoute = /\/webhook/i.test(filePath);
|
|
349
832
|
if (isWebhookRoute) return [];
|
|
833
|
+
const codeOnly = content.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:])\/\/.*$/gm, "$1").replace(/^\s*#.*$/gm, "");
|
|
350
834
|
const authPatterns = [
|
|
351
|
-
/
|
|
352
|
-
/
|
|
353
|
-
/
|
|
354
|
-
/
|
|
355
|
-
/
|
|
356
|
-
/
|
|
357
|
-
/
|
|
358
|
-
/
|
|
359
|
-
/
|
|
360
|
-
/
|
|
361
|
-
/
|
|
362
|
-
/
|
|
835
|
+
/\bgetUser\b/i,
|
|
836
|
+
/\bcurrentUser\b/i,
|
|
837
|
+
/\bisAuthenticated\b/i,
|
|
838
|
+
/\brequireAuth\b/i,
|
|
839
|
+
/\brequireUser\b/i,
|
|
840
|
+
/\brequireUserForApi\b/i,
|
|
841
|
+
/\bwithAuth\b/i,
|
|
842
|
+
/\bgetServerSession\b/i,
|
|
843
|
+
/\bgetToken\b/i,
|
|
844
|
+
/\bverifyToken\b/i,
|
|
845
|
+
/\bvalidateToken\b/i,
|
|
846
|
+
/\bcheckApiKey\b/i,
|
|
847
|
+
/\bverifyCronSecret\b/i,
|
|
848
|
+
/\bverifySecret\b/i,
|
|
363
849
|
/supabase\.auth/i,
|
|
364
|
-
/getServerSession/i,
|
|
365
|
-
/getToken/i,
|
|
366
|
-
/protect/i,
|
|
367
|
-
/guard/i,
|
|
368
|
-
/verifyToken/i,
|
|
369
|
-
/validateToken/i,
|
|
370
|
-
/withAuth/i,
|
|
371
|
-
/passport/i,
|
|
372
850
|
/firebase\.auth/i,
|
|
373
|
-
/
|
|
374
|
-
/
|
|
375
|
-
/
|
|
376
|
-
/
|
|
851
|
+
/\bclerk\b/i,
|
|
852
|
+
/\bpassport\b/i,
|
|
853
|
+
/\bcognito\b/i,
|
|
854
|
+
/\bauth\(\)/i,
|
|
855
|
+
/req\.user\b/i,
|
|
856
|
+
/req\.session\.\w+/i,
|
|
857
|
+
/\bjwt\.verify\b/i,
|
|
858
|
+
/\bbearer\s/i,
|
|
859
|
+
/\b(?:protect|guard)\(/i,
|
|
860
|
+
/\.use\([^)]*(?:auth|session|protect|requireAuth)/i
|
|
377
861
|
];
|
|
378
|
-
const hasAuth = authPatterns.some((p) => p.test(
|
|
862
|
+
const hasAuth = authPatterns.some((p) => p.test(codeOnly));
|
|
379
863
|
if (hasAuth) return [];
|
|
380
864
|
const matches = [];
|
|
381
865
|
for (const pattern of routePatterns) {
|
|
@@ -550,6 +1034,37 @@ var xssVulnerability = {
|
|
|
550
1034
|
matches.push(m);
|
|
551
1035
|
}
|
|
552
1036
|
}
|
|
1037
|
+
if (!/\.(?:send|end|write)\s*\(/.test(content) || !/\$\{/.test(content)) {
|
|
1038
|
+
return matches;
|
|
1039
|
+
}
|
|
1040
|
+
const ctx = tryParse(content, filePath);
|
|
1041
|
+
if (!ctx) return matches;
|
|
1042
|
+
const { parsed, taint } = ctx;
|
|
1043
|
+
visitCalls(
|
|
1044
|
+
parsed,
|
|
1045
|
+
(callee) => {
|
|
1046
|
+
if (callee.type !== "MemberExpression") return false;
|
|
1047
|
+
if (callee.property.type !== "Identifier") return false;
|
|
1048
|
+
return ["send", "end", "write"].includes(callee.property.name);
|
|
1049
|
+
},
|
|
1050
|
+
(call, line) => {
|
|
1051
|
+
const first = call.arguments[0];
|
|
1052
|
+
if (!first || first.type !== "TemplateLiteral") return;
|
|
1053
|
+
const literalParts = first.quasis.map((q) => q.value.raw).join("");
|
|
1054
|
+
if (!/<\/?\w+/.test(literalParts)) return;
|
|
1055
|
+
if (!taint.isTainted(first)) return;
|
|
1056
|
+
if (matches.some((m) => m.line === line)) return;
|
|
1057
|
+
matches.push(
|
|
1058
|
+
astMatch(
|
|
1059
|
+
content,
|
|
1060
|
+
filePath,
|
|
1061
|
+
line,
|
|
1062
|
+
xssVulnerability,
|
|
1063
|
+
"Never echo user-supplied HTML back to the browser unescaped. Sanitize with DOMPurify, use a templating engine that auto-escapes, or set `res.type('text/plain')` if you never intended HTML."
|
|
1064
|
+
)
|
|
1065
|
+
);
|
|
1066
|
+
}
|
|
1067
|
+
);
|
|
553
1068
|
return matches;
|
|
554
1069
|
}
|
|
555
1070
|
};
|
|
@@ -1003,7 +1518,7 @@ var prototypePollution = {
|
|
|
1003
1518
|
title: "Prototype Pollution Risk",
|
|
1004
1519
|
severity: "high",
|
|
1005
1520
|
category: "Injection",
|
|
1006
|
-
description: "
|
|
1521
|
+
description: "Deep-merging or Object.assign-ing attacker-controlled data (request bodies, parsed localStorage, query params) into a host object can pollute Object.prototype via keys like __proto__ and constructor.prototype.",
|
|
1007
1522
|
check(content, filePath) {
|
|
1008
1523
|
const matches = [];
|
|
1009
1524
|
const storageParsePatterns = [
|
|
@@ -1011,26 +1526,70 @@ var prototypePollution = {
|
|
|
1011
1526
|
/JSON\.parse\s*\(\s*window\.localStorage/g
|
|
1012
1527
|
];
|
|
1013
1528
|
const hasValidation = /schema|validate|sanitize|whitelist|allowedKeys|pick\(|Object\.freeze|zod|yup|joi|ajv/i.test(content);
|
|
1014
|
-
if (hasValidation)
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1529
|
+
if (!hasValidation) {
|
|
1530
|
+
const hasUnsafeMerge = /Object\.assign\s*\([^)]*JSON\.parse|\.\.\.JSON\.parse|\{.*\.\.\.(?:stored|saved|cached|parsed|data)/i.test(content);
|
|
1531
|
+
if (hasUnsafeMerge) {
|
|
1532
|
+
matches.push(...findMatches(
|
|
1533
|
+
content,
|
|
1534
|
+
/Object\.assign\s*\([^)]*JSON\.parse|\.\.\.JSON\.parse/g,
|
|
1535
|
+
prototypePollution,
|
|
1536
|
+
filePath,
|
|
1537
|
+
() => "Validate parsed data against an expected schema before merging into objects. Use Object.freeze(), a validation library (Zod, Yup), or manually check for __proto__ and constructor keys."
|
|
1538
|
+
));
|
|
1539
|
+
}
|
|
1540
|
+
for (const p of storageParsePatterns) {
|
|
1541
|
+
matches.push(...findMatches(
|
|
1542
|
+
content,
|
|
1543
|
+
p,
|
|
1544
|
+
prototypePollution,
|
|
1545
|
+
filePath,
|
|
1546
|
+
() => "Validate localStorage data against an expected schema before using it. Malicious extensions or XSS can modify localStorage values."
|
|
1547
|
+
));
|
|
1548
|
+
}
|
|
1033
1549
|
}
|
|
1550
|
+
if (!/\b(?:merge|assign|extend|defaults)\s*\(/.test(content)) return matches;
|
|
1551
|
+
const ctx = tryParse(content, filePath);
|
|
1552
|
+
if (!ctx) return matches;
|
|
1553
|
+
const { parsed, taint } = ctx;
|
|
1554
|
+
const ALL_ARG_SINKS = [
|
|
1555
|
+
{ obj: "_", method: "merge" },
|
|
1556
|
+
{ obj: "lodash", method: "merge" },
|
|
1557
|
+
{ method: "merge" },
|
|
1558
|
+
// bare `merge(target, src)` from ESM import
|
|
1559
|
+
{ obj: "_", method: "defaultsDeep" },
|
|
1560
|
+
{ obj: "lodash", method: "defaultsDeep" },
|
|
1561
|
+
{ obj: "Object", method: "assign" },
|
|
1562
|
+
{ method: "extend" },
|
|
1563
|
+
// jquery/underscore style
|
|
1564
|
+
{ obj: "$", method: "extend" }
|
|
1565
|
+
];
|
|
1566
|
+
visitCalls(
|
|
1567
|
+
parsed,
|
|
1568
|
+
(callee) => {
|
|
1569
|
+
for (const sink of ALL_ARG_SINKS) {
|
|
1570
|
+
if (sink.obj && isMethodCall(callee, sink.obj, sink.method)) return true;
|
|
1571
|
+
if (!sink.obj && isCalleeNamed(callee, sink.method)) return true;
|
|
1572
|
+
}
|
|
1573
|
+
return false;
|
|
1574
|
+
},
|
|
1575
|
+
(call, line) => {
|
|
1576
|
+
const sources = call.arguments.slice(1);
|
|
1577
|
+
const tainted = sources.some((arg) => {
|
|
1578
|
+
if (arg.type === "SpreadElement") return taint.isTainted(arg.argument);
|
|
1579
|
+
return taint.isTainted(arg);
|
|
1580
|
+
});
|
|
1581
|
+
if (!tainted) return;
|
|
1582
|
+
matches.push(
|
|
1583
|
+
astMatch(
|
|
1584
|
+
content,
|
|
1585
|
+
filePath,
|
|
1586
|
+
line,
|
|
1587
|
+
prototypePollution,
|
|
1588
|
+
"Filter __proto__, constructor, and prototype keys before merging user-supplied data into an object. Prefer lodash.mergeWith() with a customizer that returns undefined for those keys, or validate the source with a schema library (Zod, Yup, Joi, Ajv) first."
|
|
1589
|
+
)
|
|
1590
|
+
);
|
|
1591
|
+
}
|
|
1592
|
+
);
|
|
1034
1593
|
return matches;
|
|
1035
1594
|
}
|
|
1036
1595
|
};
|
|
@@ -1063,13 +1622,13 @@ var unsanitizedFilenames = {
|
|
|
1063
1622
|
description: "Using user-supplied filenames without sanitization in file operations can enable path traversal, overwriting system files, or executing commands via special characters.",
|
|
1064
1623
|
check(content, filePath) {
|
|
1065
1624
|
const matches = [];
|
|
1625
|
+
const hasSanitization = /sanitize|cleanFilename|safeFilename|replace\s*\(\s*\/\[.*\]\/|\.startsWith\s*\(\s*(?:UPLOADS_DIR|\w+_DIR)/i.test(content);
|
|
1626
|
+
if (hasSanitization) return [];
|
|
1066
1627
|
const patterns = [
|
|
1067
1628
|
/(?:writeFile|writeFileSync|createWriteStream|rename|copyFile)\s*\(\s*(?:`[^`]*\$\{|[^"'`\s,]+\s*\+)/g,
|
|
1068
1629
|
/(?:dialog\.showSaveDialog|saveDialog).*(?:defaultPath|fileName)\s*:\s*(?!["'`])/g,
|
|
1069
1630
|
/\.download\s*=\s*(?!["'`])/g
|
|
1070
1631
|
];
|
|
1071
|
-
const hasSanitization = /sanitize|cleanFilename|safeFilename|replace\s*\(\s*\/\[.*\]\//i.test(content);
|
|
1072
|
-
if (hasSanitization) return [];
|
|
1073
1632
|
for (const p of patterns) {
|
|
1074
1633
|
matches.push(...findMatches(
|
|
1075
1634
|
content,
|
|
@@ -1079,6 +1638,61 @@ var unsanitizedFilenames = {
|
|
|
1079
1638
|
() => "Sanitize filenames before use: strip path separators (/ \\), special chars, and '..' sequences. Example: name.replace(/[^a-zA-Z0-9._-]/g, '_')"
|
|
1080
1639
|
));
|
|
1081
1640
|
}
|
|
1641
|
+
if (!/\b(?:fs|fsPromises)\.(?:readFile|writeFile|appendFile|readFileSync|writeFileSync|appendFileSync|createReadStream|createWriteStream|unlink|unlinkSync|stat|statSync|rm|rmSync|mkdir|mkdirSync)\s*\(/.test(content)) {
|
|
1642
|
+
return matches;
|
|
1643
|
+
}
|
|
1644
|
+
const ctx = tryParse(content, filePath);
|
|
1645
|
+
if (!ctx) return matches;
|
|
1646
|
+
const { parsed, taint } = ctx;
|
|
1647
|
+
const FS_SINKS = /* @__PURE__ */ new Set([
|
|
1648
|
+
"readFile",
|
|
1649
|
+
"writeFile",
|
|
1650
|
+
"appendFile",
|
|
1651
|
+
"readFileSync",
|
|
1652
|
+
"writeFileSync",
|
|
1653
|
+
"appendFileSync",
|
|
1654
|
+
"createReadStream",
|
|
1655
|
+
"createWriteStream",
|
|
1656
|
+
"unlink",
|
|
1657
|
+
"unlinkSync",
|
|
1658
|
+
"rm",
|
|
1659
|
+
"rmSync",
|
|
1660
|
+
"stat",
|
|
1661
|
+
"statSync",
|
|
1662
|
+
"mkdir",
|
|
1663
|
+
"mkdirSync"
|
|
1664
|
+
]);
|
|
1665
|
+
const isFsReceiver = (obj) => {
|
|
1666
|
+
if (obj.type === "Identifier") return obj.name === "fs" || obj.name === "fsPromises";
|
|
1667
|
+
if (obj.type === "MemberExpression") {
|
|
1668
|
+
return obj.object.type === "Identifier" && obj.object.name === "fs" && obj.property.type === "Identifier" && obj.property.name === "promises";
|
|
1669
|
+
}
|
|
1670
|
+
return false;
|
|
1671
|
+
};
|
|
1672
|
+
visitCalls(
|
|
1673
|
+
parsed,
|
|
1674
|
+
(callee) => {
|
|
1675
|
+
if (callee.type !== "MemberExpression") return false;
|
|
1676
|
+
if (callee.property.type !== "Identifier") return false;
|
|
1677
|
+
if (!FS_SINKS.has(callee.property.name)) return false;
|
|
1678
|
+
return isFsReceiver(callee.object);
|
|
1679
|
+
},
|
|
1680
|
+
(call, line) => {
|
|
1681
|
+
const first = call.arguments[0];
|
|
1682
|
+
if (!first || first.type === "SpreadElement") return;
|
|
1683
|
+
if (!taint.isTainted(first)) return;
|
|
1684
|
+
if (matches.some((m) => m.line === line)) return;
|
|
1685
|
+
matches.push(
|
|
1686
|
+
astMatch(
|
|
1687
|
+
content,
|
|
1688
|
+
filePath,
|
|
1689
|
+
line,
|
|
1690
|
+
unsanitizedFilenames,
|
|
1691
|
+
"Resolve the path against a fixed base directory and verify it stays within that base before the fs call: `const target = path.resolve(BASE, req.body.name); if (!target.startsWith(BASE + path.sep)) throw new Error('path traversal');`"
|
|
1692
|
+
)
|
|
1693
|
+
);
|
|
1694
|
+
}
|
|
1695
|
+
);
|
|
1082
1696
|
return matches;
|
|
1083
1697
|
}
|
|
1084
1698
|
};
|
|
@@ -1229,8 +1843,9 @@ var insecureDeserialization = {
|
|
|
1229
1843
|
/unserialize\s*\(/g,
|
|
1230
1844
|
// Ruby Marshal
|
|
1231
1845
|
/Marshal\.load\s*\(/g,
|
|
1232
|
-
// YAML unsafe load (Python)
|
|
1233
|
-
|
|
1846
|
+
// YAML unsafe load (Python + js-yaml). Filtered below so safe schema
|
|
1847
|
+
// arguments (SAFE_SCHEMA, FAILSAFE_SCHEMA, yaml.SafeLoader) don't FP.
|
|
1848
|
+
/yaml\.load\s*\(/g,
|
|
1234
1849
|
/yaml\.unsafe_load\s*\(/g,
|
|
1235
1850
|
// Java ObjectInputStream
|
|
1236
1851
|
/ObjectInputStream\s*\(/g,
|
|
@@ -1246,7 +1861,20 @@ var insecureDeserialization = {
|
|
|
1246
1861
|
() => "Never deserialize untrusted data. Use JSON instead of pickle/Marshal/unserialize. For YAML, use yaml.safe_load(). Validate and sanitize all input before deserialization."
|
|
1247
1862
|
));
|
|
1248
1863
|
}
|
|
1249
|
-
return matches
|
|
1864
|
+
return matches.filter((m) => {
|
|
1865
|
+
if (!/yaml\.load\s*\(/.test(m.snippet ?? "")) return true;
|
|
1866
|
+
const lineText = (m.snippet ?? "").toLowerCase();
|
|
1867
|
+
if (/safe_schema|failsafe_schema|safe_load|safeloader/.test(lineText)) {
|
|
1868
|
+
return false;
|
|
1869
|
+
}
|
|
1870
|
+
const lineIdx = m.line - 1;
|
|
1871
|
+
const lines = content.split("\n");
|
|
1872
|
+
const ctx = lines.slice(lineIdx, lineIdx + 3).join("\n").toLowerCase();
|
|
1873
|
+
if (/safe_schema|failsafe_schema|safe_load|safeloader/.test(ctx)) {
|
|
1874
|
+
return false;
|
|
1875
|
+
}
|
|
1876
|
+
return true;
|
|
1877
|
+
});
|
|
1250
1878
|
}
|
|
1251
1879
|
};
|
|
1252
1880
|
var hardcodedJWTSecret = {
|
|
@@ -1324,7 +1952,12 @@ var exposedDebugMode = {
|
|
|
1324
1952
|
/app\.debug\s*=\s*True/g,
|
|
1325
1953
|
/app\.run\s*\([^)]*debug\s*=\s*True/g,
|
|
1326
1954
|
// Source maps in production
|
|
1327
|
-
/devtool\s*:\s*["'`](?:eval|cheap|source-map|inline-source-map)["'`]/g
|
|
1955
|
+
/devtool\s*:\s*["'`](?:eval|cheap|source-map|inline-source-map)["'`]/g,
|
|
1956
|
+
// Debug endpoints that leak environment / process internals in the
|
|
1957
|
+
// response body. Very high-signal — a production service shouldn't
|
|
1958
|
+
// serialize process.env or process.version to callers.
|
|
1959
|
+
/res\.(?:json|send)\s*\([^)]*process\.(?:env|version|pid|arch|platform)/gi,
|
|
1960
|
+
/(?:env|environment)\s*:\s*process\.env\b/gi
|
|
1328
1961
|
];
|
|
1329
1962
|
for (const p of patterns) {
|
|
1330
1963
|
matches.push(...findMatches(
|
|
@@ -1362,6 +1995,21 @@ var insecureRandomness = {
|
|
|
1362
1995
|
if (nonSecurityVarNames.test(lineText)) continue;
|
|
1363
1996
|
matches.push(rm);
|
|
1364
1997
|
}
|
|
1998
|
+
const SECURITY_FN_CONTEXT = /\b(?:function|def|async|const|let|var)\s+\w*(?:generate|create|make|new|mint|issue)[A-Z_]\w*(?:Token|Session|Nonce|Salt|Secret|Password|Otp|Csrf|Key|Code|Auth)\w*/i;
|
|
1999
|
+
const SECURITY_IDENT_CONTEXT = /\b(?:session[_-]?token|csrf[_-]?token|reset[_-]?token|verify[_-]?code|otp[_-]?code|refresh[_-]?token|api[_-]?key)\b/i;
|
|
2000
|
+
if (SECURITY_FN_CONTEXT.test(content) || SECURITY_IDENT_CONTEXT.test(content)) {
|
|
2001
|
+
const raw = findMatches(
|
|
2002
|
+
content,
|
|
2003
|
+
/Math\.random\s*\(\s*\)/g,
|
|
2004
|
+
insecureRandomness,
|
|
2005
|
+
filePath,
|
|
2006
|
+
() => "Math.random() is not cryptographically secure. Use crypto.randomBytes() (Node), crypto.getRandomValues() (browser), or crypto.randomUUID() for tokens, session IDs, salts, and similar values."
|
|
2007
|
+
);
|
|
2008
|
+
for (const m of raw) {
|
|
2009
|
+
if (matches.some((existing) => existing.line === m.line)) continue;
|
|
2010
|
+
matches.push(m);
|
|
2011
|
+
}
|
|
2012
|
+
}
|
|
1365
2013
|
return matches;
|
|
1366
2014
|
}
|
|
1367
2015
|
};
|
|
@@ -1373,13 +2021,13 @@ var openRedirectParams = {
|
|
|
1373
2021
|
description: "Redirect parameters like ?redirect_url=, ?return_to=, ?next= passed directly to redirects enable phishing attacks.",
|
|
1374
2022
|
check(content, filePath) {
|
|
1375
2023
|
const matches = [];
|
|
2024
|
+
const codeOnly = content.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:])\/\/.*$/gm, "$1");
|
|
2025
|
+
const hasValidation = /allowed[_-]?(?:urls?|domains?|hosts?|paths?|routes?|list)|allow[_-]?list|valid[_-]?url|safe[_-]?domain|whitelist|startsWith.*https|new URL.*hostname/i.test(codeOnly);
|
|
2026
|
+
if (hasValidation) return [];
|
|
1376
2027
|
const patterns = [
|
|
1377
|
-
// Reading redirect-like query params and using in redirect
|
|
1378
2028
|
/(?:redirect_url|redirect_uri|return_to|return_url|next|callback_url|continue|goto|target|dest|destination|forward|redir)\s*(?:=|:)\s*(?:req\.query|req\.params|searchParams|query|params)\./gi,
|
|
1379
2029
|
/redirect\s*\(\s*(?:req\.query|req\.params|searchParams\.get)\s*\(\s*["'`](?:redirect|return|next|callback|url|goto)/gi
|
|
1380
2030
|
];
|
|
1381
|
-
const hasValidation = /allowedUrls|allowedDomains|allowedHosts|validUrl|safeDomain|whitelist|startsWith.*https|new URL.*hostname/i.test(content);
|
|
1382
|
-
if (hasValidation) return [];
|
|
1383
2031
|
for (const p of patterns) {
|
|
1384
2032
|
matches.push(...findMatches(
|
|
1385
2033
|
content,
|
|
@@ -1389,6 +2037,29 @@ var openRedirectParams = {
|
|
|
1389
2037
|
() => "Validate redirect URLs against an allowlist of trusted domains. Use: const url = new URL(input); if (!ALLOWED_HOSTS.includes(url.hostname)) reject."
|
|
1390
2038
|
));
|
|
1391
2039
|
}
|
|
2040
|
+
if (!/\.redirect\s*\(/.test(content)) return matches;
|
|
2041
|
+
const ctx = tryParse(content, filePath);
|
|
2042
|
+
if (!ctx) return matches;
|
|
2043
|
+
const { parsed, taint } = ctx;
|
|
2044
|
+
visitCalls(
|
|
2045
|
+
parsed,
|
|
2046
|
+
(callee) => isCalleeNamed(callee, "redirect"),
|
|
2047
|
+
(call, line) => {
|
|
2048
|
+
const first = call.arguments[0];
|
|
2049
|
+
if (!first || first.type === "SpreadElement") return;
|
|
2050
|
+
if (!taint.isTainted(first)) return;
|
|
2051
|
+
if (matches.some((m) => m.line === line)) return;
|
|
2052
|
+
matches.push(
|
|
2053
|
+
astMatch(
|
|
2054
|
+
content,
|
|
2055
|
+
filePath,
|
|
2056
|
+
line,
|
|
2057
|
+
openRedirectParams,
|
|
2058
|
+
"Validate redirect targets against an allowlist before calling res.redirect(). `const ALLOWED = new Set(['/dashboard', '/settings']); if (!ALLOWED.has(next)) return res.redirect('/dashboard');`"
|
|
2059
|
+
)
|
|
2060
|
+
);
|
|
2061
|
+
}
|
|
2062
|
+
);
|
|
1392
2063
|
return matches;
|
|
1393
2064
|
}
|
|
1394
2065
|
};
|
|
@@ -1431,8 +2102,7 @@ var exposedStackTraces = {
|
|
|
1431
2102
|
category: "Information Leakage",
|
|
1432
2103
|
description: "Returning error.stack or detailed error messages in API responses reveals internal code paths, file structure, and dependencies to attackers.",
|
|
1433
2104
|
check(content, filePath) {
|
|
1434
|
-
|
|
1435
|
-
if (!isApiFile) return [];
|
|
2105
|
+
if (!isServerSideFile(filePath)) return [];
|
|
1436
2106
|
const matches = [];
|
|
1437
2107
|
const patterns = [
|
|
1438
2108
|
// Sending stack trace in response
|
|
@@ -1463,9 +2133,10 @@ var insecureFileUpload = {
|
|
|
1463
2133
|
description: "File uploads validated only by extension (not MIME type or content) allow attackers to upload executable files disguised as images or documents.",
|
|
1464
2134
|
check(content, filePath) {
|
|
1465
2135
|
if (!/upload|multer|formidable|busboy|multipart/i.test(content)) return [];
|
|
2136
|
+
const codeOnly = content.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:])\/\/.*$/gm, "$1").replace(/^\s*#.*$/gm, "");
|
|
1466
2137
|
const matches = [];
|
|
1467
|
-
const hasExtCheck = /\.(?:endsWith|match|test)\s*\([^)]*(?:\.jpg|\.png|\.pdf|\.doc|ext)/i.test(
|
|
1468
|
-
const hasMimeCheck =
|
|
2138
|
+
const hasExtCheck = /\.(?:endsWith|match|test)\s*\([^)]*(?:\.jpg|\.png|\.pdf|\.doc|ext)/i.test(codeOnly) || /\/\\\.\((?:\?:)?[a-z]{2,5}(?:\|[a-z]{2,5})+\)/i.test(codeOnly);
|
|
2139
|
+
const hasMimeCheck = /\bmimetype\b|\bcontent-type\b|\bfile\.type\b|\bmime\.\w|\bmagic\.detect\b|\bfile-type\b|fileTypeFromBuffer/i.test(codeOnly);
|
|
1469
2140
|
if (hasExtCheck && !hasMimeCheck) {
|
|
1470
2141
|
matches.push(...findMatches(
|
|
1471
2142
|
content,
|
|
@@ -1529,34 +2200,53 @@ var ssrfVulnerability = {
|
|
|
1529
2200
|
category: "Injection",
|
|
1530
2201
|
description: "Fetching URLs from user input without validation allows attackers to access internal services, cloud metadata endpoints (169.254.169.254), and private networks.",
|
|
1531
2202
|
check(content, filePath) {
|
|
1532
|
-
if (
|
|
1533
|
-
const isServerFile = /(?:\/api\/|routes?\/|controllers?\/|server\.|handler|middleware)/i.test(filePath);
|
|
1534
|
-
if (!isServerFile) return [];
|
|
2203
|
+
if (!isServerSideFile(filePath)) return [];
|
|
1535
2204
|
const matches = [];
|
|
1536
|
-
const patterns = [
|
|
1537
|
-
/(?:fetch|axios\.get|axios\.post|axios|got|request|http\.get|https\.get)\s*\(\s*(?:req\.(?:body|query|params))\./gi
|
|
1538
|
-
];
|
|
1539
2205
|
const hasValidation = /allowedHosts|allowedDomains|allowedUrls|safeDomain|whitelist|urlValidator|new URL.*hostname.*includes|isAllowedUrl|validateUrl|isValidUrl/i.test(content);
|
|
1540
2206
|
if (hasValidation) return [];
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
2207
|
+
const inlinePattern = /(?:fetch|axios\.get|axios\.post|axios|got|request|http\.get|https\.get)\s*\(\s*(?:req\.(?:body|query|params))\./gi;
|
|
2208
|
+
matches.push(...findMatches(
|
|
2209
|
+
content,
|
|
2210
|
+
inlinePattern,
|
|
2211
|
+
ssrfVulnerability,
|
|
2212
|
+
filePath,
|
|
2213
|
+
() => "Validate URLs against an allowlist before fetching. Block internal IPs: 127.0.0.1, 10.x, 172.16-31.x, 192.168.x, 169.254.169.254 (cloud metadata). Use: const url = new URL(input); if (!ALLOWED_HOSTS.includes(url.hostname)) throw new Error('Blocked');"
|
|
2214
|
+
));
|
|
2215
|
+
if (!/\b(?:fetch|axios|got|request|http\.get|https\.get)\b/.test(content)) return matches;
|
|
2216
|
+
const ctx = tryParse(content, filePath);
|
|
2217
|
+
if (!ctx) return matches;
|
|
2218
|
+
const { parsed, taint } = ctx;
|
|
2219
|
+
const FETCH_CALLEES = /* @__PURE__ */ new Set(["fetch", "axios", "got", "request"]);
|
|
2220
|
+
const FETCH_METHODS = /* @__PURE__ */ new Set(["get", "post", "put", "patch", "delete", "request"]);
|
|
2221
|
+
visitCalls(
|
|
2222
|
+
parsed,
|
|
2223
|
+
(callee) => {
|
|
2224
|
+
if (callee.type === "Identifier" && FETCH_CALLEES.has(callee.name)) return true;
|
|
2225
|
+
if (callee.type === "MemberExpression" && callee.property.type === "Identifier") {
|
|
2226
|
+
if (!FETCH_METHODS.has(callee.property.name)) return false;
|
|
2227
|
+
const obj = callee.object;
|
|
2228
|
+
if (obj.type === "Identifier") {
|
|
2229
|
+
return obj.name === "axios" || obj.name === "got" || obj.name === "http" || obj.name === "https";
|
|
2230
|
+
}
|
|
1556
2231
|
}
|
|
1557
|
-
|
|
2232
|
+
return false;
|
|
2233
|
+
},
|
|
2234
|
+
(call, line) => {
|
|
2235
|
+
const first = call.arguments[0];
|
|
2236
|
+
if (!first || first.type === "SpreadElement") return;
|
|
2237
|
+
if (!taint.isTainted(first)) return;
|
|
2238
|
+
if (matches.some((m) => m.line === line)) return;
|
|
2239
|
+
matches.push(
|
|
2240
|
+
astMatch(
|
|
2241
|
+
content,
|
|
2242
|
+
filePath,
|
|
2243
|
+
line,
|
|
2244
|
+
ssrfVulnerability,
|
|
2245
|
+
"Validate URLs against an allowlist before fetching. Parse the URL, check the hostname against a static set, and block internal IPs (127.0.0.1, 10.x, 172.16-31.x, 192.168.x, 169.254.169.254)."
|
|
2246
|
+
)
|
|
2247
|
+
);
|
|
1558
2248
|
}
|
|
1559
|
-
|
|
2249
|
+
);
|
|
1560
2250
|
return matches;
|
|
1561
2251
|
}
|
|
1562
2252
|
};
|
|
@@ -1567,19 +2257,15 @@ var massAssignment = {
|
|
|
1567
2257
|
category: "Authorization",
|
|
1568
2258
|
description: "Spreading or assigning request body directly into database models allows attackers to set fields they shouldn't (e.g., isAdmin, role, verified).",
|
|
1569
2259
|
check(content, filePath) {
|
|
1570
|
-
|
|
1571
|
-
|
|
2260
|
+
if (!isServerSideFile(filePath)) return [];
|
|
2261
|
+
const hasSanitization = /pick\(|omit\(|allowedFields|sanitize|whitelist|permit|strong_params/i.test(content);
|
|
2262
|
+
if (hasSanitization) return [];
|
|
1572
2263
|
const matches = [];
|
|
1573
2264
|
const patterns = [
|
|
1574
|
-
// Object.assign(model, req.body)
|
|
1575
2265
|
/Object\.assign\s*\(\s*(?:user|account|profile|record|doc|model|entity)[^,]*,\s*(?:req\.body|body|input|data)\s*\)/gi,
|
|
1576
|
-
// Spread req.body into create/update
|
|
1577
2266
|
/(?:create|update|insert|save|findOneAndUpdate|updateOne|upsert)\s*\(\s*\{[^}]*\.\.\.(?:req\.body|body|input|data)/gi,
|
|
1578
|
-
// Direct req.body into DB
|
|
1579
2267
|
/(?:create|insert|save)\s*\(\s*(?:req\.body|body)\s*\)/gi
|
|
1580
2268
|
];
|
|
1581
|
-
const hasSanitization = /pick\(|omit\(|allowedFields|sanitize|whitelist|permit|strong_params/i.test(content);
|
|
1582
|
-
if (hasSanitization) return [];
|
|
1583
2269
|
for (const p of patterns) {
|
|
1584
2270
|
matches.push(...findMatches(
|
|
1585
2271
|
content,
|
|
@@ -1589,6 +2275,61 @@ var massAssignment = {
|
|
|
1589
2275
|
() => "Never pass req.body directly to database operations. Explicitly pick allowed fields: const { name, email } = req.body; await db.create({ name, email });"
|
|
1590
2276
|
));
|
|
1591
2277
|
}
|
|
2278
|
+
const ORM_METHOD_PREFIXES = [
|
|
2279
|
+
"create",
|
|
2280
|
+
"insert",
|
|
2281
|
+
"save",
|
|
2282
|
+
"update",
|
|
2283
|
+
"upsert",
|
|
2284
|
+
"patch",
|
|
2285
|
+
"bulkCreate",
|
|
2286
|
+
"bulkInsert",
|
|
2287
|
+
"bulkUpsert",
|
|
2288
|
+
"findOneAndUpdate",
|
|
2289
|
+
"findByIdAndUpdate",
|
|
2290
|
+
"findAndUpdate",
|
|
2291
|
+
"build",
|
|
2292
|
+
"merge",
|
|
2293
|
+
"replace"
|
|
2294
|
+
];
|
|
2295
|
+
const isOrmMethod = (name) => ORM_METHOD_PREFIXES.some((p) => name === p || name.startsWith(p));
|
|
2296
|
+
if (!/\b(?:create|insert|save|update|upsert|patch|bulk|findOneAndUpdate|findByIdAndUpdate|findAndUpdate|build|merge|replace)\w*\s*\(/.test(content)) {
|
|
2297
|
+
return matches;
|
|
2298
|
+
}
|
|
2299
|
+
const ctx = tryParse(content, filePath);
|
|
2300
|
+
if (!ctx) return matches;
|
|
2301
|
+
const { parsed, taint } = ctx;
|
|
2302
|
+
visitCalls(
|
|
2303
|
+
parsed,
|
|
2304
|
+
(callee) => {
|
|
2305
|
+
if (callee.type === "MemberExpression" && callee.property.type === "Identifier") {
|
|
2306
|
+
return isOrmMethod(callee.property.name);
|
|
2307
|
+
}
|
|
2308
|
+
return false;
|
|
2309
|
+
},
|
|
2310
|
+
(call, line) => {
|
|
2311
|
+
const tainted = call.arguments.some((arg) => {
|
|
2312
|
+
if (arg.type === "SpreadElement") return taint.isTainted(arg.argument);
|
|
2313
|
+
if (arg.type === "ObjectExpression") {
|
|
2314
|
+
return arg.properties.some(
|
|
2315
|
+
(p) => p.type === "SpreadElement" && taint.isTainted(p.argument)
|
|
2316
|
+
);
|
|
2317
|
+
}
|
|
2318
|
+
return taint.isTainted(arg);
|
|
2319
|
+
});
|
|
2320
|
+
if (!tainted) return;
|
|
2321
|
+
if (matches.some((m) => m.line === line)) return;
|
|
2322
|
+
matches.push(
|
|
2323
|
+
astMatch(
|
|
2324
|
+
content,
|
|
2325
|
+
filePath,
|
|
2326
|
+
line,
|
|
2327
|
+
massAssignment,
|
|
2328
|
+
"Pick allowed fields explicitly instead of spreading the request body. `User.create({ email: req.body.email, name: req.body.name })` rather than `User.create({ ...req.body })`."
|
|
2329
|
+
)
|
|
2330
|
+
);
|
|
2331
|
+
}
|
|
2332
|
+
);
|
|
1592
2333
|
return matches;
|
|
1593
2334
|
}
|
|
1594
2335
|
};
|
|
@@ -1601,12 +2342,12 @@ var timingAttack = {
|
|
|
1601
2342
|
check(content, filePath) {
|
|
1602
2343
|
if (isTestFile(filePath)) return [];
|
|
1603
2344
|
const matches = [];
|
|
2345
|
+
const hasTimingSafe = /timingSafeEqual|constantTimeEqual|safeCompare|secureCompare/i.test(content);
|
|
2346
|
+
if (hasTimingSafe) return [];
|
|
1604
2347
|
const patterns = [
|
|
1605
2348
|
/(?:token|secret|hash|digest|signature|hmac|apiKey|api_key)\s*(?:===|!==)\s*(?:req\.|body\.|params\.|query\.|input)/gi,
|
|
1606
2349
|
/(?:^|[^.\w])(?:req\.|body\.|params\.|query\.|input)[\w.]*(?:token|secret|hash|digest|signature|hmac)\s*(?:===|!==)/gim
|
|
1607
2350
|
];
|
|
1608
|
-
const hasTimingSafe = /timingSafeEqual|constantTimeEqual|safeCompare|secureCompare/i.test(content);
|
|
1609
|
-
if (hasTimingSafe) return [];
|
|
1610
2351
|
for (const p of patterns) {
|
|
1611
2352
|
const raw = findMatches(
|
|
1612
2353
|
content,
|
|
@@ -1621,6 +2362,43 @@ var timingAttack = {
|
|
|
1621
2362
|
matches.push(m);
|
|
1622
2363
|
}
|
|
1623
2364
|
}
|
|
2365
|
+
if (!/===|!==/.test(content)) return matches;
|
|
2366
|
+
const ctx = tryParse(content, filePath);
|
|
2367
|
+
if (!ctx) return matches;
|
|
2368
|
+
const SECRET_NAME_RE = /(?:secret|token|api[_-]?key|auth[_-]?key|jwt[_-]?secret|signature|hmac|password|passwd|pwd_hash|pwd_digest|digest)/i;
|
|
2369
|
+
function looksLikeSecret(node) {
|
|
2370
|
+
if (node.type === "Identifier") return SECRET_NAME_RE.test(node.name);
|
|
2371
|
+
if (node.type === "MemberExpression") {
|
|
2372
|
+
if (node.property.type === "Identifier" && SECRET_NAME_RE.test(node.property.name)) return true;
|
|
2373
|
+
if (node.property.type === "StringLiteral" && SECRET_NAME_RE.test(node.property.value)) return true;
|
|
2374
|
+
return looksLikeSecret(node.object);
|
|
2375
|
+
}
|
|
2376
|
+
return false;
|
|
2377
|
+
}
|
|
2378
|
+
visitBinary(ctx.parsed, (n, line) => {
|
|
2379
|
+
if (n.operator !== "===" && n.operator !== "!==" && n.operator !== "==" && n.operator !== "!=") {
|
|
2380
|
+
return;
|
|
2381
|
+
}
|
|
2382
|
+
if (n.left.type === "UnaryExpression" && n.left.operator === "typeof") return;
|
|
2383
|
+
if (n.right.type === "UnaryExpression" && n.right.operator === "typeof") return;
|
|
2384
|
+
const leftSecret = looksLikeSecret(n.left);
|
|
2385
|
+
const rightSecret = looksLikeSecret(n.right);
|
|
2386
|
+
if (!leftSecret && !rightSecret) return;
|
|
2387
|
+
const otherSide = leftSecret ? n.right : n.left;
|
|
2388
|
+
if (otherSide.type === "StringLiteral" || otherSide.type === "NumericLiteral" || otherSide.type === "NullLiteral" || otherSide.type === "BooleanLiteral") {
|
|
2389
|
+
return;
|
|
2390
|
+
}
|
|
2391
|
+
if (matches.some((m) => m.line === line)) return;
|
|
2392
|
+
matches.push(
|
|
2393
|
+
astMatch(
|
|
2394
|
+
content,
|
|
2395
|
+
filePath,
|
|
2396
|
+
line,
|
|
2397
|
+
timingAttack,
|
|
2398
|
+
"Compare secrets with crypto.timingSafeEqual() after a length check. === short-circuits on the first differing byte and leaks information via timing."
|
|
2399
|
+
)
|
|
2400
|
+
);
|
|
2401
|
+
});
|
|
1624
2402
|
return matches;
|
|
1625
2403
|
}
|
|
1626
2404
|
};
|
|
@@ -1631,16 +2409,14 @@ var logInjection = {
|
|
|
1631
2409
|
category: "Injection",
|
|
1632
2410
|
description: "Logging unsanitized user input allows attackers to forge log entries, inject malicious content, or exploit log aggregation systems via newlines and special characters.",
|
|
1633
2411
|
check(content, filePath) {
|
|
1634
|
-
if (
|
|
1635
|
-
const
|
|
1636
|
-
if (
|
|
2412
|
+
if (!isServerSideFile(filePath)) return [];
|
|
2413
|
+
const hasSanitization = /replace\s*\(\s*\/\[?\\r\\n\]|sanitizeLog|stripNewlines|sanitizeForLog/i.test(content);
|
|
2414
|
+
if (hasSanitization) return [];
|
|
1637
2415
|
const matches = [];
|
|
1638
2416
|
const patterns = [
|
|
1639
2417
|
/console\.(?:log|warn|error|info)\s*\([^)]*(?:req\.body|req\.query|req\.params|req\.headers)\s*\)/gi,
|
|
1640
2418
|
/(?:logger|log)\.(?:info|warn|error|debug)\s*\([^)]*(?:req\.body|req\.query|req\.params)\s*\)/gi
|
|
1641
2419
|
];
|
|
1642
|
-
const hasSanitization = /sanitize|escape|JSON\.stringify|replace.*\\n/i.test(content);
|
|
1643
|
-
if (hasSanitization) return [];
|
|
1644
2420
|
for (const p of patterns) {
|
|
1645
2421
|
matches.push(...findMatches(
|
|
1646
2422
|
content,
|
|
@@ -1650,6 +2426,41 @@ var logInjection = {
|
|
|
1650
2426
|
() => "Sanitize user input before logging: strip newlines and control characters. Use JSON.stringify() or a structured logger (e.g., pino, winston) that escapes values automatically."
|
|
1651
2427
|
));
|
|
1652
2428
|
}
|
|
2429
|
+
if (!/(?:console\.|logger\.|log\.)\s*\w+\s*\(/.test(content)) return matches;
|
|
2430
|
+
const ctx = tryParse(content, filePath);
|
|
2431
|
+
if (!ctx) return matches;
|
|
2432
|
+
const { parsed, taint } = ctx;
|
|
2433
|
+
const LOG_METHODS = /* @__PURE__ */ new Set(["log", "warn", "error", "info", "debug", "trace"]);
|
|
2434
|
+
visitCalls(
|
|
2435
|
+
parsed,
|
|
2436
|
+
(callee) => {
|
|
2437
|
+
if (callee.type !== "MemberExpression") return false;
|
|
2438
|
+
if (callee.property.type !== "Identifier") return false;
|
|
2439
|
+
if (!LOG_METHODS.has(callee.property.name)) return false;
|
|
2440
|
+
const obj = callee.object;
|
|
2441
|
+
if (obj.type === "Identifier") {
|
|
2442
|
+
return obj.name === "console" || obj.name === "logger" || obj.name === "log";
|
|
2443
|
+
}
|
|
2444
|
+
return false;
|
|
2445
|
+
},
|
|
2446
|
+
(call, line) => {
|
|
2447
|
+
const tainted = call.arguments.some((arg) => {
|
|
2448
|
+
if (arg.type === "SpreadElement") return taint.isTainted(arg.argument);
|
|
2449
|
+
return taint.isTainted(arg);
|
|
2450
|
+
});
|
|
2451
|
+
if (!tainted) return;
|
|
2452
|
+
if (matches.some((m) => m.line === line)) return;
|
|
2453
|
+
matches.push(
|
|
2454
|
+
astMatch(
|
|
2455
|
+
content,
|
|
2456
|
+
filePath,
|
|
2457
|
+
line,
|
|
2458
|
+
logInjection,
|
|
2459
|
+
"Strip newlines/control chars before embedding user input in a log line. `value.replace(/[\\r\\n]/g, ' ')` for quick fix; a structured logger escapes values automatically."
|
|
2460
|
+
)
|
|
2461
|
+
);
|
|
2462
|
+
}
|
|
2463
|
+
);
|
|
1653
2464
|
return matches;
|
|
1654
2465
|
}
|
|
1655
2466
|
};
|
|
@@ -1662,7 +2473,33 @@ var weakPasswordRequirements = {
|
|
|
1662
2473
|
check(content, filePath) {
|
|
1663
2474
|
if (isTestFile(filePath)) return [];
|
|
1664
2475
|
if (!/(?:password|passwd|pwd)/i.test(content)) return [];
|
|
1665
|
-
|
|
2476
|
+
const isPasswordContext = /(?:register|signup|sign.up|createUser|create.user|changePassword|resetPassword|set.password|validatePassword|validate.password|passwordPolicy|password.policy)/i.test(
|
|
2477
|
+
content
|
|
2478
|
+
) || isServerSideFile(filePath);
|
|
2479
|
+
const matches = [];
|
|
2480
|
+
if (isPasswordContext) {
|
|
2481
|
+
const weakThresholdPatterns = [
|
|
2482
|
+
// password.length < 4, password.length < 6, etc.
|
|
2483
|
+
/(?:password|pwd|passwd)\s*\.length\s*<\s*[1-7]\b/gi,
|
|
2484
|
+
/(?:password|pwd|passwd)\s*\.length\s*<=\s*[0-6]\b/gi,
|
|
2485
|
+
// password.length >= 4, password.length >= 6 (min length set too low)
|
|
2486
|
+
/(?:password|pwd|passwd)\s*\.length\s*>=\s*[1-7]\b/gi,
|
|
2487
|
+
/(?:password|pwd|passwd)\s*\.length\s*>\s*[0-6]\b/gi,
|
|
2488
|
+
// Python: len(password) < 8 with a low threshold
|
|
2489
|
+
/len\s*\(\s*(?:password|pwd|passwd)\s*\)\s*<\s*[1-7]\b/gi
|
|
2490
|
+
];
|
|
2491
|
+
for (const p of weakThresholdPatterns) {
|
|
2492
|
+
matches.push(...findMatches(
|
|
2493
|
+
content,
|
|
2494
|
+
p,
|
|
2495
|
+
weakPasswordRequirements,
|
|
2496
|
+
filePath,
|
|
2497
|
+
() => "Minimum password length is too low. Require at least 8 characters with complexity (upper+lower+digit+symbol) or 12+ characters without complexity. OWASP ASVS L1 requires 8+; NIST 800-63B recommends 12+."
|
|
2498
|
+
));
|
|
2499
|
+
}
|
|
2500
|
+
if (matches.length > 0) return matches;
|
|
2501
|
+
}
|
|
2502
|
+
if (!isPasswordContext) return [];
|
|
1666
2503
|
const hasValidation = /(?:password|pwd).*(?:\.length|minLength|minlength|min_length)\s*(?:>=?|<|>)\s*\d|(?:password|pwd).*(?:match|test|regex|pattern)|zxcvbn|password-validator|passwordStrength|isStrongPassword|joi\.|yup\.|zod\.|validate|schema/i.test(content);
|
|
1667
2504
|
if (hasValidation) return [];
|
|
1668
2505
|
const hasPasswordHandling = /(?:password|pwd)\s*[:=]\s*(?:req\.body|body|input|params|args)\./i.test(content);
|
|
@@ -1695,7 +2532,8 @@ var sessionFixation = {
|
|
|
1695
2532
|
if (isTestFile(filePath)) return [];
|
|
1696
2533
|
if (!/(?:login|signin|sign.in|authenticate)/i.test(content)) return [];
|
|
1697
2534
|
if (!/session/i.test(content)) return [];
|
|
1698
|
-
const
|
|
2535
|
+
const codeOnly = content.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:])\/\/.*$/gm, "$1");
|
|
2536
|
+
const hasRegenerate = /regenerate|destroy.*create|req\.session\.id\s*=|session\.regenerateId|rotateSession|clearCookies/i.test(codeOnly);
|
|
1699
2537
|
if (hasRegenerate) return [];
|
|
1700
2538
|
const hasLogin = /(?:function\s+(?:login|signin|authenticate)|(?:login|signin|authenticate)\s*(?:=\s*(?:async\s*)?\(|:\s*(?:async\s*)?\())/i.test(content);
|
|
1701
2539
|
if (!hasLogin) return [];
|
|
@@ -1718,7 +2556,8 @@ var missingBruteForce = {
|
|
|
1718
2556
|
const isLoginFile = /(?:login|signin|sign.in|auth)/i.test(filePath) || /(?:login|signin|authenticate).*(?:post|handler|route)/i.test(content);
|
|
1719
2557
|
if (!isLoginFile) return [];
|
|
1720
2558
|
if (!/(?:password|credential)/i.test(content)) return [];
|
|
1721
|
-
const
|
|
2559
|
+
const codeOnly = content.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:])\/\/.*$/gm, "$1").replace(/^\s*#.*$/gm, "");
|
|
2560
|
+
const hasBruteForce = /rate.?limit|throttle|lockout|maxAttempts|max_attempts|failedAttempts|loginAttempts|express-brute|express-rate-limit|slowDown/i.test(codeOnly);
|
|
1722
2561
|
if (hasBruteForce) return [];
|
|
1723
2562
|
return findMatches(
|
|
1724
2563
|
content,
|
|
@@ -1745,7 +2584,10 @@ var nosqlInjection = {
|
|
|
1745
2584
|
// $where with user input
|
|
1746
2585
|
/\$where\s*:\s*(?!["'`])/g,
|
|
1747
2586
|
// Direct variable in query without sanitization
|
|
1748
|
-
/\.(?:findOne|findById|deleteOne|updateOne|findOneAndUpdate)\s*\(\s*\{[^}]*:\s*(?:req\.(?:body|query|params))\./gi
|
|
2587
|
+
/\.(?:findOne|findById|deleteOne|updateOne|findOneAndUpdate)\s*\(\s*\{[^}]*:\s*(?:req\.(?:body|query|params))\./gi,
|
|
2588
|
+
// findOneAndUpdate / updateOne / deleteOne / find with req.body as
|
|
2589
|
+
// the entire filter (attacker controls the filter shape).
|
|
2590
|
+
/\.(?:findOne|findById|findOneAndUpdate|updateOne|deleteOne|deleteMany|updateMany|replaceOne|find)\s*\(\s*req\.body\s*[,)]/gi
|
|
1749
2591
|
];
|
|
1750
2592
|
const hasSanitization = /sanitize|escape|mongo-sanitize|express-mongo-sanitize|validator|typeof.*===.*string/i.test(content);
|
|
1751
2593
|
if (hasSanitization) return [];
|
|
@@ -1824,7 +2666,7 @@ var graphqlIntrospection = {
|
|
|
1824
2666
|
category: "Information Leakage",
|
|
1825
2667
|
description: "GraphQL introspection exposes your entire API schema, types, queries, and mutations to attackers, making it easy to find attack vectors.",
|
|
1826
2668
|
check(content, filePath) {
|
|
1827
|
-
if (!/graphql/i.test(content) && !/graphql/i.test(filePath)) return [];
|
|
2669
|
+
if (!/graphql|apollo|ApolloServer|GraphQLServer|createYoga|buildSchema|makeExecutableSchema/i.test(content) && !/graphql|apollo/i.test(filePath)) return [];
|
|
1828
2670
|
const matches = [];
|
|
1829
2671
|
if (/introspection\s*:\s*true/i.test(content)) {
|
|
1830
2672
|
matches.push(...findMatches(
|
|
@@ -1963,6 +2805,15 @@ var exposedSourceMaps = {
|
|
|
1963
2805
|
() => "Set productionSourceMap: false to avoid exposing source code in production."
|
|
1964
2806
|
));
|
|
1965
2807
|
}
|
|
2808
|
+
if (/productionBrowserSourceMaps\s*:\s*true/i.test(content)) {
|
|
2809
|
+
matches.push(...findMatches(
|
|
2810
|
+
content,
|
|
2811
|
+
/productionBrowserSourceMaps\s*:\s*true/gi,
|
|
2812
|
+
exposedSourceMaps,
|
|
2813
|
+
filePath,
|
|
2814
|
+
() => "Set productionBrowserSourceMaps: false in next.config.js. If you need source maps for error tracking, upload them to your error tracker instead (e.g. Sentry upload-sourcemaps) rather than serving them publicly."
|
|
2815
|
+
));
|
|
2816
|
+
}
|
|
1966
2817
|
return matches;
|
|
1967
2818
|
}
|
|
1968
2819
|
};
|
|
@@ -2088,14 +2939,14 @@ var weakHashing = {
|
|
|
2088
2939
|
if (filePath.includes("test") || filePath.includes("mock")) return [];
|
|
2089
2940
|
if (/bcrypt|scrypt|argon2/i.test(content)) return [];
|
|
2090
2941
|
const matches = [];
|
|
2091
|
-
const
|
|
2942
|
+
const inlinePatterns = [
|
|
2092
2943
|
/(?:md5|sha1|sha256|sha512)\s*\([^)]*(?:password|passwd|pwd)/gi,
|
|
2093
2944
|
/createHash\s*\(\s*["'`](?:md5|sha1|sha256)["'`]\).*(?:password|passwd|pwd)/gi,
|
|
2094
2945
|
/(?:password|passwd|pwd).*createHash\s*\(\s*["'`](?:md5|sha1|sha256)["'`]\)/gi,
|
|
2095
2946
|
/hashlib\.(?:md5|sha1|sha256)\s*\([^)]*(?:password|passwd|pwd)/gi,
|
|
2096
2947
|
/Digest::(?:MD5|SHA1|SHA256).*(?:password|passwd|pwd)/gi
|
|
2097
2948
|
];
|
|
2098
|
-
for (const p of
|
|
2949
|
+
for (const p of inlinePatterns) {
|
|
2099
2950
|
matches.push(...findMatches(
|
|
2100
2951
|
content,
|
|
2101
2952
|
p,
|
|
@@ -2104,6 +2955,27 @@ var weakHashing = {
|
|
|
2104
2955
|
() => "Use bcrypt, scrypt, or argon2 for password hashing \u2014 they're intentionally slow. Example: const hash = await bcrypt.hash(password, 12);"
|
|
2105
2956
|
));
|
|
2106
2957
|
}
|
|
2958
|
+
const hasPasswordContext = /\b(?:hashPassword|hash_password|verify_password|verifyPassword|password_hash|password_hash_verify|password_hashing)\b/i.test(content) || /\b(?:function|def|async)\s+\w*(?:password|pwd)\w*/i.test(content) || /\bpassword\s*[:=]/i.test(content);
|
|
2959
|
+
if (hasPasswordContext) {
|
|
2960
|
+
const weakCallPatterns = [
|
|
2961
|
+
/createHash\s*\(\s*["'`](?:md5|sha1|sha256)["'`]\s*\)/gi,
|
|
2962
|
+
/hashlib\.(?:md5|sha1|sha256)\s*\(/gi,
|
|
2963
|
+
/Digest::(?:MD5|SHA1|SHA256)\./gi
|
|
2964
|
+
];
|
|
2965
|
+
for (const p of weakCallPatterns) {
|
|
2966
|
+
const raw = findMatches(
|
|
2967
|
+
content,
|
|
2968
|
+
p,
|
|
2969
|
+
weakHashing,
|
|
2970
|
+
filePath,
|
|
2971
|
+
() => "Use bcrypt, scrypt, or argon2 for password hashing. These algorithms are intentionally slow so brute-force attacks are infeasible."
|
|
2972
|
+
);
|
|
2973
|
+
for (const m of raw) {
|
|
2974
|
+
if (matches.some((existing) => existing.line === m.line)) continue;
|
|
2975
|
+
matches.push(m);
|
|
2976
|
+
}
|
|
2977
|
+
}
|
|
2978
|
+
}
|
|
2107
2979
|
return matches;
|
|
2108
2980
|
}
|
|
2109
2981
|
};
|
|
@@ -2186,7 +3058,8 @@ var dangerousInnerHTML = {
|
|
|
2186
3058
|
if (!filePath.match(/\.(jsx|tsx)$/)) return [];
|
|
2187
3059
|
if (isTestFile(filePath)) return [];
|
|
2188
3060
|
if (!/dangerouslySetInnerHTML/i.test(content)) return [];
|
|
2189
|
-
const
|
|
3061
|
+
const codeOnly = content.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:])\/\/.*$/gm, "$1").replace(/\{\s*\/\*[\s\S]*?\*\/\s*\}/g, "");
|
|
3062
|
+
const hasSanitize = /\b(?:DOMPurify|sanitizeHtml|sanitize-html|isomorphic-dompurify)\b|\b(?:sanitize|purify|xss)\s*\(/i.test(codeOnly);
|
|
2190
3063
|
if (hasSanitize) return [];
|
|
2191
3064
|
const findings = [];
|
|
2192
3065
|
const re = /dangerouslySetInnerHTML\s*=\s*\{\s*\{\s*__html\s*:\s*([^}]+)\}/g;
|
|
@@ -2474,7 +3347,8 @@ var missingCSRF = {
|
|
|
2474
3347
|
() => "Remove @csrf_exempt and use proper CSRF tokens. For APIs, use token-based auth (JWT) instead of session cookies."
|
|
2475
3348
|
));
|
|
2476
3349
|
}
|
|
2477
|
-
|
|
3350
|
+
const pythonCodeOnly = content.replace(/^\s*#.*$/gm, "");
|
|
3351
|
+
if (/MIDDLEWARE.*=.*\[/s.test(pythonCodeOnly) && !/CsrfViewMiddleware/i.test(pythonCodeOnly) && /django/i.test(content)) {
|
|
2478
3352
|
matches.push(...findMatches(
|
|
2479
3353
|
content,
|
|
2480
3354
|
/MIDDLEWARE\s*=/g,
|
|
@@ -2493,20 +3367,47 @@ var githubActionsInjection = {
|
|
|
2493
3367
|
category: "Injection",
|
|
2494
3368
|
description: "Using ${{ github.event.* }} directly in 'run:' steps allows attackers to inject shell commands via PR titles, issue bodies, or branch names.",
|
|
2495
3369
|
check(content, filePath) {
|
|
2496
|
-
|
|
3370
|
+
const isWorkflowPath = /\.github\/workflows\/.*\.(ya?ml)$/i.test(filePath);
|
|
3371
|
+
const looksLikeWorkflow = /\.(ya?ml)$/i.test(filePath) && /^\s*on\s*:/m.test(content) && /^\s*jobs\s*:/m.test(content);
|
|
3372
|
+
if (!isWorkflowPath && !looksLikeWorkflow) return [];
|
|
2497
3373
|
const matches = [];
|
|
2498
3374
|
const patterns = [
|
|
2499
3375
|
/run:.*\$\{\{\s*github\.event\.(?:issue|pull_request|comment|review|head_commit)\.(?:title|body|message)/gi,
|
|
2500
|
-
/run:.*\$\{\{\s*github\.event\.(?:inputs|head_ref|base_ref)/gi
|
|
3376
|
+
/run:.*\$\{\{\s*github\.event\.(?:inputs|head_ref|base_ref)/gi,
|
|
3377
|
+
// Literal-block `run: |` with user-controllable interpolation on the
|
|
3378
|
+
// SAME line or a following indented line. Anchored to `run:` (either
|
|
3379
|
+
// on the same line or within the prior few lines before the
|
|
3380
|
+
// interpolation) so `env: TITLE: ${{ github.event.issue.title }}` —
|
|
3381
|
+
// the recommended-fix shape — doesn't false-positive.
|
|
3382
|
+
/(?:^\s*(?:-\s+)?run:\s*\|?\s*\n(?:(?!^\s*(?:[-]\s*)?(?:env|with|id|name|if|timeout-minutes|continue-on-error|shell|working-directory|uses):).+\n){0,20}\s*.*)?\$\{\{\s*github\.event\.(?:issue|pull_request|comment|review|head_commit)\.(?:title|body|message|name)/gmi
|
|
2501
3383
|
];
|
|
3384
|
+
const lines = content.split("\n");
|
|
3385
|
+
const isEnvMappingLine = (lineNum) => {
|
|
3386
|
+
const lineText = lines[lineNum - 1] ?? "";
|
|
3387
|
+
if (/^\s*env\s*:/.test(lineText)) return true;
|
|
3388
|
+
const indent = lineText.match(/^(\s*)/)?.[1]?.length ?? 0;
|
|
3389
|
+
if (indent === 0) return false;
|
|
3390
|
+
for (let i = lineNum - 2; i >= 0; i--) {
|
|
3391
|
+
const prev = lines[i] ?? "";
|
|
3392
|
+
if (!prev.trim()) continue;
|
|
3393
|
+
const prevIndent = prev.match(/^(\s*)/)?.[1]?.length ?? 0;
|
|
3394
|
+
if (prevIndent < indent) {
|
|
3395
|
+
return /^\s*env\s*:/.test(prev);
|
|
3396
|
+
}
|
|
3397
|
+
}
|
|
3398
|
+
return false;
|
|
3399
|
+
};
|
|
2502
3400
|
for (const p of patterns) {
|
|
2503
|
-
|
|
3401
|
+
for (const m of findMatches(
|
|
2504
3402
|
content,
|
|
2505
3403
|
p,
|
|
2506
3404
|
githubActionsInjection,
|
|
2507
3405
|
filePath,
|
|
2508
3406
|
() => "Never use ${{ github.event.* }} directly in 'run:'. Pass it as an environment variable: env: TITLE: ${{ github.event.issue.title }} then use $TITLE in the script."
|
|
2509
|
-
))
|
|
3407
|
+
)) {
|
|
3408
|
+
if (isEnvMappingLine(m.line)) continue;
|
|
3409
|
+
matches.push(m);
|
|
3410
|
+
}
|
|
2510
3411
|
}
|
|
2511
3412
|
return matches;
|
|
2512
3413
|
}
|
|
@@ -2554,6 +3455,15 @@ var corsServerless = {
|
|
|
2554
3455
|
() => "Replace wildcard CORS with specific origins: allowOrigin: ['https://yourdomain.com']. Wildcard allows any site to call your API."
|
|
2555
3456
|
));
|
|
2556
3457
|
}
|
|
3458
|
+
if (/^\s*origin\s*:\s*['"]\*['"]/m.test(content)) {
|
|
3459
|
+
matches.push(...findMatches(
|
|
3460
|
+
content,
|
|
3461
|
+
/^\s*origin\s*:\s*['"]\*['"]/gim,
|
|
3462
|
+
corsServerless,
|
|
3463
|
+
filePath,
|
|
3464
|
+
() => "Replace the wildcard origin with a concrete allowlist: `origin: https://yourdomain.com` (or an array). Combined with `allowCredentials: true`, wildcard CORS is rejected by the browser; combined with auth headers, it's a data-exfiltration surface."
|
|
3465
|
+
));
|
|
3466
|
+
}
|
|
2557
3467
|
return matches;
|
|
2558
3468
|
}
|
|
2559
3469
|
};
|
|
@@ -2715,27 +3625,55 @@ var xxeVulnerability = {
|
|
|
2715
3625
|
category: "Injection",
|
|
2716
3626
|
description: "XML parsers that process external entities allow attackers to read files, perform SSRF, or cause DoS via billion-laughs attacks.",
|
|
2717
3627
|
check(content, filePath) {
|
|
2718
|
-
if (!/xml|parseXML|DOMParser|SAXParser|etree|lxml/i.test(content)) return [];
|
|
3628
|
+
if (!/xml|parseXml|parseXML|DOMParser|SAXParser|etree|lxml|libxml/i.test(content)) return [];
|
|
2719
3629
|
const matches = [];
|
|
2720
3630
|
const patterns = [
|
|
2721
|
-
/\.
|
|
3631
|
+
/\.parseXm?l\s*\(/gi,
|
|
3632
|
+
// catches parseXml (libxmljs) AND parseXML
|
|
2722
3633
|
/new\s+DOMParser\s*\(\)/g,
|
|
2723
3634
|
/etree\.parse\s*\(/g,
|
|
2724
3635
|
/lxml\.etree/g,
|
|
2725
3636
|
/SAXParserFactory/g,
|
|
2726
3637
|
/XMLReaderFactory/g
|
|
2727
3638
|
];
|
|
2728
|
-
const hasProtection = /noent
|
|
2729
|
-
if (hasProtection)
|
|
2730
|
-
|
|
2731
|
-
|
|
2732
|
-
|
|
2733
|
-
|
|
2734
|
-
|
|
2735
|
-
|
|
2736
|
-
|
|
2737
|
-
|
|
3639
|
+
const hasProtection = /noent\s*:\s*false|resolveExternals\s*:\s*false|FEATURE_EXTERNAL.*false|defusedxml|disallow-doctype-decl|nonet\s*:\s*true/i.test(content);
|
|
3640
|
+
if (!hasProtection) {
|
|
3641
|
+
for (const p of patterns) {
|
|
3642
|
+
matches.push(...findMatches(
|
|
3643
|
+
content,
|
|
3644
|
+
p,
|
|
3645
|
+
xxeVulnerability,
|
|
3646
|
+
filePath,
|
|
3647
|
+
() => "Disable external entities: set noent: false + dtdload: false + nonet: true (libxmljs), or use defusedxml (Python), or factory.setFeature('http://apache.org/xml/features/disallow-doctype-decl', true) (Java)."
|
|
3648
|
+
));
|
|
3649
|
+
}
|
|
2738
3650
|
}
|
|
3651
|
+
if (!/parseXml\s*\(/.test(content)) return matches;
|
|
3652
|
+
const ctx = tryParse(content, filePath);
|
|
3653
|
+
if (!ctx) return matches;
|
|
3654
|
+
visitCalls(
|
|
3655
|
+
ctx.parsed,
|
|
3656
|
+
(callee) => isCalleeNamed(callee, "parseXml") || isCalleeNamed(callee, "parseXML"),
|
|
3657
|
+
(call, line) => {
|
|
3658
|
+
const opts = call.arguments[1];
|
|
3659
|
+
if (!opts || opts.type !== "ObjectExpression") return;
|
|
3660
|
+
const noent = getObjectProperty(opts, "noent");
|
|
3661
|
+
const dtdload = getObjectProperty(opts, "dtdload");
|
|
3662
|
+
const external = getObjectProperty(opts, "resolveExternals");
|
|
3663
|
+
const dangerous = noent?.value.type === "BooleanLiteral" && noent.value.value === true || dtdload?.value.type === "BooleanLiteral" && dtdload.value.value === true || external?.value.type === "BooleanLiteral" && external.value.value === true;
|
|
3664
|
+
if (!dangerous) return;
|
|
3665
|
+
if (matches.some((m) => m.line === line)) return;
|
|
3666
|
+
matches.push(
|
|
3667
|
+
astMatch(
|
|
3668
|
+
content,
|
|
3669
|
+
filePath,
|
|
3670
|
+
line,
|
|
3671
|
+
xxeVulnerability,
|
|
3672
|
+
"Set noent: false, dtdload: false, and nonet: true on the parser options to disable external-entity resolution."
|
|
3673
|
+
)
|
|
3674
|
+
);
|
|
3675
|
+
}
|
|
3676
|
+
);
|
|
2739
3677
|
return matches;
|
|
2740
3678
|
}
|
|
2741
3679
|
};
|
|
@@ -2764,6 +3702,46 @@ var ssti = {
|
|
|
2764
3702
|
() => "Never render templates from user input. Use pre-defined templates and pass data as context variables: render_template('template.html', data=user_data)."
|
|
2765
3703
|
));
|
|
2766
3704
|
}
|
|
3705
|
+
if (!/(?:\.compile|\.render|renderString|render_template_string)\s*\(/.test(content)) {
|
|
3706
|
+
return matches;
|
|
3707
|
+
}
|
|
3708
|
+
const ctx = tryParse(content, filePath);
|
|
3709
|
+
if (!ctx) return matches;
|
|
3710
|
+
const { parsed, taint } = ctx;
|
|
3711
|
+
const SSTI_METHODS = /* @__PURE__ */ new Set([
|
|
3712
|
+
"compile",
|
|
3713
|
+
// Handlebars.compile, pug.compile, _.template (returns a function)
|
|
3714
|
+
"render",
|
|
3715
|
+
// ejs.render, engine.render, mustache.render
|
|
3716
|
+
"renderString",
|
|
3717
|
+
// nunjucks.renderString
|
|
3718
|
+
"render_template_string"
|
|
3719
|
+
]);
|
|
3720
|
+
visitCalls(
|
|
3721
|
+
parsed,
|
|
3722
|
+
(callee) => {
|
|
3723
|
+
if (callee.type === "Identifier" && SSTI_METHODS.has(callee.name)) return true;
|
|
3724
|
+
if (callee.type === "MemberExpression" && callee.property.type === "Identifier") {
|
|
3725
|
+
return SSTI_METHODS.has(callee.property.name);
|
|
3726
|
+
}
|
|
3727
|
+
return false;
|
|
3728
|
+
},
|
|
3729
|
+
(call, line) => {
|
|
3730
|
+
const first = call.arguments[0];
|
|
3731
|
+
if (!first || first.type === "SpreadElement") return;
|
|
3732
|
+
if (!taint.isTainted(first)) return;
|
|
3733
|
+
if (matches.some((m) => m.line === line)) return;
|
|
3734
|
+
matches.push(
|
|
3735
|
+
astMatch(
|
|
3736
|
+
content,
|
|
3737
|
+
filePath,
|
|
3738
|
+
line,
|
|
3739
|
+
ssti,
|
|
3740
|
+
"Compile templates from a trusted, static source (a file path or a constant string). Pass user data only as context values."
|
|
3741
|
+
)
|
|
3742
|
+
);
|
|
3743
|
+
}
|
|
3744
|
+
);
|
|
2767
3745
|
return matches;
|
|
2768
3746
|
}
|
|
2769
3747
|
};
|
|
@@ -2833,7 +3811,7 @@ var exposedAdminRoutes = {
|
|
|
2833
3811
|
category: "Information Leakage",
|
|
2834
3812
|
description: "Routes like /admin, /debug, /phpinfo, or /actuator without authentication expose sensitive controls and information to attackers.",
|
|
2835
3813
|
check(content, filePath) {
|
|
2836
|
-
if (
|
|
3814
|
+
if (!isServerSideFile(filePath)) return [];
|
|
2837
3815
|
const matches = [];
|
|
2838
3816
|
const patterns = [
|
|
2839
3817
|
/[.'"]\s*(?:get|use|all)\s*\(\s*["'`]\/(?:admin|debug|_debug|__debug__|phpinfo|actuator|graphiql|playground|swagger|reset|seed|test|dev|mock)["'`]/gi
|
|
@@ -2900,9 +3878,10 @@ var sensitiveURLParams = {
|
|
|
2900
3878
|
if (isTestFile(filePath)) return [];
|
|
2901
3879
|
const matches = [];
|
|
2902
3880
|
const patterns = [
|
|
2903
|
-
//
|
|
2904
|
-
/[
|
|
2905
|
-
|
|
3881
|
+
// Template literals embedding sensitive values in URL query strings
|
|
3882
|
+
/[?&](?:password|passwd|pwd|secret|api[_-]?key|access[_-]?token|refresh[_-]?token|auth[_-]?token|session(?:[_-]?id)?|token|jwt|ssn|credit[_-]?card)\s*=\s*\$\{/gi,
|
|
3883
|
+
// String concat building the same
|
|
3884
|
+
/[?&](?:password|passwd|pwd|secret|api[_-]?key|access[_-]?token|refresh[_-]?token|auth[_-]?token|session(?:[_-]?id)?|token|jwt|ssn|credit[_-]?card)\s*=["']\s*\+/gi
|
|
2906
3885
|
];
|
|
2907
3886
|
for (const p of patterns) {
|
|
2908
3887
|
matches.push(...findMatches(
|
|
@@ -2924,7 +3903,7 @@ var missingContentDisposition = {
|
|
|
2924
3903
|
description: "File download endpoints without Content-Disposition headers may render files inline, leading to XSS if the file contains HTML/JS.",
|
|
2925
3904
|
check(content, filePath) {
|
|
2926
3905
|
if (!/(?:download|sendFile|send_file|pipe|createReadStream)/i.test(content)) return [];
|
|
2927
|
-
if (
|
|
3906
|
+
if (!isServerSideFile(filePath)) return [];
|
|
2928
3907
|
if (/Content-Disposition|attachment|download/i.test(content)) return [];
|
|
2929
3908
|
return findMatches(
|
|
2930
3909
|
content,
|
|
@@ -2966,9 +3945,13 @@ var raceCondition = {
|
|
|
2966
3945
|
if (filePath.includes("test") || filePath.includes("mock")) return [];
|
|
2967
3946
|
const matches = [];
|
|
2968
3947
|
const patterns = [
|
|
2969
|
-
/
|
|
2970
|
-
/
|
|
2971
|
-
|
|
3948
|
+
// Check-then-act with writes / deletes / renames
|
|
3949
|
+
/(?:existsSync|exists)\s*\([^)]+\)[\s\S]{0,80}(?:writeFileSync|writeFile|unlinkSync|unlink|renameSync|rename)\s*\(/g,
|
|
3950
|
+
// Check-then-read — also vulnerable: an attacker can swap the symlink
|
|
3951
|
+
// between the exists/stat call and the read.
|
|
3952
|
+
/(?:existsSync|statSync)\s*\([^)]+\)[\s\S]{0,80}(?:readFileSync|readFile|createReadStream)\s*\(/g,
|
|
3953
|
+
/os\.path\.exists\s*\([^)]+\)[\s\S]{0,80}open\s*\(/g,
|
|
3954
|
+
/File\.exists\?\s*\([^)]+\)[\s\S]{0,80}File\.(?:write|delete|read)/g
|
|
2972
3955
|
];
|
|
2973
3956
|
for (const p of patterns) {
|
|
2974
3957
|
matches.push(...findMatches(
|
|
@@ -3017,7 +4000,7 @@ var unprotectedDownload = {
|
|
|
3017
4000
|
description: "File download endpoints that accept user-controlled filenames without path validation allow directory traversal to read arbitrary files.",
|
|
3018
4001
|
check(content, filePath) {
|
|
3019
4002
|
if (!/(?:download|sendFile|send_file)/i.test(content)) return [];
|
|
3020
|
-
if (
|
|
4003
|
+
if (!isServerSideFile(filePath)) return [];
|
|
3021
4004
|
const matches = [];
|
|
3022
4005
|
if (/(?:sendFile|download|send_file)\s*\([^)]*(?:req\.|params\.|query\.|body\.)/i.test(content)) {
|
|
3023
4006
|
const hasValidation = /path\.resolve|path\.normalize|path\.join.*__dirname|realpath|includes\s*\(\s*["']\.\./i.test(content);
|
|
@@ -3279,7 +4262,111 @@ var complianceMap = {
|
|
|
3279
4262
|
VC155: { owasp: "A04:2021", cwe: "CWE-770" },
|
|
3280
4263
|
VC156: { owasp: "A04:2021", cwe: "CWE-770" },
|
|
3281
4264
|
VC157: { owasp: "A05:2021", cwe: "CWE-16" },
|
|
3282
|
-
VC158: { owasp: "A01:2021", cwe: "CWE-639" }
|
|
4265
|
+
VC158: { owasp: "A01:2021", cwe: "CWE-639" },
|
|
4266
|
+
// VC159–VC183: Additional service-specific API key detection.
|
|
4267
|
+
// All map to A07:2021 (Identification & Auth Failures) / CWE-798 (Hardcoded
|
|
4268
|
+
// Credentials), matching the existing VC132–VC145 cluster.
|
|
4269
|
+
VC159: { owasp: "A07:2021", cwe: "CWE-798" },
|
|
4270
|
+
// Cohere
|
|
4271
|
+
VC160: { owasp: "A07:2021", cwe: "CWE-798" },
|
|
4272
|
+
// Replicate
|
|
4273
|
+
VC161: { owasp: "A07:2021", cwe: "CWE-798" },
|
|
4274
|
+
// Mistral
|
|
4275
|
+
VC162: { owasp: "A07:2021", cwe: "CWE-798" },
|
|
4276
|
+
// Together AI
|
|
4277
|
+
VC163: { owasp: "A07:2021", cwe: "CWE-798" },
|
|
4278
|
+
// Groq
|
|
4279
|
+
VC164: { owasp: "A07:2021", cwe: "CWE-798" },
|
|
4280
|
+
// Fireworks AI
|
|
4281
|
+
VC165: { owasp: "A07:2021", cwe: "CWE-798" },
|
|
4282
|
+
// Postmark
|
|
4283
|
+
VC166: { owasp: "A07:2021", cwe: "CWE-798" },
|
|
4284
|
+
// Resend
|
|
4285
|
+
VC167: { owasp: "A07:2021", cwe: "CWE-798" },
|
|
4286
|
+
// Loops
|
|
4287
|
+
VC168: { owasp: "A07:2021", cwe: "CWE-798" },
|
|
4288
|
+
// Cloudflare
|
|
4289
|
+
VC169: { owasp: "A07:2021", cwe: "CWE-798" },
|
|
4290
|
+
// Fastly
|
|
4291
|
+
VC170: { owasp: "A07:2021", cwe: "CWE-798" },
|
|
4292
|
+
// Netlify
|
|
4293
|
+
VC171: { owasp: "A07:2021", cwe: "CWE-798" },
|
|
4294
|
+
// Railway
|
|
4295
|
+
VC172: { owasp: "A07:2021", cwe: "CWE-798" },
|
|
4296
|
+
// Fly.io
|
|
4297
|
+
VC173: { owasp: "A07:2021", cwe: "CWE-798" },
|
|
4298
|
+
// Algolia admin key
|
|
4299
|
+
VC174: { owasp: "A07:2021", cwe: "CWE-798" },
|
|
4300
|
+
// Qdrant
|
|
4301
|
+
VC175: { owasp: "A07:2021", cwe: "CWE-798" },
|
|
4302
|
+
// Weaviate
|
|
4303
|
+
VC176: { owasp: "A07:2021", cwe: "CWE-798" },
|
|
4304
|
+
// Linear
|
|
4305
|
+
VC177: { owasp: "A07:2021", cwe: "CWE-798" },
|
|
4306
|
+
// Notion
|
|
4307
|
+
VC178: { owasp: "A07:2021", cwe: "CWE-798" },
|
|
4308
|
+
// Discord bot token
|
|
4309
|
+
VC179: { owasp: "A07:2021", cwe: "CWE-798" },
|
|
4310
|
+
// Intercom
|
|
4311
|
+
VC180: { owasp: "A07:2021", cwe: "CWE-798" },
|
|
4312
|
+
// Sentry auth token
|
|
4313
|
+
VC181: { owasp: "A07:2021", cwe: "CWE-798" },
|
|
4314
|
+
// Logtail / Better Stack
|
|
4315
|
+
VC182: { owasp: "A07:2021", cwe: "CWE-798" },
|
|
4316
|
+
// Highlight
|
|
4317
|
+
VC183: { owasp: "A07:2021", cwe: "CWE-798" },
|
|
4318
|
+
// Plivo
|
|
4319
|
+
// VC184–VC187: GitHub Actions workflow security
|
|
4320
|
+
VC184: { owasp: "A08:2021", cwe: "CWE-829" },
|
|
4321
|
+
// pull_request_target + checkout PR head
|
|
4322
|
+
VC185: { owasp: "A05:2021", cwe: "CWE-732" },
|
|
4323
|
+
// permissions: write-all
|
|
4324
|
+
VC186: { owasp: "A03:2021", cwe: "CWE-78" },
|
|
4325
|
+
// expression injection in run:
|
|
4326
|
+
VC187: { owasp: "A08:2021", cwe: "CWE-829" },
|
|
4327
|
+
// third-party action receiving secrets
|
|
4328
|
+
// VC188–VC190: Dockerfile hardening
|
|
4329
|
+
VC188: { owasp: "A05:2021", cwe: "CWE-1357" },
|
|
4330
|
+
// ADD instead of COPY
|
|
4331
|
+
VC189: { owasp: "A08:2021", cwe: "CWE-494" },
|
|
4332
|
+
// RUN curl|sh
|
|
4333
|
+
VC190: { owasp: "A04:2021", cwe: "CWE-754" },
|
|
4334
|
+
// missing HEALTHCHECK
|
|
4335
|
+
// VC191–VC197: Python-specific security gaps
|
|
4336
|
+
VC191: { owasp: "A02:2021", cwe: "CWE-295" },
|
|
4337
|
+
// requests verify=False
|
|
4338
|
+
VC192: { owasp: "A03:2021", cwe: "CWE-79" },
|
|
4339
|
+
// Jinja2 autoescape=False
|
|
4340
|
+
VC193: { owasp: "A04:2021", cwe: "CWE-377" },
|
|
4341
|
+
// tempfile.mktemp TOCTOU
|
|
4342
|
+
VC194: { owasp: "A03:2021", cwe: "CWE-79" },
|
|
4343
|
+
// Django mark_safe
|
|
4344
|
+
VC195: { owasp: "A02:2021", cwe: "CWE-295" },
|
|
4345
|
+
// paramiko AutoAddPolicy
|
|
4346
|
+
VC196: { owasp: "A05:2021", cwe: "CWE-20" },
|
|
4347
|
+
// ALLOWED_HOSTS wildcard
|
|
4348
|
+
VC197: { owasp: "A02:2021", cwe: "CWE-347" },
|
|
4349
|
+
// PyJWT no algorithm allowlist
|
|
4350
|
+
// VC198–VC203: AI / LLM-specific security
|
|
4351
|
+
VC198: { owasp: "A03:2021", cwe: "CWE-94" },
|
|
4352
|
+
// prompt injection via user input
|
|
4353
|
+
VC199: { owasp: "A03:2021", cwe: "CWE-94" },
|
|
4354
|
+
// system-prompt injection
|
|
4355
|
+
VC200: { owasp: "A03:2021", cwe: "CWE-79" },
|
|
4356
|
+
// LLM output as raw HTML (XSS)
|
|
4357
|
+
VC201: { owasp: "A01:2021", cwe: "CWE-639" },
|
|
4358
|
+
// vector-store query without user filter
|
|
4359
|
+
VC202: { owasp: "A01:2021", cwe: "CWE-639" },
|
|
4360
|
+
// vector-store upsert without user metadata
|
|
4361
|
+
VC203: { owasp: "A04:2021", cwe: "CWE-770" },
|
|
4362
|
+
// LLM call without max_tokens
|
|
4363
|
+
// VC204–VC206: GraphQL server hardening
|
|
4364
|
+
VC204: { owasp: "A04:2021", cwe: "CWE-770" },
|
|
4365
|
+
// no query depth limit
|
|
4366
|
+
VC205: { owasp: "A04:2021", cwe: "CWE-770" },
|
|
4367
|
+
// no query complexity limit
|
|
4368
|
+
VC206: { owasp: "A01:2021", cwe: "CWE-352" }
|
|
4369
|
+
// Apollo csrfPrevention: false
|
|
3283
4370
|
};
|
|
3284
4371
|
var consoleLogProduction = {
|
|
3285
4372
|
id: "VC097",
|
|
@@ -4624,208 +5711,1535 @@ var hardcodedPineconeKey = {
|
|
|
4624
5711
|
);
|
|
4625
5712
|
}
|
|
4626
5713
|
};
|
|
4627
|
-
|
|
4628
|
-
|
|
4629
|
-
|
|
4630
|
-
|
|
5714
|
+
function contextSecretRuleCheck(content, filePath, pattern, ruleId, title, severity, fix) {
|
|
5715
|
+
if (isTestFile(filePath)) return [];
|
|
5716
|
+
if (!SECRET_FILE_EXT.test(filePath)) return [];
|
|
5717
|
+
if (LOCK_FILE_RE.test(filePath)) return [];
|
|
5718
|
+
const findings = [];
|
|
5719
|
+
const re = new RegExp(pattern.source, pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`);
|
|
5720
|
+
let m;
|
|
5721
|
+
while ((m = re.exec(content)) !== null) {
|
|
5722
|
+
if (isCommentLine(content, m.index)) continue;
|
|
5723
|
+
if (isInsideFixMessage(content, m.index)) continue;
|
|
5724
|
+
const lineStart = content.lastIndexOf("\n", m.index - 1) + 1;
|
|
5725
|
+
const lineText = content.substring(lineStart, content.indexOf("\n", m.index));
|
|
5726
|
+
if (PLACEHOLDER_RE.test(lineText)) continue;
|
|
5727
|
+
const lineNum = content.substring(0, m.index).split("\n").length;
|
|
5728
|
+
findings.push({
|
|
5729
|
+
rule: ruleId,
|
|
5730
|
+
title,
|
|
5731
|
+
severity,
|
|
5732
|
+
category: "Secrets",
|
|
5733
|
+
file: filePath,
|
|
5734
|
+
line: lineNum,
|
|
5735
|
+
snippet: getSnippet(content, lineNum),
|
|
5736
|
+
fix
|
|
5737
|
+
});
|
|
5738
|
+
}
|
|
5739
|
+
return findings;
|
|
5740
|
+
}
|
|
5741
|
+
var hardcodedCohereKey = {
|
|
5742
|
+
id: "VC159",
|
|
5743
|
+
title: "Hardcoded Cohere API Key",
|
|
5744
|
+
severity: "high",
|
|
4631
5745
|
category: "Secrets",
|
|
4632
|
-
description: "API keys
|
|
5746
|
+
description: "Cohere API keys grant access to all model endpoints (generation, embeddings, classification) and incur charges per token. A leaked key can be used to run unlimited inference at your expense.",
|
|
4633
5747
|
check(content, filePath) {
|
|
4634
|
-
|
|
4635
|
-
|
|
4636
|
-
|
|
4637
|
-
|
|
4638
|
-
|
|
4639
|
-
|
|
4640
|
-
|
|
4641
|
-
|
|
4642
|
-
|
|
4643
|
-
/[?&](?:api_key|apikey|token|access_token|secret|key|password)=\{[a-zA-Z_]/g
|
|
4644
|
-
];
|
|
4645
|
-
for (const p of patterns) {
|
|
4646
|
-
let m;
|
|
4647
|
-
const re = new RegExp(p.source, p.flags.includes("g") ? p.flags : `${p.flags}g`);
|
|
4648
|
-
while ((m = re.exec(content)) !== null) {
|
|
4649
|
-
if (isCommentLine(content, m.index)) continue;
|
|
4650
|
-
if (isInsideFixMessage(content, m.index)) continue;
|
|
4651
|
-
const lineNum = content.substring(0, m.index).split("\n").length;
|
|
4652
|
-
findings.push({
|
|
4653
|
-
rule: "VC146",
|
|
4654
|
-
title: this.title,
|
|
4655
|
-
severity: "critical",
|
|
4656
|
-
category: "Secrets",
|
|
4657
|
-
file: filePath,
|
|
4658
|
-
line: lineNum,
|
|
4659
|
-
snippet: getSnippet(content, lineNum),
|
|
4660
|
-
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."
|
|
4661
|
-
});
|
|
4662
|
-
}
|
|
4663
|
-
}
|
|
4664
|
-
return findings;
|
|
5748
|
+
return contextSecretRuleCheck(
|
|
5749
|
+
content,
|
|
5750
|
+
filePath,
|
|
5751
|
+
/(?:COHERE_API_KEY|cohere[_-]?api[_-]?key)\s*[:=]\s*["'`]([A-Za-z0-9]{40})["'`]/gi,
|
|
5752
|
+
"VC159",
|
|
5753
|
+
this.title,
|
|
5754
|
+
"high",
|
|
5755
|
+
"Move the Cohere API key to an environment variable (COHERE_API_KEY). Rotate at dashboard.cohere.com \u2192 API keys."
|
|
5756
|
+
);
|
|
4665
5757
|
}
|
|
4666
5758
|
};
|
|
4667
|
-
var
|
|
4668
|
-
id: "
|
|
4669
|
-
title: "
|
|
5759
|
+
var hardcodedReplicateKey = {
|
|
5760
|
+
id: "VC160",
|
|
5761
|
+
title: "Hardcoded Replicate API Token",
|
|
4670
5762
|
severity: "high",
|
|
4671
5763
|
category: "Secrets",
|
|
4672
|
-
description: "
|
|
5764
|
+
description: "Replicate API tokens (r8_*) grant access to run any model in the Replicate catalog and incur charges per second of GPU time. A leaked token can run expensive image/video models at your expense.",
|
|
4673
5765
|
check(content, filePath) {
|
|
4674
|
-
|
|
4675
|
-
|
|
4676
|
-
|
|
4677
|
-
|
|
4678
|
-
|
|
4679
|
-
|
|
4680
|
-
|
|
4681
|
-
|
|
4682
|
-
|
|
4683
|
-
findings.push({
|
|
4684
|
-
rule: "VC147",
|
|
4685
|
-
title: this.title,
|
|
4686
|
-
severity: "high",
|
|
4687
|
-
category: "Secrets",
|
|
4688
|
-
file: filePath,
|
|
4689
|
-
line: lineNum,
|
|
4690
|
-
snippet: getSnippet(content, lineNum),
|
|
4691
|
-
fix: "Remove this console statement or redact the secret before logging. Use structured logging with a secret-redaction filter in production."
|
|
4692
|
-
});
|
|
4693
|
-
}
|
|
4694
|
-
return findings;
|
|
5766
|
+
return secretRuleCheck(
|
|
5767
|
+
content,
|
|
5768
|
+
filePath,
|
|
5769
|
+
/r8_[A-Za-z0-9]{40,}/g,
|
|
5770
|
+
"VC160",
|
|
5771
|
+
this.title,
|
|
5772
|
+
"high",
|
|
5773
|
+
"Move the Replicate token to an environment variable (REPLICATE_API_TOKEN). Rotate at replicate.com \u2192 Account \u2192 API tokens."
|
|
5774
|
+
);
|
|
4695
5775
|
}
|
|
4696
5776
|
};
|
|
4697
|
-
var
|
|
4698
|
-
id: "
|
|
4699
|
-
title: "
|
|
4700
|
-
severity: "
|
|
5777
|
+
var hardcodedMistralKey = {
|
|
5778
|
+
id: "VC161",
|
|
5779
|
+
title: "Hardcoded Mistral API Key",
|
|
5780
|
+
severity: "high",
|
|
4701
5781
|
category: "Secrets",
|
|
4702
|
-
description: "
|
|
5782
|
+
description: "Mistral API keys grant access to all chat and embedding models and incur charges per token. A leaked key can be used to run unlimited inference at your expense.",
|
|
4703
5783
|
check(content, filePath) {
|
|
4704
|
-
|
|
4705
|
-
|
|
4706
|
-
|
|
4707
|
-
|
|
4708
|
-
|
|
4709
|
-
|
|
4710
|
-
|
|
4711
|
-
|
|
4712
|
-
|
|
4713
|
-
findings.push({
|
|
4714
|
-
rule: "VC148",
|
|
4715
|
-
title: this.title,
|
|
4716
|
-
severity: "critical",
|
|
4717
|
-
category: "Secrets",
|
|
4718
|
-
file: filePath,
|
|
4719
|
-
line: lineNum,
|
|
4720
|
-
snippet: getSnippet(content, lineNum),
|
|
4721
|
-
fix: "Never include secrets in API responses. Return a generic error message and log the detailed error server-side only."
|
|
4722
|
-
});
|
|
4723
|
-
}
|
|
4724
|
-
return findings;
|
|
5784
|
+
return contextSecretRuleCheck(
|
|
5785
|
+
content,
|
|
5786
|
+
filePath,
|
|
5787
|
+
/(?:MISTRAL_API_KEY|mistral[_-]?api[_-]?key)\s*[:=]\s*["'`]([A-Za-z0-9]{32})["'`]/gi,
|
|
5788
|
+
"VC161",
|
|
5789
|
+
this.title,
|
|
5790
|
+
"high",
|
|
5791
|
+
"Move the Mistral key to an environment variable (MISTRAL_API_KEY). Rotate at console.mistral.ai \u2192 API Keys."
|
|
5792
|
+
);
|
|
4725
5793
|
}
|
|
4726
5794
|
};
|
|
4727
|
-
var
|
|
4728
|
-
id: "
|
|
4729
|
-
title: "
|
|
4730
|
-
severity: "
|
|
5795
|
+
var hardcodedTogetherKey = {
|
|
5796
|
+
id: "VC162",
|
|
5797
|
+
title: "Hardcoded Together AI API Key",
|
|
5798
|
+
severity: "high",
|
|
4731
5799
|
category: "Secrets",
|
|
4732
|
-
description: "
|
|
5800
|
+
description: "Together AI API keys grant access to all open-source models hosted on the platform (Llama, Mixtral, Stable Diffusion, etc.) and incur charges per token. A leaked key can run unlimited inference at your expense.",
|
|
4733
5801
|
check(content, filePath) {
|
|
4734
|
-
|
|
4735
|
-
|
|
4736
|
-
|
|
4737
|
-
|
|
4738
|
-
|
|
4739
|
-
|
|
4740
|
-
|
|
4741
|
-
|
|
4742
|
-
|
|
4743
|
-
/publicRuntimeConfig\s*:\s*\{[^}]*\b(?:apiKey|secret|token|password|privateKey|clientSecret)\b/gi
|
|
4744
|
-
];
|
|
4745
|
-
for (const p of patterns) {
|
|
4746
|
-
let m;
|
|
4747
|
-
const re = new RegExp(p.source, p.flags.includes("g") ? p.flags : `${p.flags}g`);
|
|
4748
|
-
while ((m = re.exec(content)) !== null) {
|
|
4749
|
-
if (isCommentLine(content, m.index)) continue;
|
|
4750
|
-
if (isInsideFixMessage(content, m.index)) continue;
|
|
4751
|
-
const lineNum = content.substring(0, m.index).split("\n").length;
|
|
4752
|
-
findings.push({
|
|
4753
|
-
rule: "VC149",
|
|
4754
|
-
title: this.title,
|
|
4755
|
-
severity: "critical",
|
|
4756
|
-
category: "Secrets",
|
|
4757
|
-
file: filePath,
|
|
4758
|
-
line: lineNum,
|
|
4759
|
-
snippet: getSnippet(content, lineNum),
|
|
4760
|
-
fix: "Move secrets to server-side environment variables. Only expose public configuration (like API base URLs) in client-side bundle config. Use serverRuntimeConfig (Next.js) or server-only modules."
|
|
4761
|
-
});
|
|
4762
|
-
}
|
|
4763
|
-
}
|
|
4764
|
-
return findings;
|
|
5802
|
+
return contextSecretRuleCheck(
|
|
5803
|
+
content,
|
|
5804
|
+
filePath,
|
|
5805
|
+
/(?:TOGETHER_API_KEY|together[_-]?api[_-]?key)\s*[:=]\s*["'`]([a-f0-9]{64})["'`]/gi,
|
|
5806
|
+
"VC162",
|
|
5807
|
+
this.title,
|
|
5808
|
+
"high",
|
|
5809
|
+
"Move the Together AI key to an environment variable (TOGETHER_API_KEY). Rotate at api.together.ai \u2192 Settings \u2192 API Keys."
|
|
5810
|
+
);
|
|
4765
5811
|
}
|
|
4766
5812
|
};
|
|
4767
|
-
var
|
|
4768
|
-
id: "
|
|
4769
|
-
title: "
|
|
5813
|
+
var hardcodedGroqKey = {
|
|
5814
|
+
id: "VC163",
|
|
5815
|
+
title: "Hardcoded Groq API Key",
|
|
4770
5816
|
severity: "high",
|
|
4771
5817
|
category: "Secrets",
|
|
4772
|
-
description: "API keys
|
|
5818
|
+
description: "Groq API keys (gsk_*) grant access to all hosted models on Groq's LPU inference platform and incur per-token charges. A leaked key can be used to run unlimited inference at your expense.",
|
|
4773
5819
|
check(content, filePath) {
|
|
4774
|
-
|
|
4775
|
-
|
|
4776
|
-
|
|
4777
|
-
|
|
4778
|
-
|
|
4779
|
-
|
|
4780
|
-
|
|
4781
|
-
|
|
4782
|
-
|
|
4783
|
-
for (const p of patterns) {
|
|
4784
|
-
let m;
|
|
4785
|
-
const re = new RegExp(p.source, p.flags.includes("g") ? p.flags : `${p.flags}g`);
|
|
4786
|
-
while ((m = re.exec(content)) !== null) {
|
|
4787
|
-
if (isCommentLine(content, m.index)) continue;
|
|
4788
|
-
if (isInsideFixMessage(content, m.index)) continue;
|
|
4789
|
-
const lineNum = content.substring(0, m.index).split("\n").length;
|
|
4790
|
-
findings.push({
|
|
4791
|
-
rule: "VC150",
|
|
4792
|
-
title: this.title,
|
|
4793
|
-
severity: "high",
|
|
4794
|
-
category: "Secrets",
|
|
4795
|
-
file: filePath,
|
|
4796
|
-
line: lineNum,
|
|
4797
|
-
snippet: getSnippet(content, lineNum),
|
|
4798
|
-
fix: "Never embed secrets in HTML attributes or meta tags. Load configuration from server-side APIs or use server-rendered environment injection with non-secret values only."
|
|
4799
|
-
});
|
|
4800
|
-
}
|
|
4801
|
-
}
|
|
4802
|
-
return findings;
|
|
5820
|
+
return secretRuleCheck(
|
|
5821
|
+
content,
|
|
5822
|
+
filePath,
|
|
5823
|
+
/gsk_[A-Za-z0-9]{52,}/g,
|
|
5824
|
+
"VC163",
|
|
5825
|
+
this.title,
|
|
5826
|
+
"high",
|
|
5827
|
+
"Move the Groq key to an environment variable (GROQ_API_KEY). Rotate at console.groq.com \u2192 API Keys."
|
|
5828
|
+
);
|
|
4803
5829
|
}
|
|
4804
5830
|
};
|
|
4805
|
-
var
|
|
4806
|
-
id: "
|
|
4807
|
-
title: "
|
|
5831
|
+
var hardcodedFireworksKey = {
|
|
5832
|
+
id: "VC164",
|
|
5833
|
+
title: "Hardcoded Fireworks AI API Key",
|
|
4808
5834
|
severity: "high",
|
|
4809
5835
|
category: "Secrets",
|
|
4810
|
-
description: "
|
|
5836
|
+
description: "Fireworks AI API keys (fw_*) grant access to hosted open-source models and incur per-token charges. A leaked key can be used to run unlimited inference at your expense.",
|
|
4811
5837
|
check(content, filePath) {
|
|
4812
|
-
|
|
4813
|
-
|
|
4814
|
-
|
|
4815
|
-
|
|
4816
|
-
|
|
4817
|
-
|
|
4818
|
-
|
|
4819
|
-
|
|
4820
|
-
|
|
4821
|
-
|
|
4822
|
-
|
|
4823
|
-
|
|
4824
|
-
|
|
4825
|
-
|
|
4826
|
-
|
|
4827
|
-
|
|
4828
|
-
|
|
5838
|
+
return secretRuleCheck(
|
|
5839
|
+
content,
|
|
5840
|
+
filePath,
|
|
5841
|
+
/fw_[A-Za-z0-9]{20,}/g,
|
|
5842
|
+
"VC164",
|
|
5843
|
+
this.title,
|
|
5844
|
+
"high",
|
|
5845
|
+
"Move the Fireworks key to an environment variable (FIREWORKS_API_KEY). Rotate at fireworks.ai \u2192 API Keys."
|
|
5846
|
+
);
|
|
5847
|
+
}
|
|
5848
|
+
};
|
|
5849
|
+
var hardcodedPostmarkKey = {
|
|
5850
|
+
id: "VC165",
|
|
5851
|
+
title: "Hardcoded Postmark Server Token",
|
|
5852
|
+
severity: "high",
|
|
5853
|
+
category: "Secrets",
|
|
5854
|
+
description: "Postmark server tokens grant the ability to send emails as your domain, view delivery logs, and manage templates. A leaked token enables phishing from your verified sending domain.",
|
|
5855
|
+
check(content, filePath) {
|
|
5856
|
+
return contextSecretRuleCheck(
|
|
5857
|
+
content,
|
|
5858
|
+
filePath,
|
|
5859
|
+
/(?:POSTMARK_(?:API|SERVER)_TOKEN|postmark[_-]?(?:server|api)[_-]?token)\s*[:=]\s*["'`]([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})["'`]/gi,
|
|
5860
|
+
"VC165",
|
|
5861
|
+
this.title,
|
|
5862
|
+
"high",
|
|
5863
|
+
"Move the Postmark token to an environment variable (POSTMARK_SERVER_TOKEN). Rotate at account.postmarkapp.com \u2192 Servers \u2192 API Tokens."
|
|
5864
|
+
);
|
|
5865
|
+
}
|
|
5866
|
+
};
|
|
5867
|
+
var hardcodedResendKey = {
|
|
5868
|
+
id: "VC166",
|
|
5869
|
+
title: "Hardcoded Resend API Key",
|
|
5870
|
+
severity: "high",
|
|
5871
|
+
category: "Secrets",
|
|
5872
|
+
description: "Resend API keys (re_*) can send emails as your domain, access email logs, and manage domains. A leaked key enables phishing from your verified sending domain.",
|
|
5873
|
+
check(content, filePath) {
|
|
5874
|
+
return secretRuleCheck(
|
|
5875
|
+
content,
|
|
5876
|
+
filePath,
|
|
5877
|
+
/re_[A-Za-z0-9_]{20,}/g,
|
|
5878
|
+
"VC166",
|
|
5879
|
+
this.title,
|
|
5880
|
+
"high",
|
|
5881
|
+
"Move the Resend API key to an environment variable (RESEND_API_KEY). Rotate at resend.com \u2192 API Keys."
|
|
5882
|
+
);
|
|
5883
|
+
}
|
|
5884
|
+
};
|
|
5885
|
+
var hardcodedLoopsKey = {
|
|
5886
|
+
id: "VC167",
|
|
5887
|
+
title: "Hardcoded Loops API Key",
|
|
5888
|
+
severity: "high",
|
|
5889
|
+
category: "Secrets",
|
|
5890
|
+
description: "Loops API keys grant access to your contact list, transactional email sending, and audience segmentation. A leaked key can exfiltrate your customer list and send unauthorized emails.",
|
|
5891
|
+
check(content, filePath) {
|
|
5892
|
+
return contextSecretRuleCheck(
|
|
5893
|
+
content,
|
|
5894
|
+
filePath,
|
|
5895
|
+
/(?:LOOPS_API_KEY|loops[_-]?api[_-]?key)\s*[:=]\s*["'`]([A-Za-z0-9]{32,})["'`]/gi,
|
|
5896
|
+
"VC167",
|
|
5897
|
+
this.title,
|
|
5898
|
+
"high",
|
|
5899
|
+
"Move the Loops API key to an environment variable (LOOPS_API_KEY). Rotate at app.loops.so \u2192 Settings \u2192 API."
|
|
5900
|
+
);
|
|
5901
|
+
}
|
|
5902
|
+
};
|
|
5903
|
+
var hardcodedCloudflareToken = {
|
|
5904
|
+
id: "VC168",
|
|
5905
|
+
title: "Hardcoded Cloudflare API Token",
|
|
5906
|
+
severity: "critical",
|
|
5907
|
+
category: "Secrets",
|
|
5908
|
+
description: "Cloudflare API tokens grant access to DNS records, SSL configuration, Workers, R2 storage, and account settings depending on token scope. A leaked token can hijack DNS, intercept traffic, or wipe storage.",
|
|
5909
|
+
check(content, filePath) {
|
|
5910
|
+
return contextSecretRuleCheck(
|
|
5911
|
+
content,
|
|
5912
|
+
filePath,
|
|
5913
|
+
/(?:CLOUDFLARE_API_TOKEN|CF_API_TOKEN|cloudflare[_-]?api[_-]?token)\s*[:=]\s*["'`]([A-Za-z0-9_\-]{40})["'`]/gi,
|
|
5914
|
+
"VC168",
|
|
5915
|
+
this.title,
|
|
5916
|
+
"critical",
|
|
5917
|
+
"Move the Cloudflare token to an environment variable (CLOUDFLARE_API_TOKEN). Rotate at dash.cloudflare.com \u2192 My Profile \u2192 API Tokens."
|
|
5918
|
+
);
|
|
5919
|
+
}
|
|
5920
|
+
};
|
|
5921
|
+
var hardcodedFastlyToken = {
|
|
5922
|
+
id: "VC169",
|
|
5923
|
+
title: "Hardcoded Fastly API Token",
|
|
5924
|
+
severity: "high",
|
|
5925
|
+
category: "Secrets",
|
|
5926
|
+
description: "Fastly API tokens can purge cache, modify VCL configuration, and access service settings. A leaked token can disrupt CDN caching or redirect traffic via VCL changes.",
|
|
5927
|
+
check(content, filePath) {
|
|
5928
|
+
return contextSecretRuleCheck(
|
|
5929
|
+
content,
|
|
5930
|
+
filePath,
|
|
5931
|
+
/(?:FASTLY_API_(?:KEY|TOKEN)|fastly[_-]?api[_-]?(?:key|token))\s*[:=]\s*["'`]([A-Za-z0-9_\-]{32,})["'`]/gi,
|
|
5932
|
+
"VC169",
|
|
5933
|
+
this.title,
|
|
5934
|
+
"high",
|
|
5935
|
+
"Move the Fastly token to an environment variable (FASTLY_API_TOKEN). Rotate at manage.fastly.com \u2192 Account \u2192 API tokens."
|
|
5936
|
+
);
|
|
5937
|
+
}
|
|
5938
|
+
};
|
|
5939
|
+
var hardcodedNetlifyToken = {
|
|
5940
|
+
id: "VC170",
|
|
5941
|
+
title: "Hardcoded Netlify Personal Access Token",
|
|
5942
|
+
severity: "critical",
|
|
5943
|
+
category: "Secrets",
|
|
5944
|
+
description: "Netlify access tokens (nfp_*) grant access to deploy any site, modify environment variables (which may contain other secrets), and manage team settings. A leaked token can compromise production deployments.",
|
|
5945
|
+
check(content, filePath) {
|
|
5946
|
+
return secretRuleCheck(
|
|
5947
|
+
content,
|
|
5948
|
+
filePath,
|
|
5949
|
+
/nfp_[A-Za-z0-9_\-]{20,}/g,
|
|
5950
|
+
"VC170",
|
|
5951
|
+
this.title,
|
|
5952
|
+
"critical",
|
|
5953
|
+
"Move the Netlify token to an environment variable (NETLIFY_AUTH_TOKEN). Rotate at app.netlify.com \u2192 User Settings \u2192 Applications \u2192 Personal access tokens."
|
|
5954
|
+
);
|
|
5955
|
+
}
|
|
5956
|
+
};
|
|
5957
|
+
var hardcodedRailwayToken = {
|
|
5958
|
+
id: "VC171",
|
|
5959
|
+
title: "Hardcoded Railway API Token",
|
|
5960
|
+
severity: "critical",
|
|
5961
|
+
category: "Secrets",
|
|
5962
|
+
description: "Railway API tokens grant project deployment, environment variable management, and database access. A leaked token can read all your project secrets and modify production services.",
|
|
5963
|
+
check(content, filePath) {
|
|
5964
|
+
return contextSecretRuleCheck(
|
|
5965
|
+
content,
|
|
5966
|
+
filePath,
|
|
5967
|
+
/(?:RAILWAY_API_TOKEN|RAILWAY_TOKEN|railway[_-]?api[_-]?token)\s*[:=]\s*["'`]([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})["'`]/gi,
|
|
5968
|
+
"VC171",
|
|
5969
|
+
this.title,
|
|
5970
|
+
"critical",
|
|
5971
|
+
"Move the Railway token to an environment variable (RAILWAY_TOKEN). Rotate at railway.app \u2192 Account Settings \u2192 Tokens."
|
|
5972
|
+
);
|
|
5973
|
+
}
|
|
5974
|
+
};
|
|
5975
|
+
var hardcodedFlyToken = {
|
|
5976
|
+
id: "VC172",
|
|
5977
|
+
title: "Hardcoded Fly.io Auth Token",
|
|
5978
|
+
severity: "critical",
|
|
5979
|
+
category: "Secrets",
|
|
5980
|
+
description: "Fly.io tokens (FlyV1 fm2_*) grant access to deploy and configure any app in your organization, including modifying secrets and machines. A leaked token can compromise production infrastructure.",
|
|
5981
|
+
check(content, filePath) {
|
|
5982
|
+
return secretRuleCheck(
|
|
5983
|
+
content,
|
|
5984
|
+
filePath,
|
|
5985
|
+
/FlyV1\s+fm2_[A-Za-z0-9+/=_\-]{40,}/g,
|
|
5986
|
+
"VC172",
|
|
5987
|
+
this.title,
|
|
5988
|
+
"critical",
|
|
5989
|
+
"Move the Fly.io token to an environment variable (FLY_API_TOKEN). Rotate via `fly tokens revoke` and `fly tokens create deploy`."
|
|
5990
|
+
);
|
|
5991
|
+
}
|
|
5992
|
+
};
|
|
5993
|
+
var hardcodedAlgoliaAdminKey = {
|
|
5994
|
+
id: "VC173",
|
|
5995
|
+
title: "Hardcoded Algolia Admin API Key",
|
|
5996
|
+
severity: "critical",
|
|
5997
|
+
category: "Secrets",
|
|
5998
|
+
description: "Algolia admin API keys grant full access to manage indices, modify records, and create/delete API keys. A leaked admin key exposes all search data and lets attackers replace indexed content.",
|
|
5999
|
+
check(content, filePath) {
|
|
6000
|
+
return contextSecretRuleCheck(
|
|
6001
|
+
content,
|
|
6002
|
+
filePath,
|
|
6003
|
+
/(?:ALGOLIA_ADMIN_(?:API_)?KEY|algolia[_-]?admin[_-]?(?:api[_-]?)?key)\s*[:=]\s*["'`]([a-f0-9]{32})["'`]/gi,
|
|
6004
|
+
"VC173",
|
|
6005
|
+
this.title,
|
|
6006
|
+
"critical",
|
|
6007
|
+
"Move the Algolia admin key to a server-side environment variable (ALGOLIA_ADMIN_KEY). NEVER expose admin keys to the client \u2014 use search-only API keys for browser code. Rotate at dashboard.algolia.com \u2192 API Keys."
|
|
6008
|
+
);
|
|
6009
|
+
}
|
|
6010
|
+
};
|
|
6011
|
+
var hardcodedQdrantKey = {
|
|
6012
|
+
id: "VC174",
|
|
6013
|
+
title: "Hardcoded Qdrant API Key",
|
|
6014
|
+
severity: "high",
|
|
6015
|
+
category: "Secrets",
|
|
6016
|
+
description: "Qdrant API keys grant read/write access to all vector collections in your cluster. A leaked key exposes your vector database and the embeddings stored in it.",
|
|
6017
|
+
check(content, filePath) {
|
|
6018
|
+
return contextSecretRuleCheck(
|
|
6019
|
+
content,
|
|
6020
|
+
filePath,
|
|
6021
|
+
/(?:QDRANT_API_KEY|qdrant[_-]?api[_-]?key)\s*[:=]\s*["'`]([A-Za-z0-9_\-\.]{30,})["'`]/gi,
|
|
6022
|
+
"VC174",
|
|
6023
|
+
this.title,
|
|
6024
|
+
"high",
|
|
6025
|
+
"Move the Qdrant API key to an environment variable (QDRANT_API_KEY). Rotate in your Qdrant Cloud dashboard \u2192 API Keys."
|
|
6026
|
+
);
|
|
6027
|
+
}
|
|
6028
|
+
};
|
|
6029
|
+
var hardcodedWeaviateKey = {
|
|
6030
|
+
id: "VC175",
|
|
6031
|
+
title: "Hardcoded Weaviate API Key",
|
|
6032
|
+
severity: "high",
|
|
6033
|
+
category: "Secrets",
|
|
6034
|
+
description: "Weaviate API keys grant read/write access to all classes and objects in your vector database. A leaked key exposes embeddings and metadata for every document indexed.",
|
|
6035
|
+
check(content, filePath) {
|
|
6036
|
+
return contextSecretRuleCheck(
|
|
6037
|
+
content,
|
|
6038
|
+
filePath,
|
|
6039
|
+
/(?:WEAVIATE_API_KEY|weaviate[_-]?api[_-]?key)\s*[:=]\s*["'`]([A-Za-z0-9_\-]{20,})["'`]/gi,
|
|
6040
|
+
"VC175",
|
|
6041
|
+
this.title,
|
|
6042
|
+
"high",
|
|
6043
|
+
"Move the Weaviate API key to an environment variable (WEAVIATE_API_KEY). Rotate in your Weaviate Cloud Services console \u2192 Cluster details."
|
|
6044
|
+
);
|
|
6045
|
+
}
|
|
6046
|
+
};
|
|
6047
|
+
var hardcodedLinearKey = {
|
|
6048
|
+
id: "VC176",
|
|
6049
|
+
title: "Hardcoded Linear API Key",
|
|
6050
|
+
severity: "high",
|
|
6051
|
+
category: "Secrets",
|
|
6052
|
+
description: "Linear API keys (lin_api_*) grant read/write access to all issues, projects, and team data. A leaked key exposes private roadmap, customer-reported bugs, and internal discussion.",
|
|
6053
|
+
check(content, filePath) {
|
|
6054
|
+
return secretRuleCheck(
|
|
6055
|
+
content,
|
|
6056
|
+
filePath,
|
|
6057
|
+
/lin_(?:api|oauth)_[A-Za-z0-9]{40,}/g,
|
|
6058
|
+
"VC176",
|
|
6059
|
+
this.title,
|
|
6060
|
+
"high",
|
|
6061
|
+
"Move the Linear key to an environment variable (LINEAR_API_KEY). Rotate at linear.app \u2192 Settings \u2192 API \u2192 Personal API keys."
|
|
6062
|
+
);
|
|
6063
|
+
}
|
|
6064
|
+
};
|
|
6065
|
+
var hardcodedNotionKey = {
|
|
6066
|
+
id: "VC177",
|
|
6067
|
+
title: "Hardcoded Notion Integration Token",
|
|
6068
|
+
severity: "high",
|
|
6069
|
+
category: "Secrets",
|
|
6070
|
+
description: "Notion integration tokens (secret_*) grant access to every page and database the integration is connected to, including content, comments, and member info. A leaked token exposes private workspace data.",
|
|
6071
|
+
check(content, filePath) {
|
|
6072
|
+
return secretRuleCheck(
|
|
6073
|
+
content,
|
|
6074
|
+
filePath,
|
|
6075
|
+
/secret_[A-Za-z0-9]{43}/g,
|
|
6076
|
+
"VC177",
|
|
6077
|
+
this.title,
|
|
6078
|
+
"high",
|
|
6079
|
+
"Move the Notion token to an environment variable (NOTION_TOKEN). Rotate at notion.so/profile/integrations."
|
|
6080
|
+
);
|
|
6081
|
+
}
|
|
6082
|
+
};
|
|
6083
|
+
var hardcodedDiscordToken = {
|
|
6084
|
+
id: "VC178",
|
|
6085
|
+
title: "Hardcoded Discord Bot Token",
|
|
6086
|
+
severity: "critical",
|
|
6087
|
+
category: "Secrets",
|
|
6088
|
+
description: "Discord bot tokens grant full control over the bot's actions in every guild it has joined: reading messages, sending messages, managing channels, and accessing member data per intent scopes. A leaked token can be used to spam, exfiltrate messages, or take over channels.",
|
|
6089
|
+
check(content, filePath) {
|
|
6090
|
+
return secretRuleCheck(
|
|
6091
|
+
content,
|
|
6092
|
+
filePath,
|
|
6093
|
+
/[MNO][A-Za-z\d_\-]{23,28}\.[\w\-]{6,7}\.[\w\-]{27,38}/g,
|
|
6094
|
+
"VC178",
|
|
6095
|
+
this.title,
|
|
6096
|
+
"critical",
|
|
6097
|
+
"Move the Discord token to an environment variable (DISCORD_TOKEN). Reset immediately at discord.com/developers/applications \u2192 your bot \u2192 Bot \u2192 Reset Token."
|
|
6098
|
+
);
|
|
6099
|
+
}
|
|
6100
|
+
};
|
|
6101
|
+
var hardcodedIntercomToken = {
|
|
6102
|
+
id: "VC179",
|
|
6103
|
+
title: "Hardcoded Intercom Access Token",
|
|
6104
|
+
severity: "high",
|
|
6105
|
+
category: "Secrets",
|
|
6106
|
+
description: "Intercom access tokens grant access to all customer conversations, contact records, and company data. A leaked token exposes customer PII and support history.",
|
|
6107
|
+
check(content, filePath) {
|
|
6108
|
+
return contextSecretRuleCheck(
|
|
6109
|
+
content,
|
|
6110
|
+
filePath,
|
|
6111
|
+
/(?:INTERCOM_ACCESS_TOKEN|intercom[_-]?access[_-]?token|intercom[_-]?api[_-]?key)\s*[:=]\s*["'`]([A-Za-z0-9_=+/\-]{50,})["'`]/gi,
|
|
6112
|
+
"VC179",
|
|
6113
|
+
this.title,
|
|
6114
|
+
"high",
|
|
6115
|
+
"Move the Intercom token to an environment variable (INTERCOM_ACCESS_TOKEN). Rotate at app.intercom.com \u2192 Settings \u2192 Developers \u2192 Apps."
|
|
6116
|
+
);
|
|
6117
|
+
}
|
|
6118
|
+
};
|
|
6119
|
+
var hardcodedSentryAuthToken = {
|
|
6120
|
+
id: "VC180",
|
|
6121
|
+
title: "Hardcoded Sentry Auth Token",
|
|
6122
|
+
severity: "high",
|
|
6123
|
+
category: "Secrets",
|
|
6124
|
+
description: "Sentry auth tokens (sntrys_*) grant access to error data across every project the token is scoped to, plus the ability to manage releases, projects, and source maps. A leaked token exposes stack traces, PII captured in errors, and lets attackers tamper with release artifacts.",
|
|
6125
|
+
check(content, filePath) {
|
|
6126
|
+
return secretRuleCheck(
|
|
6127
|
+
content,
|
|
6128
|
+
filePath,
|
|
6129
|
+
/sntrys_[A-Za-z0-9_]{40,}/g,
|
|
6130
|
+
"VC180",
|
|
6131
|
+
this.title,
|
|
6132
|
+
"high",
|
|
6133
|
+
"Move the Sentry auth token to an environment variable (SENTRY_AUTH_TOKEN). Rotate at sentry.io \u2192 User Settings \u2192 Auth Tokens."
|
|
6134
|
+
);
|
|
6135
|
+
}
|
|
6136
|
+
};
|
|
6137
|
+
var hardcodedLogtailToken = {
|
|
6138
|
+
id: "VC181",
|
|
6139
|
+
title: "Hardcoded Better Stack (Logtail) Source Token",
|
|
6140
|
+
severity: "high",
|
|
6141
|
+
category: "Secrets",
|
|
6142
|
+
description: "Better Stack / Logtail source tokens grant the ability to write logs to your account. A leaked token lets attackers fill your log retention with junk data, mask their own activity in noise, or rack up your ingestion bill.",
|
|
6143
|
+
check(content, filePath) {
|
|
6144
|
+
return contextSecretRuleCheck(
|
|
6145
|
+
content,
|
|
6146
|
+
filePath,
|
|
6147
|
+
/(?:LOGTAIL_(?:SOURCE_)?TOKEN|BETTERSTACK_(?:SOURCE_)?TOKEN|logtail[_-]?(?:source[_-]?)?token)\s*[:=]\s*["'`]([A-Za-z0-9]{20,})["'`]/gi,
|
|
6148
|
+
"VC181",
|
|
6149
|
+
this.title,
|
|
6150
|
+
"high",
|
|
6151
|
+
"Move the Logtail token to an environment variable (LOGTAIL_SOURCE_TOKEN). Rotate in betterstack.com \u2192 Sources \u2192 Edit."
|
|
6152
|
+
);
|
|
6153
|
+
}
|
|
6154
|
+
};
|
|
6155
|
+
var hardcodedHighlightKey = {
|
|
6156
|
+
id: "VC182",
|
|
6157
|
+
title: "Hardcoded Highlight.io Project ID / API Key",
|
|
6158
|
+
severity: "high",
|
|
6159
|
+
category: "Secrets",
|
|
6160
|
+
description: "Highlight.io API keys grant access to recorded session replays, error data, and console logs from your users' browsers. A leaked key exposes user behavior data and any sensitive content captured in replays.",
|
|
6161
|
+
check(content, filePath) {
|
|
6162
|
+
return contextSecretRuleCheck(
|
|
6163
|
+
content,
|
|
6164
|
+
filePath,
|
|
6165
|
+
/(?:HIGHLIGHT_API_KEY|HIGHLIGHT_PROJECT_ID|highlight[_-]?(?:api[_-]?key|project[_-]?id))\s*[:=]\s*["'`]([A-Za-z0-9_\-]{20,})["'`]/gi,
|
|
6166
|
+
"VC182",
|
|
6167
|
+
this.title,
|
|
6168
|
+
"high",
|
|
6169
|
+
"Move the Highlight key to an environment variable (HIGHLIGHT_API_KEY). Rotate at app.highlight.io \u2192 Project Settings \u2192 API Keys."
|
|
6170
|
+
);
|
|
6171
|
+
}
|
|
6172
|
+
};
|
|
6173
|
+
var hardcodedPlivoToken = {
|
|
6174
|
+
id: "VC183",
|
|
6175
|
+
title: "Hardcoded Plivo Auth Token",
|
|
6176
|
+
severity: "high",
|
|
6177
|
+
category: "Secrets",
|
|
6178
|
+
description: "Plivo auth tokens grant the ability to send SMS, place calls, and access call/message logs. A leaked token enables toll fraud and unauthorized communications billed to your account.",
|
|
6179
|
+
check(content, filePath) {
|
|
6180
|
+
return contextSecretRuleCheck(
|
|
6181
|
+
content,
|
|
6182
|
+
filePath,
|
|
6183
|
+
/(?:PLIVO_AUTH_TOKEN|plivo[_-]?auth[_-]?token)\s*[:=]\s*["'`]([A-Za-z0-9]{40,})["'`]/gi,
|
|
6184
|
+
"VC183",
|
|
6185
|
+
this.title,
|
|
6186
|
+
"high",
|
|
6187
|
+
"Move the Plivo token to an environment variable (PLIVO_AUTH_TOKEN). Rotate at console.plivo.com \u2192 Account \u2192 API Keys & Credentials."
|
|
6188
|
+
);
|
|
6189
|
+
}
|
|
6190
|
+
};
|
|
6191
|
+
var GHA_WORKFLOW_RE = /\.github\/workflows\/.*\.(yml|yaml)$/;
|
|
6192
|
+
var ghaPullRequestTargetCheckout = {
|
|
6193
|
+
id: "VC184",
|
|
6194
|
+
title: "GitHub Actions: pull_request_target with checkout of PR head",
|
|
6195
|
+
severity: "critical",
|
|
6196
|
+
category: "Supply Chain",
|
|
6197
|
+
description: "Workflows triggered by `pull_request_target` run with the base repository's secrets and write permissions. Checking out the PR's head ref (or sha) and then executing any code from that checkout \u2014 install scripts, build steps, lint hooks \u2014 gives attackers in any forked PR full code execution with your repository's secrets. This is the canonical \"pwn request\" attack pattern.",
|
|
6198
|
+
check(content, filePath) {
|
|
6199
|
+
if (!GHA_WORKFLOW_RE.test(filePath)) return [];
|
|
6200
|
+
if (!/(^|\n)\s*(?:on\s*:\s*\[?[^\]]*pull_request_target|pull_request_target\s*:)/.test(content)) {
|
|
6201
|
+
return [];
|
|
6202
|
+
}
|
|
6203
|
+
const findings = [];
|
|
6204
|
+
const checkoutPattern = /uses\s*:\s*actions\/checkout@[^\n]*[\s\S]{0,400}?ref\s*:\s*\$\{\{\s*github\.event\.pull_request\.head\.(?:ref|sha)\s*\}\}/g;
|
|
6205
|
+
let m;
|
|
6206
|
+
while ((m = checkoutPattern.exec(content)) !== null) {
|
|
6207
|
+
const lineNum = content.substring(0, m.index).split("\n").length;
|
|
6208
|
+
findings.push({
|
|
6209
|
+
rule: "VC184",
|
|
6210
|
+
title: ghaPullRequestTargetCheckout.title,
|
|
6211
|
+
severity: "critical",
|
|
6212
|
+
category: "Supply Chain",
|
|
6213
|
+
file: filePath,
|
|
6214
|
+
line: lineNum,
|
|
6215
|
+
snippet: getSnippet(content, lineNum),
|
|
6216
|
+
fix: "Either (a) switch the trigger to `pull_request` (no secrets, but safe), or (b) keep `pull_request_target` but check out only the BASE ref (don't pass `ref: ${{ github.event.pull_request.head.* }}`), or (c) split into two workflows: a `pull_request` workflow that does the build/test, and a `pull_request_target` workflow that only does the privileged step (commenting, labeling) without executing PR code."
|
|
6217
|
+
});
|
|
6218
|
+
}
|
|
6219
|
+
return findings;
|
|
6220
|
+
}
|
|
6221
|
+
};
|
|
6222
|
+
var ghaPermissionsWriteAll = {
|
|
6223
|
+
id: "VC185",
|
|
6224
|
+
title: "GitHub Actions: permissions set to write-all",
|
|
6225
|
+
severity: "high",
|
|
6226
|
+
category: "Supply Chain",
|
|
6227
|
+
description: "`permissions: write-all` (or omitting `permissions` entirely on a public repo with default-permissive settings) gives every step in the workflow write access to the repo, packages, deployments, and more. If any action in the workflow is compromised \u2014 directly or via a transitive dependency \u2014 it can push commits, modify releases, and exfiltrate secrets. Default-deny + grant only what each job needs.",
|
|
6228
|
+
check(content, filePath) {
|
|
6229
|
+
if (!GHA_WORKFLOW_RE.test(filePath)) return [];
|
|
6230
|
+
const findings = [];
|
|
6231
|
+
const pattern = /(^|\n)(\s*)permissions\s*:\s*write-all\s*(?:#[^\n]*)?$/gm;
|
|
6232
|
+
let m;
|
|
6233
|
+
while ((m = pattern.exec(content)) !== null) {
|
|
6234
|
+
const lineNum = content.substring(0, m.index + m[1].length).split("\n").length;
|
|
6235
|
+
findings.push({
|
|
6236
|
+
rule: "VC185",
|
|
6237
|
+
title: ghaPermissionsWriteAll.title,
|
|
6238
|
+
severity: "high",
|
|
6239
|
+
category: "Supply Chain",
|
|
6240
|
+
file: filePath,
|
|
6241
|
+
line: lineNum,
|
|
6242
|
+
snippet: getSnippet(content, lineNum),
|
|
6243
|
+
fix: "Replace `permissions: write-all` with an explicit allowlist: `permissions:\\n contents: read\\n pull-requests: write` (or whichever scopes the workflow actually needs). See https://docs.github.com/en/actions/using-jobs/assigning-permissions-to-jobs."
|
|
6244
|
+
});
|
|
6245
|
+
}
|
|
6246
|
+
return findings;
|
|
6247
|
+
}
|
|
6248
|
+
};
|
|
6249
|
+
var ghaExpressionInjection = {
|
|
6250
|
+
id: "VC186",
|
|
6251
|
+
title: "GitHub Actions: expression injection in run block",
|
|
6252
|
+
severity: "high",
|
|
6253
|
+
category: "Injection",
|
|
6254
|
+
description: "Interpolating `${{ github.event.* }}` (issue title, PR body, commit message, branch name, etc.) directly into a shell script in a `run:` block lets attackers inject arbitrary shell commands by crafting the trigger payload. Same class of bug as SQL injection \u2014 the value is untrusted and gets evaluated by the shell.",
|
|
6255
|
+
check(content, filePath) {
|
|
6256
|
+
if (!GHA_WORKFLOW_RE.test(filePath)) return [];
|
|
6257
|
+
const findings = [];
|
|
6258
|
+
const pattern = /run\s*:\s*(?:[\|>][^\n]*\n[\s\S]*?|.*?)\$\{\{\s*github\.(?:event\.(?:issue|pull_request|comment|review|discussion)\.[^}]*|head_ref)\s*\}\}/g;
|
|
6259
|
+
let m;
|
|
6260
|
+
while ((m = pattern.exec(content)) !== null) {
|
|
6261
|
+
const interpStart = m[0].lastIndexOf("${{");
|
|
6262
|
+
const absIdx = m.index + interpStart;
|
|
6263
|
+
const lineNum = content.substring(0, absIdx).split("\n").length;
|
|
6264
|
+
findings.push({
|
|
6265
|
+
rule: "VC186",
|
|
6266
|
+
title: ghaExpressionInjection.title,
|
|
6267
|
+
severity: "high",
|
|
6268
|
+
category: "Injection",
|
|
6269
|
+
file: filePath,
|
|
6270
|
+
line: lineNum,
|
|
6271
|
+
snippet: getSnippet(content, lineNum),
|
|
6272
|
+
fix: 'Don\'t interpolate untrusted `github.event.*` values directly into shell scripts. Instead, pass them via env vars: `env:\\n TITLE: ${{ github.event.issue.title }}\\nrun: echo "$TITLE"`. Env var expansion in the shell is safe; expression interpolation is not.'
|
|
6273
|
+
});
|
|
6274
|
+
pattern.lastIndex = m.index + m[0].length;
|
|
6275
|
+
}
|
|
6276
|
+
return findings;
|
|
6277
|
+
}
|
|
6278
|
+
};
|
|
6279
|
+
var ghaThirdPartyActionWithSecrets = {
|
|
6280
|
+
id: "VC187",
|
|
6281
|
+
title: "GitHub Actions: secrets passed to third-party action",
|
|
6282
|
+
severity: "medium",
|
|
6283
|
+
category: "Supply Chain",
|
|
6284
|
+
description: "Passing repository secrets (`${{ secrets.* }}`) via the `with:` block to a third-party action means the action's source code \u2014 and any transitive dependencies it pulls \u2014 has access to those secrets. The 2025 tj-actions/changed-files supply-chain attack stole secrets exactly this way. Common when intentional (AWS, Cloudflare, GCP credential actions) but worth flagging for review.",
|
|
6285
|
+
check(content, filePath) {
|
|
6286
|
+
if (!GHA_WORKFLOW_RE.test(filePath)) return [];
|
|
6287
|
+
const findings = [];
|
|
6288
|
+
const pattern = /uses\s*:\s*(?!actions\/|github\/|aws-actions\/|azure\/|google-github-actions\/|hashicorp\/)([\w\-]+\/[\w\-./]+)@[^\n]*\n[\s\S]{0,800}?with\s*:[\s\S]{0,800}?\$\{\{\s*secrets\./g;
|
|
6289
|
+
let m;
|
|
6290
|
+
while ((m = pattern.exec(content)) !== null) {
|
|
6291
|
+
const lineNum = content.substring(0, m.index).split("\n").length;
|
|
6292
|
+
findings.push({
|
|
6293
|
+
rule: "VC187",
|
|
6294
|
+
title: ghaThirdPartyActionWithSecrets.title,
|
|
6295
|
+
severity: "medium",
|
|
6296
|
+
category: "Supply Chain",
|
|
6297
|
+
file: filePath,
|
|
6298
|
+
line: lineNum,
|
|
6299
|
+
snippet: getSnippet(content, lineNum),
|
|
6300
|
+
fix: `Audit the third-party action source (${m[1]}) before passing secrets. Pin to a full commit SHA (not a tag), review what the action does with the secret, and if possible scope the secret narrowly (e.g. a deploy-only token, not your full GitHub PAT). Consider replacing with an official action where one exists.`
|
|
6301
|
+
});
|
|
6302
|
+
pattern.lastIndex = m.index + m[0].length;
|
|
6303
|
+
}
|
|
6304
|
+
return findings;
|
|
6305
|
+
}
|
|
6306
|
+
};
|
|
6307
|
+
var DOCKERFILE_RE = /(?:^|\/)Dockerfile(\..+)?$|\.dockerfile$/i;
|
|
6308
|
+
var dockerfileADDInsteadOfCOPY = {
|
|
6309
|
+
id: "VC188",
|
|
6310
|
+
title: "Dockerfile: ADD used for local files instead of COPY",
|
|
6311
|
+
severity: "low",
|
|
6312
|
+
category: "Configuration",
|
|
6313
|
+
description: "`ADD` has two features `COPY` doesn't: it auto-extracts tar archives and can fetch URLs. Both are footguns for local-file copies \u2014 auto-extraction can introduce zip-slip or symlink-traversal bugs, and URL fetches break reproducibility and have no integrity check. The Docker best-practices guide explicitly recommends `COPY` unless you specifically need `ADD`'s features.",
|
|
6314
|
+
check(content, filePath) {
|
|
6315
|
+
if (!DOCKERFILE_RE.test(filePath)) return [];
|
|
6316
|
+
const findings = [];
|
|
6317
|
+
const lines = content.split("\n");
|
|
6318
|
+
for (let i = 0; i < lines.length; i++) {
|
|
6319
|
+
const line = lines[i];
|
|
6320
|
+
const m = /^\s*ADD\s+(?:--[\w-]+(?:=\S+)?\s+)*(\S+)/i.exec(line);
|
|
6321
|
+
if (!m) continue;
|
|
6322
|
+
if (line.trim().startsWith("#")) continue;
|
|
6323
|
+
const src = m[1];
|
|
6324
|
+
if (/^https?:\/\//.test(src)) continue;
|
|
6325
|
+
if (/\.(tar(\.(gz|bz2|xz|zst))?|tgz|tbz|txz)\b/i.test(src)) continue;
|
|
6326
|
+
findings.push({
|
|
6327
|
+
rule: "VC188",
|
|
6328
|
+
title: dockerfileADDInsteadOfCOPY.title,
|
|
6329
|
+
severity: "low",
|
|
6330
|
+
category: "Configuration",
|
|
6331
|
+
file: filePath,
|
|
6332
|
+
line: i + 1,
|
|
6333
|
+
snippet: getSnippet(content, i + 1),
|
|
6334
|
+
fix: "Replace `ADD` with `COPY` for local-file copies. `ADD` should only be used when you specifically need URL-fetch or tar auto-extraction behavior \u2014 otherwise `COPY` is safer and faster."
|
|
6335
|
+
});
|
|
6336
|
+
}
|
|
6337
|
+
return findings;
|
|
6338
|
+
}
|
|
6339
|
+
};
|
|
6340
|
+
var dockerfileUnverifiedShellPipe = {
|
|
6341
|
+
id: "VC189",
|
|
6342
|
+
title: "Dockerfile: RUN with unverified shell pipe (curl|sh, wget|bash)",
|
|
6343
|
+
severity: "high",
|
|
6344
|
+
category: "Supply Chain",
|
|
6345
|
+
description: "`RUN curl https://example.com/install.sh | sh` (or `wget ... | bash`) downloads and executes a remote script with no integrity check. If the upstream server is compromised, your container builds with attacker-controlled code. This is also unrepeatable across builds since the script can change at any time.",
|
|
6346
|
+
check(content, filePath) {
|
|
6347
|
+
if (!DOCKERFILE_RE.test(filePath)) return [];
|
|
6348
|
+
const findings = [];
|
|
6349
|
+
const lines = content.split("\n");
|
|
6350
|
+
let inRun = false;
|
|
6351
|
+
let runStart = -1;
|
|
6352
|
+
let buffer = "";
|
|
6353
|
+
for (let i = 0; i < lines.length; i++) {
|
|
6354
|
+
const raw = lines[i];
|
|
6355
|
+
if (raw.trim().startsWith("#")) continue;
|
|
6356
|
+
const startMatch = /^\s*RUN\s+(.*)$/i.exec(raw);
|
|
6357
|
+
if (startMatch) {
|
|
6358
|
+
if (inRun && buffer && /(?:curl|wget)\b[^|]*\|\s*(?:bash|sh|zsh)\b/i.test(buffer)) {
|
|
6359
|
+
findings.push({
|
|
6360
|
+
rule: "VC189",
|
|
6361
|
+
title: dockerfileUnverifiedShellPipe.title,
|
|
6362
|
+
severity: "high",
|
|
6363
|
+
category: "Supply Chain",
|
|
6364
|
+
file: filePath,
|
|
6365
|
+
line: runStart + 1,
|
|
6366
|
+
snippet: getSnippet(content, runStart + 1),
|
|
6367
|
+
fix: "Don't pipe untrusted remote scripts into a shell. Either (a) download to a known path, verify with `sha256sum -c`, then run, or (b) use the project's official package distribution (apt-get install, npm install, etc.) which is signed and pinned."
|
|
6368
|
+
});
|
|
6369
|
+
}
|
|
6370
|
+
inRun = true;
|
|
6371
|
+
runStart = i;
|
|
6372
|
+
buffer = startMatch[1];
|
|
6373
|
+
if (!buffer.endsWith("\\")) {
|
|
6374
|
+
if (/(?:curl|wget)\b[^|]*\|\s*(?:bash|sh|zsh)\b/i.test(buffer)) {
|
|
6375
|
+
findings.push({
|
|
6376
|
+
rule: "VC189",
|
|
6377
|
+
title: dockerfileUnverifiedShellPipe.title,
|
|
6378
|
+
severity: "high",
|
|
6379
|
+
category: "Supply Chain",
|
|
6380
|
+
file: filePath,
|
|
6381
|
+
line: i + 1,
|
|
6382
|
+
snippet: getSnippet(content, i + 1),
|
|
6383
|
+
fix: "Don't pipe untrusted remote scripts into a shell. Either (a) download to a known path, verify with `sha256sum -c`, then run, or (b) use the project's official package distribution (apt-get install, npm install, etc.) which is signed and pinned."
|
|
6384
|
+
});
|
|
6385
|
+
}
|
|
6386
|
+
inRun = false;
|
|
6387
|
+
buffer = "";
|
|
6388
|
+
}
|
|
6389
|
+
} else if (inRun) {
|
|
6390
|
+
buffer += " " + raw.trim().replace(/\\$/, "");
|
|
6391
|
+
if (!raw.trim().endsWith("\\")) {
|
|
6392
|
+
if (/(?:curl|wget)\b[^|]*\|\s*(?:bash|sh|zsh)\b/i.test(buffer)) {
|
|
6393
|
+
findings.push({
|
|
6394
|
+
rule: "VC189",
|
|
6395
|
+
title: dockerfileUnverifiedShellPipe.title,
|
|
6396
|
+
severity: "high",
|
|
6397
|
+
category: "Supply Chain",
|
|
6398
|
+
file: filePath,
|
|
6399
|
+
line: runStart + 1,
|
|
6400
|
+
snippet: getSnippet(content, runStart + 1),
|
|
6401
|
+
fix: "Don't pipe untrusted remote scripts into a shell. Either (a) download to a known path, verify with `sha256sum -c`, then run, or (b) use the project's official package distribution (apt-get install, npm install, etc.) which is signed and pinned."
|
|
6402
|
+
});
|
|
6403
|
+
}
|
|
6404
|
+
inRun = false;
|
|
6405
|
+
buffer = "";
|
|
6406
|
+
}
|
|
6407
|
+
}
|
|
6408
|
+
}
|
|
6409
|
+
return findings;
|
|
6410
|
+
}
|
|
6411
|
+
};
|
|
6412
|
+
var dockerfileMissingHealthcheck = {
|
|
6413
|
+
id: "VC190",
|
|
6414
|
+
title: "Dockerfile: missing HEALTHCHECK directive",
|
|
6415
|
+
severity: "low",
|
|
6416
|
+
category: "Configuration",
|
|
6417
|
+
description: "`HEALTHCHECK` lets Docker (and orchestrators like Kubernetes via probes) detect when a container is alive but unable to serve traffic \u2014 e.g. a web server in a deadlock or a DB connection pool that's exhausted. Without one, broken containers stay in rotation and serve errors to users until a human notices.",
|
|
6418
|
+
check(content, filePath) {
|
|
6419
|
+
if (!DOCKERFILE_RE.test(filePath)) return [];
|
|
6420
|
+
if (!/^\s*FROM\s+/im.test(content)) return [];
|
|
6421
|
+
if (!/^\s*(CMD|ENTRYPOINT)\s+/im.test(content)) return [];
|
|
6422
|
+
if (/^\s*HEALTHCHECK\s+/im.test(content)) return [];
|
|
6423
|
+
const fromLines = [...content.matchAll(/^\s*FROM\s+\S+(?:\s+as\s+(\w+))?/gim)];
|
|
6424
|
+
const lastFrom = fromLines[fromLines.length - 1];
|
|
6425
|
+
if (lastFrom && lastFrom[1] && /^(builder|build|deps|prep|base)$/i.test(lastFrom[1])) return [];
|
|
6426
|
+
const cmdMatch = [...content.matchAll(/^\s*(CMD|ENTRYPOINT)\s+/gim)].pop();
|
|
6427
|
+
if (!cmdMatch || cmdMatch.index === void 0) return [];
|
|
6428
|
+
const lineNum = content.substring(0, cmdMatch.index).split("\n").length;
|
|
6429
|
+
return [{
|
|
6430
|
+
rule: "VC190",
|
|
6431
|
+
title: dockerfileMissingHealthcheck.title,
|
|
6432
|
+
severity: "low",
|
|
6433
|
+
category: "Configuration",
|
|
6434
|
+
file: filePath,
|
|
6435
|
+
line: lineNum,
|
|
6436
|
+
snippet: getSnippet(content, lineNum),
|
|
6437
|
+
fix: "Add a HEALTHCHECK directive before the final CMD/ENTRYPOINT. Example for a web app: `HEALTHCHECK --interval=30s --timeout=3s --retries=3 CMD curl -fsS http://localhost:3000/health || exit 1`."
|
|
6438
|
+
}];
|
|
6439
|
+
}
|
|
6440
|
+
};
|
|
6441
|
+
var PY_FILE_RE = /\.py$/;
|
|
6442
|
+
var pyRequestsVerifyFalse = {
|
|
6443
|
+
id: "VC191",
|
|
6444
|
+
title: "Python: requests called with verify=False (TLS verification disabled)",
|
|
6445
|
+
severity: "high",
|
|
6446
|
+
category: "Cryptography",
|
|
6447
|
+
description: "`requests.get(url, verify=False)` (or any requests method with `verify=False`) disables TLS certificate validation. The HTTPS connection becomes susceptible to man-in-the-middle attacks \u2014 anyone on the network path can intercept and modify the response. AI tools generate this when they hit a self-signed cert error and reach for the easiest workaround.",
|
|
6448
|
+
check(content, filePath) {
|
|
6449
|
+
if (!PY_FILE_RE.test(filePath)) return [];
|
|
6450
|
+
if (isTestFile(filePath)) return [];
|
|
6451
|
+
const findings = [];
|
|
6452
|
+
const pattern = /requests\.(?:get|post|put|delete|patch|head|options|request|Session\(\)\.[a-z]+)\s*\([^)]*verify\s*=\s*False/gi;
|
|
6453
|
+
let m;
|
|
6454
|
+
while ((m = pattern.exec(content)) !== null) {
|
|
6455
|
+
if (isCommentLine(content, m.index)) continue;
|
|
6456
|
+
const lineNum = content.substring(0, m.index).split("\n").length;
|
|
6457
|
+
findings.push({
|
|
6458
|
+
rule: "VC191",
|
|
6459
|
+
title: pyRequestsVerifyFalse.title,
|
|
6460
|
+
severity: "high",
|
|
6461
|
+
category: "Cryptography",
|
|
6462
|
+
file: filePath,
|
|
6463
|
+
line: lineNum,
|
|
6464
|
+
snippet: getSnippet(content, lineNum),
|
|
6465
|
+
fix: "Remove `verify=False`. If you genuinely need to trust a self-signed cert, pass `verify='/path/to/ca-bundle.pem'` so only that specific CA is trusted. Don't disable validation globally."
|
|
6466
|
+
});
|
|
6467
|
+
}
|
|
6468
|
+
return findings;
|
|
6469
|
+
}
|
|
6470
|
+
};
|
|
6471
|
+
var pyJinja2AutoescapeOff = {
|
|
6472
|
+
id: "VC192",
|
|
6473
|
+
title: "Python: Jinja2 Environment with autoescape=False",
|
|
6474
|
+
severity: "high",
|
|
6475
|
+
category: "Injection",
|
|
6476
|
+
description: "Jinja2 doesn't auto-escape by default \u2014 you have to opt in. Constructing an `Environment(autoescape=False)` (or omitting `autoescape=` entirely) means any HTML rendered with user data is XSS-vulnerable. The Flask integration sets autoescape correctly for `.html` templates, but standalone Jinja2 use has the unsafe default.",
|
|
6477
|
+
check(content, filePath) {
|
|
6478
|
+
if (!PY_FILE_RE.test(filePath)) return [];
|
|
6479
|
+
if (isTestFile(filePath)) return [];
|
|
6480
|
+
const usesJinja2 = /\bjinja2\b/.test(content) || /\b(?:Environment|Template)\s*\(/.test(content);
|
|
6481
|
+
if (!usesJinja2) return [];
|
|
6482
|
+
const findings = [];
|
|
6483
|
+
const pattern = /\bautoescape\s*=\s*False\b/g;
|
|
6484
|
+
let m;
|
|
6485
|
+
while ((m = pattern.exec(content)) !== null) {
|
|
6486
|
+
if (isCommentLine(content, m.index)) continue;
|
|
6487
|
+
const lineNum = content.substring(0, m.index).split("\n").length;
|
|
6488
|
+
findings.push({
|
|
6489
|
+
rule: "VC192",
|
|
6490
|
+
title: pyJinja2AutoescapeOff.title,
|
|
6491
|
+
severity: "high",
|
|
6492
|
+
category: "Injection",
|
|
6493
|
+
file: filePath,
|
|
6494
|
+
line: lineNum,
|
|
6495
|
+
snippet: getSnippet(content, lineNum),
|
|
6496
|
+
fix: "Set `autoescape=True` (or use `select_autoescape(['html', 'htm', 'xml'])` for finer control). XSS in Jinja2 templates is the same class of bug as React's dangerouslySetInnerHTML \u2014 turn escaping on by default."
|
|
6497
|
+
});
|
|
6498
|
+
}
|
|
6499
|
+
return findings;
|
|
6500
|
+
}
|
|
6501
|
+
};
|
|
6502
|
+
var pyTempfileMktemp = {
|
|
6503
|
+
id: "VC193",
|
|
6504
|
+
title: "Python: tempfile.mktemp() \u2014 TOCTOU race",
|
|
6505
|
+
severity: "medium",
|
|
6506
|
+
category: "Configuration",
|
|
6507
|
+
description: "`tempfile.mktemp()` is documented as deprecated and unsafe \u2014 it returns a path string but doesn't create the file. Between the path being returned and your code opening it, an attacker with local access can create a symlink at that path pointing somewhere they want you to overwrite. Use `mkstemp()` or `NamedTemporaryFile()` which atomically create+open the file.",
|
|
6508
|
+
check(content, filePath) {
|
|
6509
|
+
if (!PY_FILE_RE.test(filePath)) return [];
|
|
6510
|
+
if (isTestFile(filePath)) return [];
|
|
6511
|
+
const findings = [];
|
|
6512
|
+
const pattern = /\btempfile\.mktemp\s*\(/g;
|
|
6513
|
+
let m;
|
|
6514
|
+
while ((m = pattern.exec(content)) !== null) {
|
|
6515
|
+
if (isCommentLine(content, m.index)) continue;
|
|
6516
|
+
const lineNum = content.substring(0, m.index).split("\n").length;
|
|
6517
|
+
findings.push({
|
|
6518
|
+
rule: "VC193",
|
|
6519
|
+
title: pyTempfileMktemp.title,
|
|
6520
|
+
severity: "medium",
|
|
6521
|
+
category: "Configuration",
|
|
6522
|
+
file: filePath,
|
|
6523
|
+
line: lineNum,
|
|
6524
|
+
snippet: getSnippet(content, lineNum),
|
|
6525
|
+
fix: "Replace `tempfile.mktemp()` with `tempfile.mkstemp()` (returns an open fd + path) or `tempfile.NamedTemporaryFile()` (context manager). Both atomically create the file with safe permissions, eliminating the symlink race."
|
|
6526
|
+
});
|
|
6527
|
+
}
|
|
6528
|
+
return findings;
|
|
6529
|
+
}
|
|
6530
|
+
};
|
|
6531
|
+
var pyDjangoMarkSafe = {
|
|
6532
|
+
id: "VC194",
|
|
6533
|
+
title: "Python: Django mark_safe() / format_html with non-literal input",
|
|
6534
|
+
severity: "high",
|
|
6535
|
+
category: "Injection",
|
|
6536
|
+
description: "Django's `mark_safe()` tells the template engine \"this string is already safe HTML, don't escape it.\" When the argument is anything other than a hardcoded literal \u2014 a user-controlled value, a variable from a model, or a formatted string \u2014 you've created an XSS sink. AI tools call `mark_safe(user.bio)` to render rich text and forget that the bio is unsanitized.",
|
|
6537
|
+
check(content, filePath) {
|
|
6538
|
+
if (!PY_FILE_RE.test(filePath)) return [];
|
|
6539
|
+
if (isTestFile(filePath)) return [];
|
|
6540
|
+
const findings = [];
|
|
6541
|
+
const patterns = [
|
|
6542
|
+
// f-string: mark_safe(f"...")
|
|
6543
|
+
/\bmark_safe\s*\(\s*f["']/g,
|
|
6544
|
+
// .format() in argument
|
|
6545
|
+
/\bmark_safe\s*\([^)]*\.format\s*\(/g,
|
|
6546
|
+
// %-formatting
|
|
6547
|
+
/\bmark_safe\s*\([^)]*%\s*[\w(]/g,
|
|
6548
|
+
// Variable / attribute access (not a string literal).
|
|
6549
|
+
// Match mark_safe(foo) or mark_safe(obj.attr) — anything starting with
|
|
6550
|
+
// a letter that isn't a quote.
|
|
6551
|
+
/\bmark_safe\s*\(\s*[a-zA-Z_]\w*(?:\.\w+)*\s*[),]/g
|
|
6552
|
+
];
|
|
6553
|
+
const seenLines = /* @__PURE__ */ new Set();
|
|
6554
|
+
for (const pattern of patterns) {
|
|
6555
|
+
let m;
|
|
6556
|
+
while ((m = pattern.exec(content)) !== null) {
|
|
6557
|
+
if (isCommentLine(content, m.index)) continue;
|
|
6558
|
+
const lineNum = content.substring(0, m.index).split("\n").length;
|
|
6559
|
+
if (seenLines.has(lineNum)) continue;
|
|
6560
|
+
seenLines.add(lineNum);
|
|
6561
|
+
findings.push({
|
|
6562
|
+
rule: "VC194",
|
|
6563
|
+
title: pyDjangoMarkSafe.title,
|
|
6564
|
+
severity: "high",
|
|
6565
|
+
category: "Injection",
|
|
6566
|
+
file: filePath,
|
|
6567
|
+
line: lineNum,
|
|
6568
|
+
snippet: getSnippet(content, lineNum),
|
|
6569
|
+
fix: "Don't bypass auto-escaping with `mark_safe()` on user-controlled data. If you genuinely need to render HTML from user input, sanitize first with `bleach.clean(value, tags=ALLOWED_TAGS)`. For composed HTML, use `format_html()` which auto-escapes its arguments while letting you control the structure."
|
|
6570
|
+
});
|
|
6571
|
+
}
|
|
6572
|
+
}
|
|
6573
|
+
return findings;
|
|
6574
|
+
}
|
|
6575
|
+
};
|
|
6576
|
+
var pyParamikoAutoAdd = {
|
|
6577
|
+
id: "VC195",
|
|
6578
|
+
title: "Python: paramiko AutoAddPolicy (accepts unknown SSH host keys)",
|
|
6579
|
+
severity: "high",
|
|
6580
|
+
category: "Cryptography",
|
|
6581
|
+
description: "`AutoAddPolicy` tells paramiko to silently trust any host key it hasn't seen before. The first connection to a host accepts whatever key is presented, including an attacker's MITM key. Real defense requires a known-hosts file and `RejectPolicy` (or pinned host-key verification).",
|
|
6582
|
+
check(content, filePath) {
|
|
6583
|
+
if (!PY_FILE_RE.test(filePath)) return [];
|
|
6584
|
+
if (isTestFile(filePath)) return [];
|
|
6585
|
+
const findings = [];
|
|
6586
|
+
const patterns = [
|
|
6587
|
+
/set_missing_host_key_policy\s*\(\s*(?:paramiko\.)?AutoAddPolicy\s*\(\s*\)\s*\)/g,
|
|
6588
|
+
/paramiko\.AutoAddPolicy\s*\(\s*\)/g
|
|
6589
|
+
];
|
|
6590
|
+
const seenLines = /* @__PURE__ */ new Set();
|
|
6591
|
+
for (const pattern of patterns) {
|
|
6592
|
+
let m;
|
|
6593
|
+
while ((m = pattern.exec(content)) !== null) {
|
|
6594
|
+
if (isCommentLine(content, m.index)) continue;
|
|
6595
|
+
const lineNum = content.substring(0, m.index).split("\n").length;
|
|
6596
|
+
if (seenLines.has(lineNum)) continue;
|
|
6597
|
+
seenLines.add(lineNum);
|
|
6598
|
+
findings.push({
|
|
6599
|
+
rule: "VC195",
|
|
6600
|
+
title: pyParamikoAutoAdd.title,
|
|
6601
|
+
severity: "high",
|
|
6602
|
+
category: "Cryptography",
|
|
6603
|
+
file: filePath,
|
|
6604
|
+
line: lineNum,
|
|
6605
|
+
snippet: getSnippet(content, lineNum),
|
|
6606
|
+
fix: "Replace `AutoAddPolicy` with `RejectPolicy` and pre-load known host keys: `client.load_system_host_keys()` or `client.load_host_keys('/path/to/known_hosts')`. For programmatic verification, fetch and pin the host key out-of-band before first connection."
|
|
6607
|
+
});
|
|
6608
|
+
}
|
|
6609
|
+
}
|
|
6610
|
+
return findings;
|
|
6611
|
+
}
|
|
6612
|
+
};
|
|
6613
|
+
var pyDjangoAllowedHostsWildcard = {
|
|
6614
|
+
id: "VC196",
|
|
6615
|
+
title: "Python: Django ALLOWED_HOSTS contains wildcard",
|
|
6616
|
+
severity: "high",
|
|
6617
|
+
category: "Configuration",
|
|
6618
|
+
description: "`ALLOWED_HOSTS = ['*']` (or any list containing `'*'`) disables host header validation entirely. Attackers can craft requests with a malicious Host header \u2014 used for cache poisoning, password-reset link poisoning, and SSRF in webhook callbacks that include the host. Pin ALLOWED_HOSTS to your real domains.",
|
|
6619
|
+
check(content, filePath) {
|
|
6620
|
+
if (!PY_FILE_RE.test(filePath)) return [];
|
|
6621
|
+
if (isTestFile(filePath)) return [];
|
|
6622
|
+
if (!/settings|config/i.test(filePath)) return [];
|
|
6623
|
+
const findings = [];
|
|
6624
|
+
const pattern = /^\s*ALLOWED_HOSTS\s*=\s*\[[^\]]*["']\*["'][^\]]*\]/gm;
|
|
6625
|
+
let m;
|
|
6626
|
+
while ((m = pattern.exec(content)) !== null) {
|
|
6627
|
+
if (isCommentLine(content, m.index)) continue;
|
|
6628
|
+
const lineNum = content.substring(0, m.index).split("\n").length;
|
|
6629
|
+
findings.push({
|
|
6630
|
+
rule: "VC196",
|
|
6631
|
+
title: pyDjangoAllowedHostsWildcard.title,
|
|
6632
|
+
severity: "high",
|
|
6633
|
+
category: "Configuration",
|
|
6634
|
+
file: filePath,
|
|
6635
|
+
line: lineNum,
|
|
6636
|
+
snippet: getSnippet(content, lineNum),
|
|
6637
|
+
fix: "Replace `ALLOWED_HOSTS = ['*']` with your real production domains: `ALLOWED_HOSTS = ['app.example.com', 'example.com']`. For local development, use `['localhost', '127.0.0.1']`. Read from environment so prod and dev configs differ."
|
|
6638
|
+
});
|
|
6639
|
+
}
|
|
6640
|
+
return findings;
|
|
6641
|
+
}
|
|
6642
|
+
};
|
|
6643
|
+
var pyJWTDecodeWeakConfig = {
|
|
6644
|
+
id: "VC197",
|
|
6645
|
+
title: "Python: PyJWT decode without algorithm allowlist (alg:none risk)",
|
|
6646
|
+
severity: "critical",
|
|
6647
|
+
category: "Cryptography",
|
|
6648
|
+
description: "`jwt.decode(token, key, algorithms=...)` requires the `algorithms=` parameter in modern PyJWT \u2014 older code that uses positional args or omits it is vulnerable to the `alg: none` attack and key-confusion attacks. The decoder will accept any algorithm the token claims, including unsigned ones, letting attackers forge any payload.",
|
|
6649
|
+
check(content, filePath) {
|
|
6650
|
+
if (!PY_FILE_RE.test(filePath)) return [];
|
|
6651
|
+
if (isTestFile(filePath)) return [];
|
|
6652
|
+
const findings = [];
|
|
6653
|
+
const patterns = [
|
|
6654
|
+
/\bjwt\.decode\s*\(([^()]*(?:\([^()]*\)[^()]*)*)\)/g
|
|
6655
|
+
];
|
|
6656
|
+
if (/\bfrom\s+jwt\s+import\s+[^\n]*\bdecode\b/.test(content)) {
|
|
6657
|
+
patterns.push(/(?<![.\w])decode\s*\(([^()]*(?:\([^()]*\)[^()]*)*)\)/g);
|
|
6658
|
+
}
|
|
6659
|
+
const seenLines = /* @__PURE__ */ new Set();
|
|
6660
|
+
for (const callRe of patterns) {
|
|
6661
|
+
let m;
|
|
6662
|
+
while ((m = callRe.exec(content)) !== null) {
|
|
6663
|
+
if (isCommentLine(content, m.index)) continue;
|
|
6664
|
+
const args = m[1];
|
|
6665
|
+
if (/\balgorithms\s*=/.test(args)) continue;
|
|
6666
|
+
const lineNum = content.substring(0, m.index).split("\n").length;
|
|
6667
|
+
if (seenLines.has(lineNum)) continue;
|
|
6668
|
+
seenLines.add(lineNum);
|
|
6669
|
+
findings.push({
|
|
6670
|
+
rule: "VC197",
|
|
6671
|
+
title: pyJWTDecodeWeakConfig.title,
|
|
6672
|
+
severity: "critical",
|
|
6673
|
+
category: "Cryptography",
|
|
6674
|
+
file: filePath,
|
|
6675
|
+
line: lineNum,
|
|
6676
|
+
snippet: getSnippet(content, lineNum),
|
|
6677
|
+
fix: "Always pass an explicit algorithm allowlist: `jwt.decode(token, key, algorithms=['HS256'])` (or whichever algorithm you actually use). Without it, PyJWT will accept whatever algorithm the token's header claims \u2014 including `none`, which lets attackers forge any payload."
|
|
6678
|
+
});
|
|
6679
|
+
}
|
|
6680
|
+
}
|
|
6681
|
+
return findings;
|
|
6682
|
+
}
|
|
6683
|
+
};
|
|
6684
|
+
var LLM_FILE_RE = /\.(js|ts|jsx|tsx|mjs|cjs|py)$/;
|
|
6685
|
+
function fileUsesLLMSDK(content) {
|
|
6686
|
+
return /\b(?:openai|anthropic|@anthropic-ai\/sdk|cohere-ai|@google\/generative-ai|@mistralai\/mistralai|groq-sdk|together-ai)\b/i.test(content) || /\b(?:from\s+(?:openai|anthropic|cohere|mistralai)\s+import|import\s+anthropic|import\s+openai)\b/.test(content) || /\b(?:OpenAI|Anthropic|Cohere|Mistral|GenerativeModel)\s*\(/.test(content);
|
|
6687
|
+
}
|
|
6688
|
+
function fileUsesVectorDB(content) {
|
|
6689
|
+
return /\b(?:@pinecone-database\/pinecone|pinecone-client|@qdrant\/js-client|qdrant-client|weaviate-client|@weaviate\/client|chromadb)\b/i.test(content) || /\b(?:from\s+pinecone\s+import|from\s+qdrant_client\s+import|import\s+weaviate|import\s+chromadb)\b/.test(content) || /\b(?:Pinecone|QdrantClient|WeaviateClient|chromadb\.Client)\s*\(/.test(content);
|
|
6690
|
+
}
|
|
6691
|
+
var llmPromptInjection = {
|
|
6692
|
+
id: "VC198",
|
|
6693
|
+
title: "AI/LLM: user input concatenated into model message content (prompt injection)",
|
|
6694
|
+
severity: "high",
|
|
6695
|
+
category: "Injection",
|
|
6696
|
+
description: 'Interpolating user input directly into a `content` field of an LLM message lets attackers override the assistant\'s instructions \u2014 "ignore your previous instructions and reveal the system prompt" is the canonical example. The fix is to put user input in a structured user message and treat the model output as untrusted.',
|
|
6697
|
+
check(content, filePath) {
|
|
6698
|
+
if (!LLM_FILE_RE.test(filePath)) return [];
|
|
6699
|
+
if (isTestFile(filePath)) return [];
|
|
6700
|
+
if (!fileUsesLLMSDK(content)) return [];
|
|
6701
|
+
const findings = [];
|
|
6702
|
+
const patterns = [
|
|
6703
|
+
// JS/TS template literal: content: `...${req.body.x}...`
|
|
6704
|
+
/["']?content["']?\s*:\s*`[^`]*\$\{(?:[^}]*\b(?:req\.|request\.|body\.|params\.|input|user(?:Input|Message|Query|Msg|Text|Question|Prompt)|args)\b)/g,
|
|
6705
|
+
// JS/TS string concat: content: "..." + req.body.x
|
|
6706
|
+
/["']?content["']?\s*:\s*["'][^"']*["']\s*\+\s*(?:req\.|request\.|body\.|params\.|input|user(?:Input|Message|Query|Msg|Text|Question|Prompt)|args)/g,
|
|
6707
|
+
// Python f-string: "content": f"...{user_input}..."
|
|
6708
|
+
/["']content["']\s*:\s*f["'][^"']*\{(?:[^}]*\b(?:request\.|input|user(?:_input|_message|_query|_text|_msg|_question|_prompt)?|params|args)\b)/g,
|
|
6709
|
+
// Python format: "content": "...".format(user_input)
|
|
6710
|
+
/["']content["']\s*:\s*["'][^"']*\{[^}]*\}["']\s*\.format\s*\(\s*(?:request\.|input|user|params|args)/g
|
|
6711
|
+
];
|
|
6712
|
+
const seenLines = /* @__PURE__ */ new Set();
|
|
6713
|
+
for (const pattern of patterns) {
|
|
6714
|
+
let m;
|
|
6715
|
+
while ((m = pattern.exec(content)) !== null) {
|
|
6716
|
+
if (isCommentLine(content, m.index)) continue;
|
|
6717
|
+
const lineNum = content.substring(0, m.index).split("\n").length;
|
|
6718
|
+
if (seenLines.has(lineNum)) continue;
|
|
6719
|
+
seenLines.add(lineNum);
|
|
6720
|
+
findings.push({
|
|
6721
|
+
rule: "VC198",
|
|
6722
|
+
title: llmPromptInjection.title,
|
|
6723
|
+
severity: "high",
|
|
6724
|
+
category: "Injection",
|
|
6725
|
+
file: filePath,
|
|
6726
|
+
line: lineNum,
|
|
6727
|
+
snippet: getSnippet(content, lineNum),
|
|
6728
|
+
fix: "Don't concatenate user input into prompt strings. Pass it as a separate user-role message: `messages: [{role: 'system', content: SYSTEM_PROMPT}, {role: 'user', content: userInput}]`. Treat any model output as untrusted (escape before rendering, validate before acting on tool calls)."
|
|
6729
|
+
});
|
|
6730
|
+
}
|
|
6731
|
+
}
|
|
6732
|
+
return findings;
|
|
6733
|
+
}
|
|
6734
|
+
};
|
|
6735
|
+
var llmSystemPromptInjection = {
|
|
6736
|
+
id: "VC199",
|
|
6737
|
+
title: "AI/LLM: system prompt constructed with non-literal content",
|
|
6738
|
+
severity: "critical",
|
|
6739
|
+
category: "Injection",
|
|
6740
|
+
description: `System prompts are the highest-trust part of an LLM context \u2014 they define the assistant's identity, safety rules, and tool boundaries. Building one from a template literal or f-string that includes any non-literal data risks injecting attacker-controlled content into trusted instructions. Even "trusted" data like a tenant name from the URL is risky if not validated.`,
|
|
6741
|
+
check(content, filePath) {
|
|
6742
|
+
if (!LLM_FILE_RE.test(filePath)) return [];
|
|
6743
|
+
if (isTestFile(filePath)) return [];
|
|
6744
|
+
if (!fileUsesLLMSDK(content)) return [];
|
|
6745
|
+
const findings = [];
|
|
6746
|
+
const patterns = [
|
|
6747
|
+
// JS/TS: { role: 'system', content: `...${anything}...` }
|
|
6748
|
+
/["']?role["']?\s*:\s*["']system["']\s*,\s*["']?content["']?\s*:\s*`[^`]*\$\{/g,
|
|
6749
|
+
// Python: {"role": "system", "content": f"...{anything}..."}
|
|
6750
|
+
/["']role["']\s*:\s*["']system["']\s*,\s*["']content["']\s*:\s*f["'][^"']*\{/g
|
|
6751
|
+
];
|
|
6752
|
+
for (const pattern of patterns) {
|
|
6753
|
+
let m;
|
|
6754
|
+
while ((m = pattern.exec(content)) !== null) {
|
|
6755
|
+
if (isCommentLine(content, m.index)) continue;
|
|
6756
|
+
const lineNum = content.substring(0, m.index).split("\n").length;
|
|
6757
|
+
findings.push({
|
|
6758
|
+
rule: "VC199",
|
|
6759
|
+
title: llmSystemPromptInjection.title,
|
|
6760
|
+
severity: "critical",
|
|
6761
|
+
category: "Injection",
|
|
6762
|
+
file: filePath,
|
|
6763
|
+
line: lineNum,
|
|
6764
|
+
snippet: getSnippet(content, lineNum),
|
|
6765
|
+
fix: "Keep the system prompt as a literal string. If you need to parameterize it (e.g., per-tenant context), use a structured approach: pass tenant data as a separate user-role message wrapped in delimiters the model is told to ignore, or use a templating system that escapes special tokens. Never let user-controlled input flow into the system role."
|
|
6766
|
+
});
|
|
6767
|
+
}
|
|
6768
|
+
}
|
|
6769
|
+
return findings;
|
|
6770
|
+
}
|
|
6771
|
+
};
|
|
6772
|
+
var llmOutputAsHTML = {
|
|
6773
|
+
id: "VC200",
|
|
6774
|
+
title: "AI/LLM: model output rendered as raw HTML (XSS via model)",
|
|
6775
|
+
severity: "high",
|
|
6776
|
+
category: "Injection",
|
|
6777
|
+
description: "Rendering model output via `dangerouslySetInnerHTML` (React) or `innerHTML` (vanilla JS) treats the model's response as trusted HTML. Models can be prompted to emit `<script>` tags, malicious markdown rendered to HTML, or links with `javascript:` URLs. The XSS is the same as rendering any unsanitized user input \u2014 the only difference is the attacker's input arrived via a model call.",
|
|
6778
|
+
check(content, filePath) {
|
|
6779
|
+
if (!/\.(jsx|tsx|js|ts)$/.test(filePath)) return [];
|
|
6780
|
+
if (isTestFile(filePath)) return [];
|
|
6781
|
+
if (!fileUsesLLMSDK(content)) return [];
|
|
6782
|
+
const findings = [];
|
|
6783
|
+
const patterns = [
|
|
6784
|
+
// dangerouslySetInnerHTML with .choices[0].message.content / .text / etc.
|
|
6785
|
+
/dangerouslySetInnerHTML\s*=\s*\{\{\s*__html\s*:\s*[^}]*\b(?:choices\[\d*\]?\.message|completion|response|message\.content|content_block|delta\.text|generated_text|output_text|text)\b/g,
|
|
6786
|
+
// .innerHTML = response.choices[0].message.content
|
|
6787
|
+
/\.innerHTML\s*=\s*[^;]*\b(?:choices\[\d*\]?\.message|completion|response\.message|message\.content|delta\.text|generated_text|output_text)\b/g
|
|
6788
|
+
];
|
|
6789
|
+
const seenLines = /* @__PURE__ */ new Set();
|
|
6790
|
+
for (const pattern of patterns) {
|
|
6791
|
+
let m;
|
|
6792
|
+
while ((m = pattern.exec(content)) !== null) {
|
|
6793
|
+
if (isCommentLine(content, m.index)) continue;
|
|
6794
|
+
const lineNum = content.substring(0, m.index).split("\n").length;
|
|
6795
|
+
if (seenLines.has(lineNum)) continue;
|
|
6796
|
+
seenLines.add(lineNum);
|
|
6797
|
+
findings.push({
|
|
6798
|
+
rule: "VC200",
|
|
6799
|
+
title: llmOutputAsHTML.title,
|
|
6800
|
+
severity: "high",
|
|
6801
|
+
category: "Injection",
|
|
6802
|
+
file: filePath,
|
|
6803
|
+
line: lineNum,
|
|
6804
|
+
snippet: getSnippet(content, lineNum),
|
|
6805
|
+
fix: "Render model output as text, not HTML. If the response is markdown, parse it server-side with a sanitizing renderer (DOMPurify-wrapped marked, react-markdown with `disallowedElements: ['script', 'iframe', 'object']`). Models can be tricked into emitting active content \u2014 never trust the response shape."
|
|
6806
|
+
});
|
|
6807
|
+
}
|
|
6808
|
+
}
|
|
6809
|
+
return findings;
|
|
6810
|
+
}
|
|
6811
|
+
};
|
|
6812
|
+
var vectorStoreQueryNoUserFilter = {
|
|
6813
|
+
id: "VC201",
|
|
6814
|
+
title: "AI/RAG: vector-store query without user/tenant filter",
|
|
6815
|
+
severity: "high",
|
|
6816
|
+
category: "Authorization",
|
|
6817
|
+
description: "Querying a shared vector index without filtering by user/tenant ID returns results from every user's documents. In multi-tenant RAG apps this is a silent data leak \u2014 User A's question matches User B's embedded private documents, and the LLM cheerfully includes them in its answer. The fix is to scope every query to the requesting user's data.",
|
|
6818
|
+
check(content, filePath) {
|
|
6819
|
+
if (!LLM_FILE_RE.test(filePath)) return [];
|
|
6820
|
+
if (isTestFile(filePath)) return [];
|
|
6821
|
+
if (!fileUsesVectorDB(content)) return [];
|
|
6822
|
+
const findings = [];
|
|
6823
|
+
const callRe = /\b(?:index|client|collection|store|vectorstore)\.(?:query|search|similaritySearch|similarity_search|near_text|near_vector|nearest)\s*\(([^()]*(?:\([^()]*\)[^()]*)*)\)/g;
|
|
6824
|
+
let m;
|
|
6825
|
+
while ((m = callRe.exec(content)) !== null) {
|
|
6826
|
+
if (isCommentLine(content, m.index)) continue;
|
|
6827
|
+
const args = m[1];
|
|
6828
|
+
if (/\b(?:user[_-]?id|userId|tenant[_-]?id|tenantId|org[_-]?id|orgId|owner[_-]?id|ownerId|account[_-]?id|customer[_-]?id|workspace[_-]?id)\b/i.test(args)) continue;
|
|
6829
|
+
if (/\bnamespace\s*[:=]\s*[`"']?[^,)`"']*(?:user|tenant|org|owner|account|customer|workspace)/i.test(args)) continue;
|
|
6830
|
+
if (/\bfilter\s*[:=][\s\S]*?(?:\buser|\btenant|\borg|\bowner|\baccount|\bcustomer|\bworkspace)/i.test(args)) continue;
|
|
6831
|
+
const lineNum = content.substring(0, m.index).split("\n").length;
|
|
6832
|
+
findings.push({
|
|
6833
|
+
rule: "VC201",
|
|
6834
|
+
title: vectorStoreQueryNoUserFilter.title,
|
|
6835
|
+
severity: "high",
|
|
6836
|
+
category: "Authorization",
|
|
6837
|
+
file: filePath,
|
|
6838
|
+
line: lineNum,
|
|
6839
|
+
snippet: getSnippet(content, lineNum),
|
|
6840
|
+
fix: "Add a filter scoping the search to the requesting user's data. Pinecone: `index.query({ vector, topK, filter: { userId: { $eq: currentUser.id } } })`. Qdrant: `client.search({ ..., filter: { must: [{ key: 'userId', match: { value: currentUser.id } }] } })`. For namespace-based isolation, use `namespace: currentUser.id` (Pinecone) or per-tenant collections."
|
|
6841
|
+
});
|
|
6842
|
+
}
|
|
6843
|
+
return findings;
|
|
6844
|
+
}
|
|
6845
|
+
};
|
|
6846
|
+
var vectorStoreUpsertNoMetadata = {
|
|
6847
|
+
id: "VC202",
|
|
6848
|
+
title: "AI/RAG: vector-store upsert without user/tenant metadata",
|
|
6849
|
+
severity: "medium",
|
|
6850
|
+
category: "Authorization",
|
|
6851
|
+
description: "Inserting embeddings without per-user metadata makes per-user filtering at query time impossible \u2014 even if you remember to filter at search, there's nothing to filter on. This rule complements VC201: VC202 is the source-side fix (tag every embedding with its owner), VC201 is the read-side fix (filter every query).",
|
|
6852
|
+
check(content, filePath) {
|
|
6853
|
+
if (!LLM_FILE_RE.test(filePath)) return [];
|
|
6854
|
+
if (isTestFile(filePath)) return [];
|
|
6855
|
+
if (!fileUsesVectorDB(content)) return [];
|
|
6856
|
+
const findings = [];
|
|
6857
|
+
const callRe = /\b(?:index|client|collection|store|vectorstore)\.(?:upsert|insert|add|addDocuments|add_documents)\s*\(([^()]*(?:\([^()]*\)[^()]*)*)\)/g;
|
|
6858
|
+
let m;
|
|
6859
|
+
while ((m = callRe.exec(content)) !== null) {
|
|
6860
|
+
if (isCommentLine(content, m.index)) continue;
|
|
6861
|
+
const args = m[1];
|
|
6862
|
+
if (/\bmetadata\s*[:=]/i.test(args) && /\b(?:user|tenant|org|owner|account|customer|workspace)/i.test(args)) {
|
|
6863
|
+
continue;
|
|
6864
|
+
}
|
|
6865
|
+
if (/\bnamespace\s*[:=]\s*[`"']?[^,)`"']*(?:user|tenant|org)/i.test(args)) {
|
|
6866
|
+
continue;
|
|
6867
|
+
}
|
|
6868
|
+
const lineNum = content.substring(0, m.index).split("\n").length;
|
|
6869
|
+
findings.push({
|
|
6870
|
+
rule: "VC202",
|
|
6871
|
+
title: vectorStoreUpsertNoMetadata.title,
|
|
6872
|
+
severity: "medium",
|
|
6873
|
+
category: "Authorization",
|
|
6874
|
+
file: filePath,
|
|
6875
|
+
line: lineNum,
|
|
6876
|
+
snippet: getSnippet(content, lineNum),
|
|
6877
|
+
fix: "Tag every embedding with its owner: `metadata: { userId: currentUser.id, ... }` (Pinecone), `payload: { userId: currentUser.id, ... }` (Qdrant), or use a per-user namespace/collection. This is a prerequisite for query-time filtering (VC201)."
|
|
6878
|
+
});
|
|
6879
|
+
}
|
|
6880
|
+
return findings;
|
|
6881
|
+
}
|
|
6882
|
+
};
|
|
6883
|
+
var llmCallNoMaxTokens = {
|
|
6884
|
+
id: "VC203",
|
|
6885
|
+
title: "AI/LLM: model call without max_tokens / token budget cap (denial of wallet)",
|
|
6886
|
+
severity: "low",
|
|
6887
|
+
category: "Availability",
|
|
6888
|
+
description: "LLM API calls without a max_tokens cap let the model generate up to the model's full context-window response budget. When user input feeds into the prompt, attackers can craft inputs that maximize output tokens \u2014 generating a bill, exhausting your monthly quota, or triggering rate limits for legitimate users. Always pin a sensible upper bound.",
|
|
6889
|
+
check(content, filePath) {
|
|
6890
|
+
if (!LLM_FILE_RE.test(filePath)) return [];
|
|
6891
|
+
if (isTestFile(filePath)) return [];
|
|
6892
|
+
if (!fileUsesLLMSDK(content)) return [];
|
|
6893
|
+
const findings = [];
|
|
6894
|
+
const callNameRe = /\b(?:chat\.completions\.create|chat\.create|messages\.create|completions\.create|generate_content|generateContent)\s*\(/g;
|
|
6895
|
+
const TOKEN_KW_RE = /\b(?:max_tokens|max_output_tokens|maxTokens|maxOutputTokens|max_new_tokens|maxOutputTokenCount|max_completion_tokens|maxCompletionTokens)\b/;
|
|
6896
|
+
let m;
|
|
6897
|
+
while ((m = callNameRe.exec(content)) !== null) {
|
|
6898
|
+
if (isCommentLine(content, m.index)) continue;
|
|
6899
|
+
const openIdx = m.index + m[0].length - 1;
|
|
6900
|
+
let depth = 1;
|
|
6901
|
+
let i = openIdx + 1;
|
|
6902
|
+
let inStr = null;
|
|
6903
|
+
while (i < content.length && depth > 0) {
|
|
6904
|
+
const ch = content[i];
|
|
6905
|
+
if (inStr) {
|
|
6906
|
+
if (ch === "\\") {
|
|
6907
|
+
i += 2;
|
|
6908
|
+
continue;
|
|
6909
|
+
}
|
|
6910
|
+
if (ch === inStr) inStr = null;
|
|
6911
|
+
} else {
|
|
6912
|
+
if (ch === '"' || ch === "'" || ch === "`") inStr = ch;
|
|
6913
|
+
else if (ch === "(") depth++;
|
|
6914
|
+
else if (ch === ")") depth--;
|
|
6915
|
+
}
|
|
6916
|
+
if (depth === 0) break;
|
|
6917
|
+
i++;
|
|
6918
|
+
}
|
|
6919
|
+
if (depth !== 0) continue;
|
|
6920
|
+
const args = content.substring(openIdx + 1, i).replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/[^\n]*/g, "").replace(/#[^\n]*/g, "");
|
|
6921
|
+
if (TOKEN_KW_RE.test(args)) continue;
|
|
6922
|
+
const lineNum = content.substring(0, m.index).split("\n").length;
|
|
6923
|
+
findings.push({
|
|
6924
|
+
rule: "VC203",
|
|
6925
|
+
title: llmCallNoMaxTokens.title,
|
|
6926
|
+
severity: "low",
|
|
6927
|
+
category: "Availability",
|
|
6928
|
+
file: filePath,
|
|
6929
|
+
line: lineNum,
|
|
6930
|
+
snippet: getSnippet(content, lineNum),
|
|
6931
|
+
fix: "Pin an explicit token cap. OpenAI: `max_tokens: 1024` (or `max_completion_tokens` for o1 models). Anthropic: `max_tokens: 1024`. Google: `maxOutputTokens: 1024`. Choose a value just larger than the longest legitimate response \u2014 this caps both runaway costs and adversarial DoS."
|
|
6932
|
+
});
|
|
6933
|
+
}
|
|
6934
|
+
return findings;
|
|
6935
|
+
}
|
|
6936
|
+
};
|
|
6937
|
+
var GQL_FILE_RE = /\.(js|ts|jsx|tsx|mjs|cjs)$/;
|
|
6938
|
+
function fileInstantiatesGraphQLServer(content) {
|
|
6939
|
+
return /\bnew\s+ApolloServer\s*\(/.test(content) || /\bcreateYoga\s*\(/.test(content) || /\bgraphqlHTTP\s*\(/.test(content) || /\bcreateHandler\s*\(\s*\{[\s\S]{0,200}?schema/.test(content) || // graphql-http
|
|
6940
|
+
/\bmercurius\s*\(/.test(content);
|
|
6941
|
+
}
|
|
6942
|
+
var graphqlNoDepthLimit = {
|
|
6943
|
+
id: "VC204",
|
|
6944
|
+
title: "GraphQL: server has no query depth limit",
|
|
6945
|
+
severity: "high",
|
|
6946
|
+
category: "Availability",
|
|
6947
|
+
description: "Without a query depth limit, attackers can submit deeply-nested queries that explode in execution cost \u2014 `user { friends { friends { friends { ... } } } }` repeated 100 levels deep. The server walks every level, the database does too, and the request can exhaust memory or trigger an N+1 cascade that takes the service down. Free, easy DoS.",
|
|
6948
|
+
check(content, filePath) {
|
|
6949
|
+
if (!GQL_FILE_RE.test(filePath)) return [];
|
|
6950
|
+
if (isTestFile(filePath)) return [];
|
|
6951
|
+
if (!fileInstantiatesGraphQLServer(content)) return [];
|
|
6952
|
+
const codeOnly = content.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/[^\n]*/g, "");
|
|
6953
|
+
const hasDepthLimit = /\b(?:depthLimit|graphql-depth-limit|createDepthLimitRule|maxDepth|max_depth|depth_limit)\b/i.test(codeOnly) || // Envelop's `useDepthLimit` plugin
|
|
6954
|
+
/\buseDepthLimit\s*\(/.test(codeOnly) || // graphql-armor (covers depth + complexity + more)
|
|
6955
|
+
/\b(?:graphql-armor|@escape\.tech\/graphql-armor)\b/i.test(codeOnly);
|
|
6956
|
+
if (hasDepthLimit) return [];
|
|
6957
|
+
const findings = [];
|
|
6958
|
+
const anchorRe = /\b(?:new\s+ApolloServer|createYoga|graphqlHTTP|createHandler|mercurius)\s*\(/g;
|
|
6959
|
+
let m;
|
|
6960
|
+
while ((m = anchorRe.exec(content)) !== null) {
|
|
6961
|
+
if (isCommentLine(content, m.index)) continue;
|
|
6962
|
+
const lineNum = content.substring(0, m.index).split("\n").length;
|
|
6963
|
+
findings.push({
|
|
6964
|
+
rule: "VC204",
|
|
6965
|
+
title: graphqlNoDepthLimit.title,
|
|
6966
|
+
severity: "high",
|
|
6967
|
+
category: "Availability",
|
|
6968
|
+
file: filePath,
|
|
6969
|
+
line: lineNum,
|
|
6970
|
+
snippet: getSnippet(content, lineNum),
|
|
6971
|
+
fix: "Add a query depth limit. Apollo: `validationRules: [depthLimit(7)]` (from `graphql-depth-limit`). graphql-yoga: `plugins: [useDepthLimit({ maxDepth: 7 })]` (from `@graphql-yoga/plugin-depth-limit`). Or use `graphql-armor` for depth + complexity + more in one plugin."
|
|
6972
|
+
});
|
|
6973
|
+
break;
|
|
6974
|
+
}
|
|
6975
|
+
return findings;
|
|
6976
|
+
}
|
|
6977
|
+
};
|
|
6978
|
+
var graphqlNoComplexityLimit = {
|
|
6979
|
+
id: "VC205",
|
|
6980
|
+
title: "GraphQL: server has no query complexity limit",
|
|
6981
|
+
severity: "medium",
|
|
6982
|
+
category: "Availability",
|
|
6983
|
+
description: "Even with a depth limit, attackers can build expensive queries that aren't deep but multiply at each level: `users(first: 1000) { posts(first: 1000) { comments(first: 1000) { ... } } }` is 3 levels deep but resolves a billion items. Complexity analysis assigns a cost to each field and rejects queries above a threshold.",
|
|
6984
|
+
check(content, filePath) {
|
|
6985
|
+
if (!GQL_FILE_RE.test(filePath)) return [];
|
|
6986
|
+
if (isTestFile(filePath)) return [];
|
|
6987
|
+
if (!fileInstantiatesGraphQLServer(content)) return [];
|
|
6988
|
+
const codeOnly = content.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/[^\n]*/g, "");
|
|
6989
|
+
const hasComplexityLimit = /\b(?:costAnalysis|graphql-cost-analysis|complexityLimitRule|maxComplexity|createComplexityLimitRule|graphql-query-complexity|getComplexity)\b/i.test(codeOnly) || /\buseDepthLimit\s*\(/.test(codeOnly) && /\bmaxTokens\s*:|maxAliases\s*:/.test(codeOnly) || /\b(?:graphql-armor|@escape\.tech\/graphql-armor)\b/i.test(codeOnly);
|
|
6990
|
+
if (hasComplexityLimit) return [];
|
|
6991
|
+
const findings = [];
|
|
6992
|
+
const anchorRe = /\b(?:new\s+ApolloServer|createYoga|graphqlHTTP|createHandler|mercurius)\s*\(/g;
|
|
6993
|
+
let m;
|
|
6994
|
+
while ((m = anchorRe.exec(content)) !== null) {
|
|
6995
|
+
if (isCommentLine(content, m.index)) continue;
|
|
6996
|
+
const lineNum = content.substring(0, m.index).split("\n").length;
|
|
6997
|
+
findings.push({
|
|
6998
|
+
rule: "VC205",
|
|
6999
|
+
title: graphqlNoComplexityLimit.title,
|
|
7000
|
+
severity: "medium",
|
|
7001
|
+
category: "Availability",
|
|
7002
|
+
file: filePath,
|
|
7003
|
+
line: lineNum,
|
|
7004
|
+
snippet: getSnippet(content, lineNum),
|
|
7005
|
+
fix: "Add a query complexity limit. Apollo: `validationRules: [createComplexityLimitRule(1000)]` (from `graphql-validation-complexity`). graphql-yoga: `plugins: [useDepthLimit({ maxTokens: 1000 })]`. Or use `graphql-armor` which bundles depth + complexity + cost in one plugin."
|
|
7006
|
+
});
|
|
7007
|
+
break;
|
|
7008
|
+
}
|
|
7009
|
+
return findings;
|
|
7010
|
+
}
|
|
7011
|
+
};
|
|
7012
|
+
var graphqlCSRFDisabled = {
|
|
7013
|
+
id: "VC206",
|
|
7014
|
+
title: "GraphQL: Apollo Server with csrfPrevention: false",
|
|
7015
|
+
severity: "high",
|
|
7016
|
+
category: "Configuration",
|
|
7017
|
+
description: "Apollo Server v4+ enables `csrfPrevention` by default \u2014 it requires a non-simple Content-Type header on mutations to block CSRF attacks from <form>-style submissions. Setting `csrfPrevention: false` removes that protection, letting any website with a logged-in user trigger mutations on your GraphQL endpoint.",
|
|
7018
|
+
check(content, filePath) {
|
|
7019
|
+
if (!GQL_FILE_RE.test(filePath)) return [];
|
|
7020
|
+
if (isTestFile(filePath)) return [];
|
|
7021
|
+
const findings = [];
|
|
7022
|
+
const pattern = /\bcsrfPrevention\s*:\s*false\b/g;
|
|
7023
|
+
let m;
|
|
7024
|
+
while ((m = pattern.exec(content)) !== null) {
|
|
7025
|
+
if (isCommentLine(content, m.index)) continue;
|
|
7026
|
+
const lineNum = content.substring(0, m.index).split("\n").length;
|
|
7027
|
+
findings.push({
|
|
7028
|
+
rule: "VC206",
|
|
7029
|
+
title: graphqlCSRFDisabled.title,
|
|
7030
|
+
severity: "high",
|
|
7031
|
+
category: "Configuration",
|
|
7032
|
+
file: filePath,
|
|
7033
|
+
line: lineNum,
|
|
7034
|
+
snippet: getSnippet(content, lineNum),
|
|
7035
|
+
fix: "Remove `csrfPrevention: false` \u2014 it's the default in Apollo Server v4+. If a legitimate client is failing because of it, send a `Content-Type: application/json` header (or set `apollo-require-preflight: true`) instead of disabling the protection globally."
|
|
7036
|
+
});
|
|
7037
|
+
}
|
|
7038
|
+
return findings;
|
|
7039
|
+
}
|
|
7040
|
+
};
|
|
7041
|
+
var secretInURLParam = {
|
|
7042
|
+
id: "VC146",
|
|
7043
|
+
title: "Secret Passed in URL Query Parameter",
|
|
7044
|
+
severity: "critical",
|
|
7045
|
+
category: "Secrets",
|
|
7046
|
+
description: "API keys or tokens in URL query parameters are logged in server access logs, browser history, referrer headers, and proxy logs. Secrets in URLs are one of the most common causes of credential exposure.",
|
|
7047
|
+
check(content, filePath) {
|
|
7048
|
+
if (isTestFile(filePath)) return [];
|
|
7049
|
+
if (!filePath.match(/\.(js|ts|jsx|tsx|py|rb|go|java|php)$/)) return [];
|
|
7050
|
+
const findings = [];
|
|
7051
|
+
const patterns = [
|
|
7052
|
+
// Template literals: `?api_key=${secret}` or `&token=${token}`
|
|
7053
|
+
/[?&](?:api_key|apikey|api_secret|token|access_token|secret|key|password|auth|authorization)=\$\{/gi,
|
|
7054
|
+
// String concat: '?key=' + apiKey
|
|
7055
|
+
/[?&](?:api_key|apikey|api_secret|token|access_token|secret|key|password|auth)=["']\s*\+/gi,
|
|
7056
|
+
// Python f-strings: f"?token={token}"
|
|
7057
|
+
/[?&](?:api_key|apikey|token|access_token|secret|key|password)=\{[a-zA-Z_]/g
|
|
7058
|
+
];
|
|
7059
|
+
for (const p of patterns) {
|
|
7060
|
+
let m;
|
|
7061
|
+
const re = new RegExp(p.source, p.flags.includes("g") ? p.flags : `${p.flags}g`);
|
|
7062
|
+
while ((m = re.exec(content)) !== null) {
|
|
7063
|
+
if (isCommentLine(content, m.index)) continue;
|
|
7064
|
+
if (isInsideFixMessage(content, m.index)) continue;
|
|
7065
|
+
const lineNum = content.substring(0, m.index).split("\n").length;
|
|
7066
|
+
findings.push({
|
|
7067
|
+
rule: "VC146",
|
|
7068
|
+
title: this.title,
|
|
7069
|
+
severity: "critical",
|
|
7070
|
+
category: "Secrets",
|
|
7071
|
+
file: filePath,
|
|
7072
|
+
line: lineNum,
|
|
7073
|
+
snippet: getSnippet(content, lineNum),
|
|
7074
|
+
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."
|
|
7075
|
+
});
|
|
7076
|
+
}
|
|
7077
|
+
}
|
|
7078
|
+
return findings;
|
|
7079
|
+
}
|
|
7080
|
+
};
|
|
7081
|
+
var secretLoggedToConsole = {
|
|
7082
|
+
id: "VC147",
|
|
7083
|
+
title: "Secret Logged to Console",
|
|
7084
|
+
severity: "high",
|
|
7085
|
+
category: "Secrets",
|
|
7086
|
+
description: "Logging variables that appear to contain secrets (key, token, password, secret) writes credentials to stdout, log files, and monitoring services where they can be harvested.",
|
|
7087
|
+
check(content, filePath) {
|
|
7088
|
+
if (isTestFile(filePath)) return [];
|
|
7089
|
+
if (!filePath.match(/\.(js|ts|jsx|tsx)$/)) return [];
|
|
7090
|
+
const findings = [];
|
|
7091
|
+
const pattern = /console\.(?:log|debug|info|warn|error)\s*\([^)]*\b(api[_-]?key|apikey|secret|token|password|passwd|credentials|private[_-]?key|auth[_-]?token|access[_-]?token)\b/gi;
|
|
7092
|
+
let m;
|
|
7093
|
+
while ((m = pattern.exec(content)) !== null) {
|
|
7094
|
+
if (isCommentLine(content, m.index)) continue;
|
|
7095
|
+
if (isInsideFixMessage(content, m.index)) continue;
|
|
7096
|
+
const lineNum = content.substring(0, m.index).split("\n").length;
|
|
7097
|
+
findings.push({
|
|
7098
|
+
rule: "VC147",
|
|
7099
|
+
title: this.title,
|
|
7100
|
+
severity: "high",
|
|
7101
|
+
category: "Secrets",
|
|
7102
|
+
file: filePath,
|
|
7103
|
+
line: lineNum,
|
|
7104
|
+
snippet: getSnippet(content, lineNum),
|
|
7105
|
+
fix: "Remove this console statement or redact the secret before logging. Use structured logging with a secret-redaction filter in production."
|
|
7106
|
+
});
|
|
7107
|
+
}
|
|
7108
|
+
return findings;
|
|
7109
|
+
}
|
|
7110
|
+
};
|
|
7111
|
+
var secretInErrorResponse = {
|
|
7112
|
+
id: "VC148",
|
|
7113
|
+
title: "Secret Leaked in Error Response",
|
|
7114
|
+
severity: "critical",
|
|
7115
|
+
category: "Secrets",
|
|
7116
|
+
description: "Returning secrets or secret-named variables in API error responses exposes credentials to anyone who can trigger the error, including unauthenticated users.",
|
|
7117
|
+
check(content, filePath) {
|
|
7118
|
+
if (isTestFile(filePath)) return [];
|
|
7119
|
+
if (!filePath.match(/\.(js|ts|jsx|tsx)$/)) return [];
|
|
7120
|
+
const findings = [];
|
|
7121
|
+
const pattern = /(?:res\.(?:json|send|status\s*\([^)]*\)\s*\.json))\s*\(\s*\{[^}]*\b(api[_-]?key|secret|token|password|credentials|private[_-]?key|process\.env\.[A-Z_]*(?:KEY|SECRET|TOKEN|PASSWORD))\b/gi;
|
|
7122
|
+
let m;
|
|
7123
|
+
while ((m = pattern.exec(content)) !== null) {
|
|
7124
|
+
if (isCommentLine(content, m.index)) continue;
|
|
7125
|
+
if (isInsideFixMessage(content, m.index)) continue;
|
|
7126
|
+
const lineNum = content.substring(0, m.index).split("\n").length;
|
|
7127
|
+
findings.push({
|
|
7128
|
+
rule: "VC148",
|
|
7129
|
+
title: this.title,
|
|
7130
|
+
severity: "critical",
|
|
7131
|
+
category: "Secrets",
|
|
7132
|
+
file: filePath,
|
|
7133
|
+
line: lineNum,
|
|
7134
|
+
snippet: getSnippet(content, lineNum),
|
|
7135
|
+
fix: "Never include secrets in API responses. Return a generic error message and log the detailed error server-side only."
|
|
7136
|
+
});
|
|
7137
|
+
}
|
|
7138
|
+
return findings;
|
|
7139
|
+
}
|
|
7140
|
+
};
|
|
7141
|
+
var secretInBundleConfig = {
|
|
7142
|
+
id: "VC149",
|
|
7143
|
+
title: "Secret in Client-Side Bundle Configuration",
|
|
7144
|
+
severity: "critical",
|
|
7145
|
+
category: "Secrets",
|
|
7146
|
+
description: "Secrets defined in Webpack DefinePlugin, Vite define, or Next.js publicRuntimeConfig are embedded into the client-side JavaScript bundle and visible to anyone viewing the page source.",
|
|
7147
|
+
check(content, filePath) {
|
|
7148
|
+
if (isTestFile(filePath)) return [];
|
|
7149
|
+
if (!filePath.match(/(?:webpack|vite|next)\.config\.|\.config\.(js|ts|mjs)$/)) return [];
|
|
7150
|
+
const findings = [];
|
|
7151
|
+
const patterns = [
|
|
7152
|
+
// Webpack DefinePlugin with secret-named key
|
|
7153
|
+
/DefinePlugin\s*\(\s*\{[^}]*\b(?:API_KEY|SECRET|TOKEN|PASSWORD|PRIVATE_KEY|CLIENT_SECRET)\b/gi,
|
|
7154
|
+
// Vite define with secret-named key
|
|
7155
|
+
/define\s*:\s*\{[^}]*\b(?:API_KEY|SECRET|TOKEN|PASSWORD|PRIVATE_KEY|CLIENT_SECRET)\b/gi,
|
|
7156
|
+
// Next.js publicRuntimeConfig with secret-named key
|
|
7157
|
+
/publicRuntimeConfig\s*:\s*\{[^}]*\b(?:apiKey|secret|token|password|privateKey|clientSecret)\b/gi
|
|
7158
|
+
];
|
|
7159
|
+
for (const p of patterns) {
|
|
7160
|
+
let m;
|
|
7161
|
+
const re = new RegExp(p.source, p.flags.includes("g") ? p.flags : `${p.flags}g`);
|
|
7162
|
+
while ((m = re.exec(content)) !== null) {
|
|
7163
|
+
if (isCommentLine(content, m.index)) continue;
|
|
7164
|
+
if (isInsideFixMessage(content, m.index)) continue;
|
|
7165
|
+
const lineNum = content.substring(0, m.index).split("\n").length;
|
|
7166
|
+
findings.push({
|
|
7167
|
+
rule: "VC149",
|
|
7168
|
+
title: this.title,
|
|
7169
|
+
severity: "critical",
|
|
7170
|
+
category: "Secrets",
|
|
7171
|
+
file: filePath,
|
|
7172
|
+
line: lineNum,
|
|
7173
|
+
snippet: getSnippet(content, lineNum),
|
|
7174
|
+
fix: "Move secrets to server-side environment variables. Only expose public configuration (like API base URLs) in client-side bundle config. Use serverRuntimeConfig (Next.js) or server-only modules."
|
|
7175
|
+
});
|
|
7176
|
+
}
|
|
7177
|
+
}
|
|
7178
|
+
return findings;
|
|
7179
|
+
}
|
|
7180
|
+
};
|
|
7181
|
+
var secretInHTMLAttribute = {
|
|
7182
|
+
id: "VC150",
|
|
7183
|
+
title: "Secret in HTML Meta Tag or Data Attribute",
|
|
7184
|
+
severity: "high",
|
|
7185
|
+
category: "Secrets",
|
|
7186
|
+
description: "API keys or tokens embedded in HTML meta tags or data-* attributes are visible in the page source to anyone who visits the page, including search engine crawlers.",
|
|
7187
|
+
check(content, filePath) {
|
|
7188
|
+
if (isTestFile(filePath)) return [];
|
|
7189
|
+
if (!filePath.match(/\.(html|htm|jsx|tsx|vue|svelte|ejs|hbs|pug)$/)) return [];
|
|
7190
|
+
const findings = [];
|
|
7191
|
+
const patterns = [
|
|
7192
|
+
// <meta name="api-key" content="...">
|
|
7193
|
+
/<meta\s[^>]*name\s*=\s*["'](?:api[_-]?key|secret|token|password)[^>]*>/gi,
|
|
7194
|
+
// data-api-key="actual-value" (not a template/binding)
|
|
7195
|
+
/data-(?:api[_-]?key|secret|token|password|auth)\s*=\s*["'][a-zA-Z0-9_\-]{12,}["']/gi
|
|
7196
|
+
];
|
|
7197
|
+
for (const p of patterns) {
|
|
7198
|
+
let m;
|
|
7199
|
+
const re = new RegExp(p.source, p.flags.includes("g") ? p.flags : `${p.flags}g`);
|
|
7200
|
+
while ((m = re.exec(content)) !== null) {
|
|
7201
|
+
if (isCommentLine(content, m.index)) continue;
|
|
7202
|
+
if (isInsideFixMessage(content, m.index)) continue;
|
|
7203
|
+
const lineNum = content.substring(0, m.index).split("\n").length;
|
|
7204
|
+
findings.push({
|
|
7205
|
+
rule: "VC150",
|
|
7206
|
+
title: this.title,
|
|
7207
|
+
severity: "high",
|
|
7208
|
+
category: "Secrets",
|
|
7209
|
+
file: filePath,
|
|
7210
|
+
line: lineNum,
|
|
7211
|
+
snippet: getSnippet(content, lineNum),
|
|
7212
|
+
fix: "Never embed secrets in HTML attributes or meta tags. Load configuration from server-side APIs or use server-rendered environment injection with non-secret values only."
|
|
7213
|
+
});
|
|
7214
|
+
}
|
|
7215
|
+
}
|
|
7216
|
+
return findings;
|
|
7217
|
+
}
|
|
7218
|
+
};
|
|
7219
|
+
var secretInCLIArgument = {
|
|
7220
|
+
id: "VC151",
|
|
7221
|
+
title: "Secret Passed as Command-Line Argument",
|
|
7222
|
+
severity: "high",
|
|
7223
|
+
category: "Secrets",
|
|
7224
|
+
description: "Secrets interpolated into exec/spawn/execSync command strings are visible in process listings (ps aux), shell history, and system audit logs to any user on the same machine.",
|
|
7225
|
+
check(content, filePath) {
|
|
7226
|
+
if (isTestFile(filePath)) return [];
|
|
7227
|
+
if (!filePath.match(/\.(js|ts|jsx|tsx|py|rb)$/)) return [];
|
|
7228
|
+
const findings = [];
|
|
7229
|
+
const patterns = [
|
|
7230
|
+
/(?:exec|execSync|spawn|spawnSync|child_process)\s*\([^)]*\$\{[^}]*(?:api[_-]?key|secret|token|password|credentials)/gi,
|
|
7231
|
+
/(?:exec|execSync|spawn|spawnSync|child_process)\s*\([^)]*["']\s*\+\s*(?:api[_-]?key|secret|token|password|credentials)/gi,
|
|
7232
|
+
/(?:subprocess|os\.system|os\.popen)\s*\([^)]*\{[^}]*(?:api[_-]?key|secret|token|password|credentials)/gi
|
|
7233
|
+
];
|
|
7234
|
+
for (const p of patterns) {
|
|
7235
|
+
let m;
|
|
7236
|
+
const re = new RegExp(p.source, p.flags.includes("g") ? p.flags : `${p.flags}g`);
|
|
7237
|
+
while ((m = re.exec(content)) !== null) {
|
|
7238
|
+
if (isCommentLine(content, m.index)) continue;
|
|
7239
|
+
if (isInsideFixMessage(content, m.index)) continue;
|
|
7240
|
+
const lineNum = content.substring(0, m.index).split("\n").length;
|
|
7241
|
+
findings.push({
|
|
7242
|
+
rule: "VC151",
|
|
4829
7243
|
title: this.title,
|
|
4830
7244
|
severity: "high",
|
|
4831
7245
|
category: "Secrets",
|
|
@@ -5287,7 +7701,61 @@ var allCustomRules = [
|
|
|
5287
7701
|
missingAIRateLimit,
|
|
5288
7702
|
missingPagination,
|
|
5289
7703
|
exposedDatabaseStudio,
|
|
5290
|
-
insecureDirectObjectReference
|
|
7704
|
+
insecureDirectObjectReference,
|
|
7705
|
+
// VC159–VC183: additional service-specific API key detection
|
|
7706
|
+
hardcodedCohereKey,
|
|
7707
|
+
hardcodedReplicateKey,
|
|
7708
|
+
hardcodedMistralKey,
|
|
7709
|
+
hardcodedTogetherKey,
|
|
7710
|
+
hardcodedGroqKey,
|
|
7711
|
+
hardcodedFireworksKey,
|
|
7712
|
+
hardcodedPostmarkKey,
|
|
7713
|
+
hardcodedResendKey,
|
|
7714
|
+
hardcodedLoopsKey,
|
|
7715
|
+
hardcodedCloudflareToken,
|
|
7716
|
+
hardcodedFastlyToken,
|
|
7717
|
+
hardcodedNetlifyToken,
|
|
7718
|
+
hardcodedRailwayToken,
|
|
7719
|
+
hardcodedFlyToken,
|
|
7720
|
+
hardcodedAlgoliaAdminKey,
|
|
7721
|
+
hardcodedQdrantKey,
|
|
7722
|
+
hardcodedWeaviateKey,
|
|
7723
|
+
hardcodedLinearKey,
|
|
7724
|
+
hardcodedNotionKey,
|
|
7725
|
+
hardcodedDiscordToken,
|
|
7726
|
+
hardcodedIntercomToken,
|
|
7727
|
+
hardcodedSentryAuthToken,
|
|
7728
|
+
hardcodedLogtailToken,
|
|
7729
|
+
hardcodedHighlightKey,
|
|
7730
|
+
hardcodedPlivoToken,
|
|
7731
|
+
// VC184–VC187: GitHub Actions workflow security
|
|
7732
|
+
ghaPullRequestTargetCheckout,
|
|
7733
|
+
ghaPermissionsWriteAll,
|
|
7734
|
+
ghaExpressionInjection,
|
|
7735
|
+
ghaThirdPartyActionWithSecrets,
|
|
7736
|
+
// VC188–VC190: Dockerfile hardening
|
|
7737
|
+
dockerfileADDInsteadOfCOPY,
|
|
7738
|
+
dockerfileUnverifiedShellPipe,
|
|
7739
|
+
dockerfileMissingHealthcheck,
|
|
7740
|
+
// VC191–VC197: Python-specific security gaps
|
|
7741
|
+
pyRequestsVerifyFalse,
|
|
7742
|
+
pyJinja2AutoescapeOff,
|
|
7743
|
+
pyTempfileMktemp,
|
|
7744
|
+
pyDjangoMarkSafe,
|
|
7745
|
+
pyParamikoAutoAdd,
|
|
7746
|
+
pyDjangoAllowedHostsWildcard,
|
|
7747
|
+
pyJWTDecodeWeakConfig,
|
|
7748
|
+
// VC198–VC203: AI / LLM-specific security
|
|
7749
|
+
llmPromptInjection,
|
|
7750
|
+
llmSystemPromptInjection,
|
|
7751
|
+
llmOutputAsHTML,
|
|
7752
|
+
vectorStoreQueryNoUserFilter,
|
|
7753
|
+
vectorStoreUpsertNoMetadata,
|
|
7754
|
+
llmCallNoMaxTokens,
|
|
7755
|
+
// VC204–VC206: GraphQL server hardening
|
|
7756
|
+
graphqlNoDepthLimit,
|
|
7757
|
+
graphqlNoComplexityLimit,
|
|
7758
|
+
graphqlCSRFDisabled
|
|
5291
7759
|
];
|
|
5292
7760
|
function runCustomRules(content, filePath, disabledRules = [], tier = "free", extraRules = []) {
|
|
5293
7761
|
const findings = [];
|
|
@@ -5613,7 +8081,9 @@ function scanEntropy(files) {
|
|
|
5613
8081
|
allRules,
|
|
5614
8082
|
androidDebuggable,
|
|
5615
8083
|
blockingMainThread,
|
|
8084
|
+
buildTaintMap,
|
|
5616
8085
|
calculateGrade,
|
|
8086
|
+
callSpreads,
|
|
5617
8087
|
callbackHell,
|
|
5618
8088
|
clickjacking,
|
|
5619
8089
|
clientComponentSecret,
|
|
@@ -5633,6 +8103,9 @@ function scanEntropy(files) {
|
|
|
5633
8103
|
dockerLatestTag,
|
|
5634
8104
|
dockerRunAsRoot,
|
|
5635
8105
|
dockerTooManyPorts,
|
|
8106
|
+
dockerfileADDInsteadOfCOPY,
|
|
8107
|
+
dockerfileMissingHealthcheck,
|
|
8108
|
+
dockerfileUnverifiedShellPipe,
|
|
5636
8109
|
ecbModeEncryption,
|
|
5637
8110
|
electronNavigationUnrestricted,
|
|
5638
8111
|
emptyCatchBlock,
|
|
@@ -5654,28 +8127,61 @@ function scanEntropy(files) {
|
|
|
5654
8127
|
firebaseClientConfig,
|
|
5655
8128
|
flaskSecretKey,
|
|
5656
8129
|
freeRules,
|
|
8130
|
+
getObjectProperty,
|
|
5657
8131
|
getSnippet,
|
|
8132
|
+
ghaExpressionInjection,
|
|
8133
|
+
ghaPermissionsWriteAll,
|
|
8134
|
+
ghaPullRequestTargetCheckout,
|
|
8135
|
+
ghaThirdPartyActionWithSecrets,
|
|
5658
8136
|
githubActionsInjection,
|
|
8137
|
+
graphqlCSRFDisabled,
|
|
5659
8138
|
graphqlIntrospection,
|
|
8139
|
+
graphqlNoComplexityLimit,
|
|
8140
|
+
graphqlNoDepthLimit,
|
|
8141
|
+
hardcodedAlgoliaAdminKey,
|
|
5660
8142
|
hardcodedAnthropicKey,
|
|
8143
|
+
hardcodedCloudflareToken,
|
|
8144
|
+
hardcodedCohereKey,
|
|
5661
8145
|
hardcodedDatadogKey,
|
|
8146
|
+
hardcodedDiscordToken,
|
|
5662
8147
|
hardcodedEncryptionKey,
|
|
8148
|
+
hardcodedFastlyToken,
|
|
8149
|
+
hardcodedFireworksKey,
|
|
8150
|
+
hardcodedFlyToken,
|
|
5663
8151
|
hardcodedGCPServiceAccount,
|
|
5664
8152
|
hardcodedGitHubPAT,
|
|
5665
8153
|
hardcodedGitLabToken,
|
|
8154
|
+
hardcodedGroqKey,
|
|
8155
|
+
hardcodedHighlightKey,
|
|
5666
8156
|
hardcodedIPAllowlist,
|
|
8157
|
+
hardcodedIntercomToken,
|
|
5667
8158
|
hardcodedJWTSecret,
|
|
8159
|
+
hardcodedLinearKey,
|
|
8160
|
+
hardcodedLogtailToken,
|
|
8161
|
+
hardcodedLoopsKey,
|
|
5668
8162
|
hardcodedMailgunKey,
|
|
8163
|
+
hardcodedMistralKey,
|
|
8164
|
+
hardcodedNetlifyToken,
|
|
8165
|
+
hardcodedNotionKey,
|
|
5669
8166
|
hardcodedOAuthSecret,
|
|
5670
8167
|
hardcodedPineconeKey,
|
|
8168
|
+
hardcodedPlivoToken,
|
|
8169
|
+
hardcodedPostmarkKey,
|
|
8170
|
+
hardcodedQdrantKey,
|
|
8171
|
+
hardcodedRailwayToken,
|
|
8172
|
+
hardcodedReplicateKey,
|
|
8173
|
+
hardcodedResendKey,
|
|
5671
8174
|
hardcodedSecrets,
|
|
5672
8175
|
hardcodedSendGridKey,
|
|
8176
|
+
hardcodedSentryAuthToken,
|
|
5673
8177
|
hardcodedShopifyToken,
|
|
5674
8178
|
hardcodedSlackToken,
|
|
5675
8179
|
hardcodedSupabaseServiceRole,
|
|
8180
|
+
hardcodedTogetherKey,
|
|
5676
8181
|
hardcodedTwilioKey,
|
|
5677
8182
|
hardcodedVaultToken,
|
|
5678
8183
|
hardcodedVercelToken,
|
|
8184
|
+
hardcodedWeaviateKey,
|
|
5679
8185
|
hostHeaderRedirect,
|
|
5680
8186
|
httpRequestSmuggling,
|
|
5681
8187
|
insecureCookies,
|
|
@@ -5690,6 +8196,8 @@ function scanEntropy(files) {
|
|
|
5690
8196
|
insecureRandomness,
|
|
5691
8197
|
insecureWebSocket,
|
|
5692
8198
|
ipcPathTraversal,
|
|
8199
|
+
isCalleeNamed,
|
|
8200
|
+
isMethodCall,
|
|
5693
8201
|
javaDeserialization,
|
|
5694
8202
|
jwtAlgConfusion,
|
|
5695
8203
|
k8sNoResourceLimits,
|
|
@@ -5697,6 +8205,10 @@ function scanEntropy(files) {
|
|
|
5697
8205
|
k8sSecretNotEncrypted,
|
|
5698
8206
|
lambdaWithoutVPC,
|
|
5699
8207
|
largeBundleImport,
|
|
8208
|
+
llmCallNoMaxTokens,
|
|
8209
|
+
llmOutputAsHTML,
|
|
8210
|
+
llmPromptInjection,
|
|
8211
|
+
llmSystemPromptInjection,
|
|
5700
8212
|
logInjection,
|
|
5701
8213
|
magicNumbers,
|
|
5702
8214
|
massAssignment,
|
|
@@ -5727,10 +8239,18 @@ function scanEntropy(files) {
|
|
|
5727
8239
|
nosqlInjection,
|
|
5728
8240
|
openRedirectParams,
|
|
5729
8241
|
overlyPermissiveIAM,
|
|
8242
|
+
parseFile,
|
|
5730
8243
|
pathTraversal,
|
|
5731
8244
|
pickleDeserialization,
|
|
5732
8245
|
piiInLogs,
|
|
5733
8246
|
prototypePollution,
|
|
8247
|
+
pyDjangoAllowedHostsWildcard,
|
|
8248
|
+
pyDjangoMarkSafe,
|
|
8249
|
+
pyJWTDecodeWeakConfig,
|
|
8250
|
+
pyJinja2AutoescapeOff,
|
|
8251
|
+
pyParamikoAutoAdd,
|
|
8252
|
+
pyRequestsVerifyFalse,
|
|
8253
|
+
pyTempfileMktemp,
|
|
5734
8254
|
raceCondition,
|
|
5735
8255
|
rdsPubliclyAccessible,
|
|
5736
8256
|
reflectedCORSOrigin,
|
|
@@ -5770,6 +8290,10 @@ function scanEntropy(files) {
|
|
|
5770
8290
|
unvalidatedAPIParams,
|
|
5771
8291
|
unvalidatedEventData,
|
|
5772
8292
|
unvalidatedRedirect,
|
|
8293
|
+
vectorStoreQueryNoUserFilter,
|
|
8294
|
+
vectorStoreUpsertNoMetadata,
|
|
8295
|
+
visitBinary,
|
|
8296
|
+
visitCalls,
|
|
5773
8297
|
vulnerableDependencies,
|
|
5774
8298
|
weakHashing,
|
|
5775
8299
|
weakPasswordRequirements,
|