fullshield 0.1.0 → 0.1.3
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/CHANGELOG.md +109 -3
- package/README.md +48 -6
- package/dist/agent.js +3424 -601
- package/dist/bench/score.js +118 -0
- package/dist/cli/index.js +5857 -2033
- package/dist/compliance.js +31 -2
- package/dist/dashboard.js +50 -9
- package/dist/index.d.ts +152 -2
- package/dist/index.js +5733 -2611
- package/dist/mcp.js +3452 -613
- package/dist/runtime/auto.js +100 -3
- package/dist/runtime/dlp.js +313 -0
- package/dist/runtime/express.js +351 -1
- package/dist/runtime/fastify.js +339 -1
- package/dist/runtime/hono.js +570 -0
- package/dist/runtime/index.js +14 -1
- package/dist/runtime/koa.js +573 -0
- package/dist/runtime/rasp.js +100 -3
- package/dist/runtime/register.js +100 -3
- package/dist/supply.js +87 -1
- package/dist/vex.js +178 -0
- package/package.json +25 -6
- package/rules/CONFIG-DIRLIST-001.md +9 -0
- package/rules/CONFIG-TRUSTPROXY-001.md +11 -0
- package/rules/DLP-CARD-001.md +9 -0
- package/rules/DLP-INTERNAL-001.md +9 -0
- package/rules/DLP-PII-001.md +9 -0
- package/rules/DLP-SECRET-001.md +9 -0
- package/rules/DLP-STACK-001.md +9 -0
- package/rules/HOST-SSH-001.md +13 -0
- package/rules/HOST-SSH-002.md +11 -0
- package/rules/HOST-SSH-003.md +11 -0
- package/rules/MCP-001.md +7 -0
- package/rules/MCP-002.md +7 -0
- package/rules/MCP-003.md +7 -0
- package/rules/MCP-004.md +7 -0
- package/rules/MCP-005.md +7 -0
- package/rules/README.md +47 -1
- package/rules/SAST-COOKIE-001.md +11 -0
- package/rules/SAST-CSRF-001.md +11 -0
- package/rules/SAST-DESER-001.md +11 -0
- package/rules/SAST-GQL-001.md +11 -0
- package/rules/SAST-HASH-001.md +11 -0
- package/rules/SAST-MASSASSIGN-001.md +11 -0
- package/rules/SAST-NOSQLI-001.md +6 -2
- package/rules/SAST-REDOS-001.md +11 -0
- package/rules/SAST-TIMING-001.md +11 -0
- package/rules/SAST-UPLOAD-001.md +11 -0
- package/rules/SAST-XXE-001.md +11 -0
- package/rules/SECRET-BUNDLE-001.md +11 -0
- package/rules/SECRET-SESSION-001.md +9 -0
- package/rules/SUPPLY-WORKFLOW-001.md +9 -0
- package/rules/SUPPLY-WORM-001.md +9 -0
- package/templates/github-watch.yml +3 -3
- package/templates/github-workflow.yml +3 -3
package/dist/mcp.js
CHANGED
|
@@ -1,15 +1,13 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { readFileSync, statSync, mkdtempSync, cpSync, writeFileSync, rmSync, existsSync, readdirSync } from 'fs';
|
|
3
|
-
import { homedir, tmpdir } from 'os';
|
|
4
|
-
import { resolve, join, basename, dirname, posix, relative } from 'path';
|
|
5
|
-
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
6
|
-
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
7
|
-
import { z } from 'zod';
|
|
1
|
+
import { createHash } from 'crypto';
|
|
8
2
|
import { readFile, mkdir, writeFile, stat, readdir } from 'fs/promises';
|
|
3
|
+
import { resolve, join, basename, dirname, relative, posix } from 'path';
|
|
4
|
+
import { execFile, spawnSync, spawn, execFileSync } from 'child_process';
|
|
5
|
+
import { createInterface } from 'readline';
|
|
6
|
+
import { promisify } from 'util';
|
|
7
|
+
import { existsSync, statSync, readFileSync, readdirSync, mkdtempSync, cpSync, writeFileSync, rmSync } from 'fs';
|
|
8
|
+
import { homedir, tmpdir } from 'os';
|
|
9
9
|
import { performance } from 'perf_hooks';
|
|
10
|
-
import { createHash } from 'crypto';
|
|
11
10
|
import ignoreModule from 'ignore';
|
|
12
|
-
import { promisify } from 'util';
|
|
13
11
|
import { fileURLToPath } from 'url';
|
|
14
12
|
|
|
15
13
|
var __defProp = Object.defineProperty;
|
|
@@ -21,22 +19,966 @@ var __export = (target, all) => {
|
|
|
21
19
|
for (var name in all)
|
|
22
20
|
__defProp(target, name, { get: all[name], enumerable: true });
|
|
23
21
|
};
|
|
22
|
+
function isSeverity(value) {
|
|
23
|
+
return SEVERITIES.includes(value);
|
|
24
|
+
}
|
|
25
|
+
function isModuleName(value) {
|
|
26
|
+
return MODULE_NAMES.includes(value);
|
|
27
|
+
}
|
|
28
|
+
function severityRank(severity) {
|
|
29
|
+
return SEVERITIES.indexOf(severity);
|
|
30
|
+
}
|
|
31
|
+
function makeFindingId(ruleId, file, snippet) {
|
|
32
|
+
const normalized = (snippet ?? "").replace(/\s+/g, " ").trim();
|
|
33
|
+
return createHash("sha256").update(`${ruleId}\0${file ?? ""}\0${normalized}`).digest("hex").slice(0, 16);
|
|
34
|
+
}
|
|
35
|
+
function sortFindings(findings) {
|
|
36
|
+
return [...findings].sort(
|
|
37
|
+
(a, b) => severityRank(b.severity) - severityRank(a.severity) || (a.file ?? "").localeCompare(b.file ?? "") || (a.line ?? 0) - (b.line ?? 0) || a.ruleId.localeCompare(b.ruleId)
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
function summarize(findings) {
|
|
41
|
+
const bySeverity = Object.fromEntries(SEVERITIES.map((s) => [s, 0]));
|
|
42
|
+
for (const finding4 of findings) {
|
|
43
|
+
bySeverity[finding4.severity] += 1;
|
|
44
|
+
}
|
|
45
|
+
return { total: findings.length, bySeverity };
|
|
46
|
+
}
|
|
47
|
+
var SEVERITIES, MODULE_NAMES;
|
|
48
|
+
var init_finding = __esm({
|
|
49
|
+
"src/core/finding.ts"() {
|
|
50
|
+
SEVERITIES = ["info", "low", "medium", "high", "critical"];
|
|
51
|
+
MODULE_NAMES = ["deps", "secrets", "sast", "config"];
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// src/modules/secrets/mask.ts
|
|
56
|
+
function everyMatch(re, text) {
|
|
57
|
+
re.lastIndex = 0;
|
|
58
|
+
return [...text.matchAll(re)];
|
|
59
|
+
}
|
|
60
|
+
function maskSecret(secret) {
|
|
61
|
+
return `${secret.slice(0, 4)}****`;
|
|
62
|
+
}
|
|
63
|
+
function foldStringConcat(line) {
|
|
64
|
+
return line.replace(/(["'`])\s*\+\s*(["'`])/g, "");
|
|
65
|
+
}
|
|
66
|
+
function shannonEntropy(value) {
|
|
67
|
+
if (value.length === 0) return 0;
|
|
68
|
+
const counts = /* @__PURE__ */ new Map();
|
|
69
|
+
for (const char of value) {
|
|
70
|
+
counts.set(char, (counts.get(char) ?? 0) + 1);
|
|
71
|
+
}
|
|
72
|
+
let entropy = 0;
|
|
73
|
+
for (const count of counts.values()) {
|
|
74
|
+
const p = count / value.length;
|
|
75
|
+
entropy -= p * Math.log2(p);
|
|
76
|
+
}
|
|
77
|
+
return entropy;
|
|
78
|
+
}
|
|
79
|
+
var init_mask = __esm({
|
|
80
|
+
"src/modules/secrets/mask.ts"() {
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// src/modules/secrets/rules/aiKey.ts
|
|
85
|
+
var PATTERN, aiKeyRule;
|
|
86
|
+
var init_aiKey = __esm({
|
|
87
|
+
"src/modules/secrets/rules/aiKey.ts"() {
|
|
88
|
+
init_mask();
|
|
89
|
+
PATTERN = /\bsk-ant-[A-Za-z0-9_-]{16,}\b|\bsk-[A-Za-z0-9_-]{20,}\b/g;
|
|
90
|
+
aiKeyRule = {
|
|
91
|
+
meta: {
|
|
92
|
+
id: "SECRET-AIKEY-001",
|
|
93
|
+
title: "Hardcoded AI provider API key (OpenAI/Anthropic-style)",
|
|
94
|
+
severity: "high",
|
|
95
|
+
confidence: "high",
|
|
96
|
+
cwe: "CWE-798"
|
|
97
|
+
},
|
|
98
|
+
check(ctx) {
|
|
99
|
+
ctx.lines.forEach((line, index) => {
|
|
100
|
+
for (const match of everyMatch(PATTERN, line)) {
|
|
101
|
+
ctx.report({ line: index + 1, secret: match[0] });
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// src/modules/secrets/rules/aws.ts
|
|
110
|
+
var PATTERN2, awsRule;
|
|
111
|
+
var init_aws = __esm({
|
|
112
|
+
"src/modules/secrets/rules/aws.ts"() {
|
|
113
|
+
init_mask();
|
|
114
|
+
PATTERN2 = /\bAKIA[0-9A-Z]{16}\b/g;
|
|
115
|
+
awsRule = {
|
|
116
|
+
meta: {
|
|
117
|
+
id: "SECRET-AWS-001",
|
|
118
|
+
title: "Hardcoded AWS access key ID",
|
|
119
|
+
severity: "high",
|
|
120
|
+
confidence: "high",
|
|
121
|
+
cwe: "CWE-798"
|
|
122
|
+
},
|
|
123
|
+
check(ctx) {
|
|
124
|
+
ctx.lines.forEach((line, index) => {
|
|
125
|
+
for (const match of everyMatch(PATTERN2, line)) {
|
|
126
|
+
ctx.report({ line: index + 1, secret: match[0] });
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// src/modules/secrets/rules/dbUrl.ts
|
|
135
|
+
function isPlaceholderPassword(password) {
|
|
136
|
+
if (password.includes("${")) return true;
|
|
137
|
+
if (/^<[^>]+>$/.test(password)) return true;
|
|
138
|
+
if (/^\{[^}]+\}$/.test(password)) return true;
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
var PATTERN3, dbUrlRule;
|
|
142
|
+
var init_dbUrl = __esm({
|
|
143
|
+
"src/modules/secrets/rules/dbUrl.ts"() {
|
|
144
|
+
init_mask();
|
|
145
|
+
PATTERN3 = /\b(?:postgres(?:ql)?|mysql|mariadb|mongodb(?:\+srv)?|redis|amqp):\/\/([^\s'"@/:]+):([^\s'"@]+)@/g;
|
|
146
|
+
dbUrlRule = {
|
|
147
|
+
meta: {
|
|
148
|
+
id: "SECRET-DBURL-001",
|
|
149
|
+
title: "Database connection string with inline password",
|
|
150
|
+
severity: "high",
|
|
151
|
+
confidence: "high",
|
|
152
|
+
cwe: "CWE-798"
|
|
153
|
+
},
|
|
154
|
+
check(ctx) {
|
|
155
|
+
ctx.lines.forEach((line, index) => {
|
|
156
|
+
for (const match of everyMatch(PATTERN3, line)) {
|
|
157
|
+
const password = match[2];
|
|
158
|
+
if (password === void 0 || isPlaceholderPassword(password)) continue;
|
|
159
|
+
ctx.report({ line: index + 1, secret: password });
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// src/modules/secrets/rules/entropy.ts
|
|
168
|
+
var ASSIGNMENT, SECRET_NAME2, ENTROPY_THRESHOLD, entropyRule;
|
|
169
|
+
var init_entropy = __esm({
|
|
170
|
+
"src/modules/secrets/rules/entropy.ts"() {
|
|
171
|
+
init_mask();
|
|
172
|
+
ASSIGNMENT = /\b([A-Za-z_$][\w$]*)\s*[:=]\s*["'`]([^"'`]{20,})["'`]/g;
|
|
173
|
+
SECRET_NAME2 = /(key|secret|token|passw(or)?d|pwd|credential)/i;
|
|
174
|
+
ENTROPY_THRESHOLD = 4.5;
|
|
175
|
+
entropyRule = {
|
|
176
|
+
meta: {
|
|
177
|
+
id: "SECRET-ENTROPY-001",
|
|
178
|
+
title: "High-entropy string assigned to a secret-like variable",
|
|
179
|
+
severity: "medium",
|
|
180
|
+
confidence: "low",
|
|
181
|
+
cwe: "CWE-798"
|
|
182
|
+
},
|
|
183
|
+
check(ctx) {
|
|
184
|
+
ctx.lines.forEach((line, index) => {
|
|
185
|
+
for (const match of everyMatch(ASSIGNMENT, line)) {
|
|
186
|
+
const [, name, value] = match;
|
|
187
|
+
if (name === void 0 || value === void 0) continue;
|
|
188
|
+
if (!SECRET_NAME2.test(name)) continue;
|
|
189
|
+
if (value.includes(" ") || value.includes("${")) continue;
|
|
190
|
+
if (shannonEntropy(value) <= ENTROPY_THRESHOLD) continue;
|
|
191
|
+
ctx.report({ line: index + 1, secret: value });
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
// src/modules/secrets/rules/envFile.ts
|
|
200
|
+
var ENV_FILE, envFileRule;
|
|
201
|
+
var init_envFile = __esm({
|
|
202
|
+
"src/modules/secrets/rules/envFile.ts"() {
|
|
203
|
+
ENV_FILE = /^\.env(\..+)?$/;
|
|
204
|
+
envFileRule = {
|
|
205
|
+
meta: {
|
|
206
|
+
id: "SECRET-ENVFILE-001",
|
|
207
|
+
title: "Committed .env file",
|
|
208
|
+
severity: "high",
|
|
209
|
+
confidence: "high",
|
|
210
|
+
cwe: "CWE-312"
|
|
211
|
+
},
|
|
212
|
+
check(ctx) {
|
|
213
|
+
const base = ctx.path.split("/").pop() ?? ctx.path;
|
|
214
|
+
ENV_FILE.lastIndex = 0;
|
|
215
|
+
if (ENV_FILE.test(base) && base !== ".env.example" && base !== ".env.backup") {
|
|
216
|
+
ctx.report({
|
|
217
|
+
line: 1,
|
|
218
|
+
message: `${ctx.path} is tracked in the repository. .env files hold live credentials and must never be committed.`
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
// src/modules/secrets/rules/github.ts
|
|
227
|
+
var PATTERN4, githubRule;
|
|
228
|
+
var init_github = __esm({
|
|
229
|
+
"src/modules/secrets/rules/github.ts"() {
|
|
230
|
+
init_mask();
|
|
231
|
+
PATTERN4 = /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36}\b/g;
|
|
232
|
+
githubRule = {
|
|
233
|
+
meta: {
|
|
234
|
+
id: "SECRET-GITHUB-001",
|
|
235
|
+
title: "Hardcoded GitHub token",
|
|
236
|
+
severity: "high",
|
|
237
|
+
confidence: "high",
|
|
238
|
+
cwe: "CWE-798"
|
|
239
|
+
},
|
|
240
|
+
check(ctx) {
|
|
241
|
+
ctx.lines.forEach((line, index) => {
|
|
242
|
+
for (const match of everyMatch(PATTERN4, line)) {
|
|
243
|
+
ctx.report({ line: index + 1, secret: match[0] });
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
// src/modules/secrets/rules/google.ts
|
|
252
|
+
var PATTERN5, googleRule;
|
|
253
|
+
var init_google = __esm({
|
|
254
|
+
"src/modules/secrets/rules/google.ts"() {
|
|
255
|
+
init_mask();
|
|
256
|
+
PATTERN5 = /\bAIza[0-9A-Za-z_-]{35}\b/g;
|
|
257
|
+
googleRule = {
|
|
258
|
+
meta: {
|
|
259
|
+
id: "SECRET-GOOGLE-001",
|
|
260
|
+
title: "Hardcoded Google API key",
|
|
261
|
+
severity: "high",
|
|
262
|
+
confidence: "high",
|
|
263
|
+
cwe: "CWE-798"
|
|
264
|
+
},
|
|
265
|
+
check(ctx) {
|
|
266
|
+
ctx.lines.forEach((line, index) => {
|
|
267
|
+
for (const match of everyMatch(PATTERN5, line)) {
|
|
268
|
+
ctx.report({ line: index + 1, secret: match[0] });
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
// src/modules/secrets/rules/jwtSecret.ts
|
|
277
|
+
var PATTERN6, jwtSecretRule;
|
|
278
|
+
var init_jwtSecret = __esm({
|
|
279
|
+
"src/modules/secrets/rules/jwtSecret.ts"() {
|
|
280
|
+
init_mask();
|
|
281
|
+
PATTERN6 = /\b(?:jwt[_-]?secret|jwt[_-]?key|token[_-]?secret|signing[_-]?key)\b\s*[:=]\s*["'`]([^"'`]{8,})["'`]/gi;
|
|
282
|
+
jwtSecretRule = {
|
|
283
|
+
meta: {
|
|
284
|
+
id: "SECRET-JWT-001",
|
|
285
|
+
title: "JWT secret assigned from a string literal",
|
|
286
|
+
severity: "medium",
|
|
287
|
+
confidence: "medium",
|
|
288
|
+
cwe: "CWE-798"
|
|
289
|
+
},
|
|
290
|
+
check(ctx) {
|
|
291
|
+
ctx.lines.forEach((line, index) => {
|
|
292
|
+
for (const match of everyMatch(PATTERN6, line)) {
|
|
293
|
+
const secret = match[1];
|
|
294
|
+
if (secret !== void 0) {
|
|
295
|
+
ctx.report({ line: index + 1, secret });
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
// src/modules/secrets/rules/privateKey.ts
|
|
305
|
+
var PATTERN7, privateKeyRule;
|
|
306
|
+
var init_privateKey = __esm({
|
|
307
|
+
"src/modules/secrets/rules/privateKey.ts"() {
|
|
308
|
+
PATTERN7 = /-----BEGIN (?:[A-Z]+ )*PRIVATE KEY-----/;
|
|
309
|
+
privateKeyRule = {
|
|
310
|
+
meta: {
|
|
311
|
+
id: "SECRET-PRIVKEY-001",
|
|
312
|
+
title: "Private key material committed to the repository",
|
|
313
|
+
severity: "critical",
|
|
314
|
+
confidence: "high",
|
|
315
|
+
cwe: "CWE-321"
|
|
316
|
+
},
|
|
317
|
+
check(ctx) {
|
|
318
|
+
ctx.lines.forEach((line, index) => {
|
|
319
|
+
if (line.match(PATTERN7) !== null) {
|
|
320
|
+
ctx.report({
|
|
321
|
+
line: index + 1,
|
|
322
|
+
message: "A PEM private key block is committed to the repository."
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
// src/modules/secrets/rules/sessionSecret.ts
|
|
332
|
+
var SECRET_PROP, sessionSecretRule;
|
|
333
|
+
var init_sessionSecret = __esm({
|
|
334
|
+
"src/modules/secrets/rules/sessionSecret.ts"() {
|
|
335
|
+
init_mask();
|
|
336
|
+
SECRET_PROP = /\bsecret\s*:\s*['"`]([^'"`]{3,})['"`]/g;
|
|
337
|
+
sessionSecretRule = {
|
|
338
|
+
meta: {
|
|
339
|
+
id: "SECRET-SESSION-001",
|
|
340
|
+
title: "Hardcoded express-session secret",
|
|
341
|
+
severity: "medium",
|
|
342
|
+
confidence: "medium",
|
|
343
|
+
cwe: "CWE-798"
|
|
344
|
+
},
|
|
345
|
+
check(ctx) {
|
|
346
|
+
let pending = 0;
|
|
347
|
+
ctx.lines.forEach((line, index) => {
|
|
348
|
+
if (/\bsession\s*\(/.test(line)) pending = 8;
|
|
349
|
+
if (pending <= 0) return;
|
|
350
|
+
pending -= 1;
|
|
351
|
+
for (const match of everyMatch(SECRET_PROP, line)) {
|
|
352
|
+
const secret = match[1];
|
|
353
|
+
if (secret !== void 0) {
|
|
354
|
+
ctx.report({ line: index + 1, secret });
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
// src/modules/secrets/rules/slack.ts
|
|
364
|
+
var PATTERN8, slackRule;
|
|
365
|
+
var init_slack = __esm({
|
|
366
|
+
"src/modules/secrets/rules/slack.ts"() {
|
|
367
|
+
init_mask();
|
|
368
|
+
PATTERN8 = /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g;
|
|
369
|
+
slackRule = {
|
|
370
|
+
meta: {
|
|
371
|
+
id: "SECRET-SLACK-001",
|
|
372
|
+
title: "Hardcoded Slack token",
|
|
373
|
+
severity: "high",
|
|
374
|
+
confidence: "high",
|
|
375
|
+
cwe: "CWE-798"
|
|
376
|
+
},
|
|
377
|
+
check(ctx) {
|
|
378
|
+
ctx.lines.forEach((line, index) => {
|
|
379
|
+
for (const match of everyMatch(PATTERN8, line)) {
|
|
380
|
+
ctx.report({ line: index + 1, secret: match[0] });
|
|
381
|
+
}
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
// src/modules/secrets/rules/stripe.ts
|
|
389
|
+
var PATTERN9, stripeRule;
|
|
390
|
+
var init_stripe = __esm({
|
|
391
|
+
"src/modules/secrets/rules/stripe.ts"() {
|
|
392
|
+
init_mask();
|
|
393
|
+
PATTERN9 = /\bsk_(?:live|test)_[A-Za-z0-9]{16,}\b/g;
|
|
394
|
+
stripeRule = {
|
|
395
|
+
meta: {
|
|
396
|
+
id: "SECRET-STRIPE-001",
|
|
397
|
+
title: "Hardcoded Stripe secret key",
|
|
398
|
+
severity: "high",
|
|
399
|
+
confidence: "high",
|
|
400
|
+
cwe: "CWE-798"
|
|
401
|
+
},
|
|
402
|
+
check(ctx) {
|
|
403
|
+
ctx.lines.forEach((line, index) => {
|
|
404
|
+
for (const match of everyMatch(PATTERN9, line)) {
|
|
405
|
+
ctx.report({ line: index + 1, secret: match[0] });
|
|
406
|
+
}
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
// src/modules/secrets/index.ts
|
|
414
|
+
var secrets_exports = {};
|
|
415
|
+
__export(secrets_exports, {
|
|
416
|
+
scanTextForSecretHits: () => scanTextForSecretHits,
|
|
417
|
+
scanTextForSecrets: () => scanTextForSecrets,
|
|
418
|
+
secretsModule: () => secretsModule,
|
|
419
|
+
secretsRules: () => secretsRules
|
|
420
|
+
});
|
|
421
|
+
function scanTextForSecretHits(path, text) {
|
|
422
|
+
const rawLines = text.split(/\r?\n/);
|
|
423
|
+
const lines = rawLines.map(foldStringConcat);
|
|
424
|
+
const hits = [];
|
|
425
|
+
for (const rule of secretsRules) {
|
|
426
|
+
rule.check({
|
|
427
|
+
path,
|
|
428
|
+
lines,
|
|
429
|
+
rawLines,
|
|
430
|
+
report: ({ line, secret, message }) => {
|
|
431
|
+
const masked = secret !== void 0 ? maskSecret(secret) : void 0;
|
|
432
|
+
hits.push({
|
|
433
|
+
finding: {
|
|
434
|
+
id: makeFindingId(rule.meta.id, path, `${line}|${masked ?? "file"}`),
|
|
435
|
+
ruleId: rule.meta.id,
|
|
436
|
+
module: "secrets",
|
|
437
|
+
title: rule.meta.title,
|
|
438
|
+
severity: rule.meta.severity,
|
|
439
|
+
confidence: rule.meta.confidence,
|
|
440
|
+
cwe: rule.meta.cwe,
|
|
441
|
+
file: path,
|
|
442
|
+
line,
|
|
443
|
+
snippet: masked,
|
|
444
|
+
message: message ?? `${rule.meta.title}${masked !== void 0 ? ` (${masked})` : ""}.`,
|
|
445
|
+
remediation: REMEDIATION
|
|
446
|
+
},
|
|
447
|
+
secret
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
return hits;
|
|
453
|
+
}
|
|
454
|
+
function scanTextForSecrets(path, text) {
|
|
455
|
+
return scanTextForSecretHits(path, text).map((hit) => hit.finding);
|
|
456
|
+
}
|
|
457
|
+
var secretsRules, REMEDIATION, MAX_FILE_SIZE, secretsModule;
|
|
458
|
+
var init_secrets = __esm({
|
|
459
|
+
"src/modules/secrets/index.ts"() {
|
|
460
|
+
init_finding();
|
|
461
|
+
init_mask();
|
|
462
|
+
init_aiKey();
|
|
463
|
+
init_aws();
|
|
464
|
+
init_dbUrl();
|
|
465
|
+
init_entropy();
|
|
466
|
+
init_envFile();
|
|
467
|
+
init_github();
|
|
468
|
+
init_google();
|
|
469
|
+
init_jwtSecret();
|
|
470
|
+
init_privateKey();
|
|
471
|
+
init_sessionSecret();
|
|
472
|
+
init_slack();
|
|
473
|
+
init_stripe();
|
|
474
|
+
secretsRules = [
|
|
475
|
+
awsRule,
|
|
476
|
+
stripeRule,
|
|
477
|
+
githubRule,
|
|
478
|
+
slackRule,
|
|
479
|
+
googleRule,
|
|
480
|
+
aiKeyRule,
|
|
481
|
+
privateKeyRule,
|
|
482
|
+
dbUrlRule,
|
|
483
|
+
jwtSecretRule,
|
|
484
|
+
sessionSecretRule,
|
|
485
|
+
entropyRule,
|
|
486
|
+
envFileRule
|
|
487
|
+
];
|
|
488
|
+
REMEDIATION = "Rotate this credential immediately, then load it from an environment variable or a secret manager. Removing it from the code is NOT enough: it remains visible in git history.";
|
|
489
|
+
MAX_FILE_SIZE = 1024 * 1024;
|
|
490
|
+
secretsModule = {
|
|
491
|
+
name: "secrets",
|
|
492
|
+
async run(ctx) {
|
|
493
|
+
const findings = [];
|
|
494
|
+
for (const rel of ctx.files) {
|
|
495
|
+
let text;
|
|
496
|
+
try {
|
|
497
|
+
text = await readFile(join(ctx.cwd, rel), "utf8");
|
|
498
|
+
} catch {
|
|
499
|
+
continue;
|
|
500
|
+
}
|
|
501
|
+
if (text.length > MAX_FILE_SIZE || text.includes("\0")) continue;
|
|
502
|
+
findings.push(...scanTextForSecrets(rel, text));
|
|
503
|
+
}
|
|
504
|
+
return findings;
|
|
505
|
+
}
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
// src/core/suppression.ts
|
|
511
|
+
function parseIgnore(line) {
|
|
512
|
+
const match = IGNORE.exec(line);
|
|
513
|
+
if (match?.[1] === void 0 || match[2] === void 0) return void 0;
|
|
514
|
+
return { ruleId: match[1], reason: match[2] };
|
|
515
|
+
}
|
|
516
|
+
function applyInlineSuppressions(findings, sources) {
|
|
517
|
+
return findings.filter((finding4) => {
|
|
518
|
+
if (finding4.file === void 0 || finding4.line === void 0) return true;
|
|
519
|
+
const text = sources[finding4.file];
|
|
520
|
+
if (text === void 0) return true;
|
|
521
|
+
const lines = text.split(/\r?\n/);
|
|
522
|
+
const current = lines[finding4.line - 1] ?? "";
|
|
523
|
+
const previous = finding4.line >= 2 ? lines[finding4.line - 2] ?? "" : "";
|
|
524
|
+
for (const line of [current, previous]) {
|
|
525
|
+
const ignore = parseIgnore(line);
|
|
526
|
+
if (ignore !== void 0 && ignore.ruleId === finding4.ruleId) return false;
|
|
527
|
+
}
|
|
528
|
+
return true;
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
var IGNORE;
|
|
532
|
+
var init_suppression = __esm({
|
|
533
|
+
"src/core/suppression.ts"() {
|
|
534
|
+
IGNORE = /fullshield-ignore\s+([A-Z0-9-]+)\s*:\s*(\S+)/;
|
|
535
|
+
}
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
// src/incremental/index.ts
|
|
539
|
+
var incremental_exports = {};
|
|
540
|
+
__export(incremental_exports, {
|
|
541
|
+
resolveChangedFiles: () => resolveChangedFiles
|
|
542
|
+
});
|
|
543
|
+
function resolveChangedFiles(cwd, opts = {}) {
|
|
544
|
+
if (!opts.experimental && process.env.FULLSHIELD_EXPERIMENTAL_PLATFORM !== "1") return [];
|
|
545
|
+
if (!opts.changed && !opts.since) return [];
|
|
546
|
+
const run = opts.git ?? ((args) => spawnSync("git", args, { cwd, encoding: "utf8" }).stdout ?? "");
|
|
547
|
+
const range = opts.since ?? "HEAD";
|
|
548
|
+
const out = opts.changed ? run(["diff", "--name-only", "--cached"]) : run(["diff", "--name-only", range]);
|
|
549
|
+
return out.split("\n").map((l) => l.trim()).filter((l) => l.length > 0);
|
|
550
|
+
}
|
|
551
|
+
var init_incremental = __esm({
|
|
552
|
+
"src/incremental/index.ts"() {
|
|
553
|
+
}
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
// src/modules/secrets/history.ts
|
|
557
|
+
var history_exports = {};
|
|
558
|
+
__export(history_exports, {
|
|
559
|
+
scanGitHistory: () => scanGitHistory
|
|
560
|
+
});
|
|
561
|
+
function gitError(error) {
|
|
562
|
+
const detail = error instanceof Error ? error.message.split("\n")[0] ?? "git failed" : String(error);
|
|
563
|
+
return new Error(`--history requires a git repository and the git CLI (${detail})`);
|
|
564
|
+
}
|
|
565
|
+
async function gitOk(cwd, args) {
|
|
566
|
+
try {
|
|
567
|
+
const { stdout } = await execFileAsync2("git", ["-C", cwd, ...args], {
|
|
568
|
+
encoding: "utf8",
|
|
569
|
+
maxBuffer: MAX_BLOB + 16384
|
|
570
|
+
});
|
|
571
|
+
return stdout;
|
|
572
|
+
} catch (error) {
|
|
573
|
+
throw gitError(error);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
async function readBlob(cwd, hash) {
|
|
577
|
+
if (ZERO_HASH.test(hash)) return void 0;
|
|
578
|
+
try {
|
|
579
|
+
const sizeRaw = await gitOk(cwd, ["cat-file", "-s", hash]);
|
|
580
|
+
const size = Number(sizeRaw.trim());
|
|
581
|
+
if (!Number.isFinite(size) || size <= 0 || size > MAX_BLOB) return void 0;
|
|
582
|
+
const { stdout } = await execFileAsync2("git", ["-C", cwd, "cat-file", "blob", hash], {
|
|
583
|
+
encoding: "utf8",
|
|
584
|
+
maxBuffer: MAX_BLOB + 16384
|
|
585
|
+
});
|
|
586
|
+
if (stdout.includes("\0")) return void 0;
|
|
587
|
+
return stdout;
|
|
588
|
+
} catch {
|
|
589
|
+
return void 0;
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
function remapHit(hit, file) {
|
|
593
|
+
const snippet = hit.finding.snippet;
|
|
594
|
+
const line = hit.finding.line ?? 0;
|
|
595
|
+
const id = makeFindingId(hit.finding.ruleId, file, `${line}|${snippet ?? "file"}`);
|
|
596
|
+
return {
|
|
597
|
+
finding: { ...hit.finding, id, file },
|
|
598
|
+
secret: hit.secret
|
|
599
|
+
};
|
|
600
|
+
}
|
|
601
|
+
async function streamLogChanges(cwd) {
|
|
602
|
+
const changes = [];
|
|
603
|
+
await gitOk(cwd, ["rev-parse", "--is-inside-work-tree"]);
|
|
604
|
+
const child = spawn(
|
|
605
|
+
"git",
|
|
606
|
+
[
|
|
607
|
+
"-C",
|
|
608
|
+
cwd,
|
|
609
|
+
"log",
|
|
610
|
+
"--reverse",
|
|
611
|
+
"--raw",
|
|
612
|
+
"-p",
|
|
613
|
+
"--no-abbrev",
|
|
614
|
+
"--full-index",
|
|
615
|
+
"--format=FSCOMMIT:%H",
|
|
616
|
+
"--no-textconv",
|
|
617
|
+
"--no-ext-diff",
|
|
618
|
+
"HEAD"
|
|
619
|
+
],
|
|
620
|
+
{ stdio: ["ignore", "pipe", "pipe"] }
|
|
621
|
+
);
|
|
622
|
+
const stderrChunks = [];
|
|
623
|
+
child.stderr.on("data", (chunk) => {
|
|
624
|
+
stderrChunks.push(chunk);
|
|
625
|
+
});
|
|
626
|
+
const closed = new Promise((resolve5, reject) => {
|
|
627
|
+
child.on("error", reject);
|
|
628
|
+
child.on("close", (c) => resolve5(c));
|
|
629
|
+
});
|
|
630
|
+
let current = "";
|
|
631
|
+
const rl = createInterface({ input: child.stdout });
|
|
632
|
+
for await (const line of rl) {
|
|
633
|
+
if (line.startsWith("FSCOMMIT:")) {
|
|
634
|
+
current = line.slice("FSCOMMIT:".length).trim();
|
|
635
|
+
continue;
|
|
636
|
+
}
|
|
637
|
+
const match = RAW_LINE.exec(line);
|
|
638
|
+
if (match?.[3] !== void 0 && match[4] !== void 0 && match[6] !== void 0) {
|
|
639
|
+
const newHash = match[4];
|
|
640
|
+
const status = match[5] ?? "";
|
|
641
|
+
if (status.startsWith("D") || ZERO_HASH.test(newHash) || current.length === 0) continue;
|
|
642
|
+
const pathField = match[6];
|
|
643
|
+
const path = pathField.includes(" ") ? pathField.split(" ").pop() ?? pathField : pathField;
|
|
644
|
+
changes.push({ commit: current, hash: newHash, path });
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
let code;
|
|
648
|
+
try {
|
|
649
|
+
code = await closed;
|
|
650
|
+
} catch (error) {
|
|
651
|
+
throw gitError(error);
|
|
652
|
+
}
|
|
653
|
+
if (code !== 0) {
|
|
654
|
+
const err = Buffer.concat(stderrChunks).toString("utf8").trim();
|
|
655
|
+
throw gitError(new Error(err.length > 0 ? err : `git log exited ${code}`));
|
|
656
|
+
}
|
|
657
|
+
return changes;
|
|
658
|
+
}
|
|
659
|
+
async function headSecretKeys(cwd) {
|
|
660
|
+
const keys = /* @__PURE__ */ new Set();
|
|
661
|
+
let ls;
|
|
662
|
+
try {
|
|
663
|
+
ls = await gitOk(cwd, ["ls-tree", "-r", "--full-tree", "HEAD"]);
|
|
664
|
+
} catch {
|
|
665
|
+
return keys;
|
|
666
|
+
}
|
|
667
|
+
const seen = /* @__PURE__ */ new Map();
|
|
668
|
+
for (const line of ls.split("\n")) {
|
|
669
|
+
const match = /^(\d{6}) blob ([0-9a-f]{40})\t(.+)$/.exec(line);
|
|
670
|
+
if (match?.[2] === void 0 || match[3] === void 0) continue;
|
|
671
|
+
const hash = match[2];
|
|
672
|
+
const path = match[3];
|
|
673
|
+
let hits = seen.get(hash);
|
|
674
|
+
if (hits === void 0) {
|
|
675
|
+
const text = await readBlob(cwd, hash);
|
|
676
|
+
if (text === void 0) continue;
|
|
677
|
+
hits = scanTextForSecretHits(path, text);
|
|
678
|
+
seen.set(hash, hits);
|
|
679
|
+
}
|
|
680
|
+
for (const hit of hits) {
|
|
681
|
+
keys.add(`${hit.finding.ruleId}\0${hit.finding.snippet ?? ""}`);
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
return keys;
|
|
685
|
+
}
|
|
686
|
+
async function scanGitHistory(cwd) {
|
|
687
|
+
const changes = await streamLogChanges(cwd);
|
|
688
|
+
const blobHits = /* @__PURE__ */ new Map();
|
|
689
|
+
const first = /* @__PURE__ */ new Map();
|
|
690
|
+
for (const change of changes) {
|
|
691
|
+
let hits2 = blobHits.get(change.hash);
|
|
692
|
+
if (hits2 === void 0) {
|
|
693
|
+
const text = await readBlob(cwd, change.hash);
|
|
694
|
+
if (text === void 0) {
|
|
695
|
+
blobHits.set(change.hash, []);
|
|
696
|
+
continue;
|
|
697
|
+
}
|
|
698
|
+
const rawHits = scanTextForSecretHits(change.path, text);
|
|
699
|
+
const allowed = new Set(
|
|
700
|
+
applyInlineSuppressions(
|
|
701
|
+
rawHits.map((h) => h.finding),
|
|
702
|
+
{ [change.path]: text }
|
|
703
|
+
).map((f) => f.id)
|
|
704
|
+
);
|
|
705
|
+
hits2 = rawHits.filter((h) => allowed.has(h.finding.id));
|
|
706
|
+
blobHits.set(change.hash, hits2);
|
|
707
|
+
}
|
|
708
|
+
for (const hit of hits2) {
|
|
709
|
+
const mapped = remapHit(hit, change.path);
|
|
710
|
+
if (!first.has(mapped.finding.id)) {
|
|
711
|
+
first.set(mapped.finding.id, { hit: mapped, commit: change.commit });
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
const atHead = await headSecretKeys(cwd);
|
|
716
|
+
const hits = [];
|
|
717
|
+
for (const { hit, commit } of first.values()) {
|
|
718
|
+
const stillAtHead = atHead.has(`${hit.finding.ruleId}\0${hit.finding.snippet ?? ""}`);
|
|
719
|
+
hits.push({
|
|
720
|
+
finding: { ...hit.finding, introducedIn: commit, stillAtHead },
|
|
721
|
+
secret: hit.secret
|
|
722
|
+
});
|
|
723
|
+
}
|
|
724
|
+
return { findings: hits.map((h) => h.finding), hits };
|
|
725
|
+
}
|
|
726
|
+
var execFileAsync2, ZERO_HASH, MAX_BLOB, RAW_LINE;
|
|
727
|
+
var init_history = __esm({
|
|
728
|
+
"src/modules/secrets/history.ts"() {
|
|
729
|
+
init_suppression();
|
|
730
|
+
init_finding();
|
|
731
|
+
init_secrets();
|
|
732
|
+
execFileAsync2 = promisify(execFile);
|
|
733
|
+
ZERO_HASH = /^0+$/;
|
|
734
|
+
MAX_BLOB = 1024 * 1024;
|
|
735
|
+
RAW_LINE = /^:(\d{6}) (\d{6}) ([0-9a-f]{40}) ([0-9a-f]{40}) ([A-Z][0-9]*)\t(.+)$/;
|
|
736
|
+
}
|
|
737
|
+
});
|
|
24
738
|
|
|
25
|
-
// src/
|
|
26
|
-
var
|
|
27
|
-
__export(
|
|
28
|
-
|
|
739
|
+
// src/modules/secrets/bundle.ts
|
|
740
|
+
var bundle_exports = {};
|
|
741
|
+
__export(bundle_exports, {
|
|
742
|
+
scanBundle: () => scanBundle
|
|
29
743
|
});
|
|
30
|
-
function
|
|
31
|
-
|
|
32
|
-
if (!opts.changed && !opts.since) return [];
|
|
33
|
-
const run = opts.git ?? ((args) => spawnSync("git", args, { cwd, encoding: "utf8" }).stdout ?? "");
|
|
34
|
-
const range = opts.since ?? "HEAD";
|
|
35
|
-
const out = opts.changed ? run(["diff", "--name-only", "--cached"]) : run(["diff", "--name-only", range]);
|
|
36
|
-
return out.split("\n").map((l) => l.trim()).filter((l) => l.length > 0);
|
|
744
|
+
function posixRel(root, full) {
|
|
745
|
+
return relative(root, full).split("\\").join("/");
|
|
37
746
|
}
|
|
38
|
-
|
|
39
|
-
|
|
747
|
+
async function listFiles(root) {
|
|
748
|
+
const files = [];
|
|
749
|
+
async function walk3(dir) {
|
|
750
|
+
let entries;
|
|
751
|
+
try {
|
|
752
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
753
|
+
} catch {
|
|
754
|
+
return;
|
|
755
|
+
}
|
|
756
|
+
for (const entry of entries) {
|
|
757
|
+
if (entry.isSymbolicLink()) continue;
|
|
758
|
+
const full = join(dir, entry.name);
|
|
759
|
+
if (entry.isDirectory()) {
|
|
760
|
+
if (SKIP_DIRS2.has(entry.name)) continue;
|
|
761
|
+
await walk3(full);
|
|
762
|
+
} else if (entry.isFile()) {
|
|
763
|
+
const base = entry.name;
|
|
764
|
+
if (FILE_OK.test(base) || ENV_NAME.test(base)) files.push(full);
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
const roots = /* @__PURE__ */ new Set([root]);
|
|
769
|
+
for (const sub of BUNDLE_SUBDIRS) roots.add(join(root, sub));
|
|
770
|
+
for (const dir of roots) {
|
|
771
|
+
try {
|
|
772
|
+
const s = await stat(dir);
|
|
773
|
+
if (s.isDirectory()) await walk3(dir);
|
|
774
|
+
else if (s.isFile()) files.push(dir);
|
|
775
|
+
} catch {
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
return files;
|
|
779
|
+
}
|
|
780
|
+
function isSecretShaped(name, value) {
|
|
781
|
+
if (value.startsWith("pk_live_") || value.startsWith("pk_test_")) return false;
|
|
782
|
+
if (/^https?:\/\//i.test(value)) return false;
|
|
783
|
+
if (value.includes(" ") || value.includes("${")) return false;
|
|
784
|
+
const probe = `const ${name} = "${value}";
|
|
785
|
+
`;
|
|
786
|
+
if (scanTextForSecretHits("bundle.js", probe).some((h) => h.secret !== void 0)) return true;
|
|
787
|
+
if (!SECRETISH_NAME.test(name)) return false;
|
|
788
|
+
return value.length >= 16 && shannonEntropy(value) > 4.5;
|
|
789
|
+
}
|
|
790
|
+
function publicEnvHits(rel, text) {
|
|
791
|
+
const hits = [];
|
|
792
|
+
const folded = text.split(/\r?\n/).map((line) => line.replace(/(["'`])\s*\+\s*(["'`])/g, ""));
|
|
793
|
+
folded.forEach((line, index) => {
|
|
794
|
+
for (const match of everyMatch(PUBLIC_ENV, line)) {
|
|
795
|
+
const name = match[1];
|
|
796
|
+
const value = match[2] ?? match[3];
|
|
797
|
+
if (name === void 0 || value === void 0) continue;
|
|
798
|
+
if (!isSecretShaped(name, value)) continue;
|
|
799
|
+
const masked = maskSecret(value);
|
|
800
|
+
const lineNo = index + 1;
|
|
801
|
+
hits.push({
|
|
802
|
+
finding: {
|
|
803
|
+
id: makeFindingId("SECRET-BUNDLE-001", rel, `${lineNo}|${masked}`),
|
|
804
|
+
ruleId: "SECRET-BUNDLE-001",
|
|
805
|
+
module: "secrets",
|
|
806
|
+
title: "Secret-shaped value exposed to the browser",
|
|
807
|
+
severity: "high",
|
|
808
|
+
confidence: "medium",
|
|
809
|
+
cwe: "CWE-798",
|
|
810
|
+
file: rel,
|
|
811
|
+
line: lineNo,
|
|
812
|
+
snippet: masked,
|
|
813
|
+
message: `${name} is inlined into a client bundle (NEXT_PUBLIC_/VITE_* values are browser-visible).`,
|
|
814
|
+
remediation: BUNDLE_REMEDIATION
|
|
815
|
+
},
|
|
816
|
+
secret: value
|
|
817
|
+
});
|
|
818
|
+
}
|
|
819
|
+
});
|
|
820
|
+
return hits;
|
|
821
|
+
}
|
|
822
|
+
async function scanBundle(root) {
|
|
823
|
+
const hits = [];
|
|
824
|
+
const seen = /* @__PURE__ */ new Set();
|
|
825
|
+
for (const full of await listFiles(root)) {
|
|
826
|
+
if (seen.has(full)) continue;
|
|
827
|
+
seen.add(full);
|
|
828
|
+
let text;
|
|
829
|
+
try {
|
|
830
|
+
text = await readFile(full, "utf8");
|
|
831
|
+
} catch {
|
|
832
|
+
continue;
|
|
833
|
+
}
|
|
834
|
+
if (text.length > MAX_FILE || text.includes("\0")) continue;
|
|
835
|
+
const rel = posixRel(root, full) || full;
|
|
836
|
+
hits.push(...scanTextForSecretHits(rel, text), ...publicEnvHits(rel, text));
|
|
837
|
+
}
|
|
838
|
+
return { findings: hits.map((h) => h.finding), hits };
|
|
839
|
+
}
|
|
840
|
+
var BUNDLE_SUBDIRS, SKIP_DIRS2, FILE_OK, ENV_NAME, PUBLIC_ENV, SECRETISH_NAME, MAX_FILE, BUNDLE_REMEDIATION;
|
|
841
|
+
var init_bundle = __esm({
|
|
842
|
+
"src/modules/secrets/bundle.ts"() {
|
|
843
|
+
init_finding();
|
|
844
|
+
init_mask();
|
|
845
|
+
init_secrets();
|
|
846
|
+
BUNDLE_SUBDIRS = ["dist", ".next/static", "build"];
|
|
847
|
+
SKIP_DIRS2 = /* @__PURE__ */ new Set(["node_modules", ".git", ".fullshield"]);
|
|
848
|
+
FILE_OK = /\.(?:[cm]?[jt]sx?|json|html?|css|map|env)(?:\..*)?$/i;
|
|
849
|
+
ENV_NAME = /^\.env(?:\..+)?$/;
|
|
850
|
+
PUBLIC_ENV = /\b((?:NEXT_PUBLIC_|VITE_)[A-Z0-9_]+)\s*[:=]\s*(?:["']([^"']{8,})["']|([^\s"'`,}]{8,}))/g;
|
|
851
|
+
SECRETISH_NAME = /(SECRET|TOKEN|PASSWORD|PASSWD|PWD|KEY)/i;
|
|
852
|
+
MAX_FILE = 1024 * 1024;
|
|
853
|
+
BUNDLE_REMEDIATION = "Remove this value from client code. NEXT_PUBLIC_ and VITE_ names are inlined into the browser bundle. Rotate the credential; shipping a secret to clients cannot be undone by deleting the source line.";
|
|
854
|
+
}
|
|
855
|
+
});
|
|
856
|
+
|
|
857
|
+
// src/modules/secrets/verify.ts
|
|
858
|
+
var verify_exports = {};
|
|
859
|
+
__export(verify_exports, {
|
|
860
|
+
applyVerification: () => applyVerification,
|
|
861
|
+
verifySecretHits: () => verifySecretHits
|
|
862
|
+
});
|
|
863
|
+
function classifyHttp(status) {
|
|
864
|
+
if (status === 200) return "live";
|
|
865
|
+
if (status === 401 || status === 403) return "revoked";
|
|
866
|
+
return "unknown";
|
|
867
|
+
}
|
|
868
|
+
async function call(fetchImpl, url, init) {
|
|
869
|
+
const res = await fetchImpl(url, {
|
|
870
|
+
...init,
|
|
871
|
+
redirect: "error",
|
|
872
|
+
signal: init.signal ?? AbortSignal.timeout(8e3)
|
|
873
|
+
});
|
|
874
|
+
let json;
|
|
875
|
+
try {
|
|
876
|
+
json = await res.json();
|
|
877
|
+
} catch {
|
|
878
|
+
try {
|
|
879
|
+
await res.text();
|
|
880
|
+
} catch {
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
return { status: res.status, json };
|
|
884
|
+
}
|
|
885
|
+
async function verifyAws(secret, fetchImpl) {
|
|
886
|
+
try {
|
|
887
|
+
const { status } = await call(fetchImpl, AWS_URL, {
|
|
888
|
+
method: "POST",
|
|
889
|
+
headers: {
|
|
890
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
891
|
+
"X-Amz-Access-Key-Id": secret
|
|
892
|
+
},
|
|
893
|
+
body: "Action=GetCallerIdentity&Version=2011-06-15"
|
|
894
|
+
});
|
|
895
|
+
if (status === 200) return "live";
|
|
896
|
+
if (status === 401) return "revoked";
|
|
897
|
+
return "unknown";
|
|
898
|
+
} catch {
|
|
899
|
+
return "unknown";
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
async function verifyGithub(secret, fetchImpl) {
|
|
903
|
+
try {
|
|
904
|
+
const { status } = await call(fetchImpl, GITHUB_URL, {
|
|
905
|
+
method: "GET",
|
|
906
|
+
headers: {
|
|
907
|
+
Authorization: `Bearer ${secret}`,
|
|
908
|
+
Accept: "application/vnd.github+json",
|
|
909
|
+
"User-Agent": "fullshield"
|
|
910
|
+
}
|
|
911
|
+
});
|
|
912
|
+
return classifyHttp(status);
|
|
913
|
+
} catch {
|
|
914
|
+
return "unknown";
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
async function verifyStripe(secret, fetchImpl) {
|
|
918
|
+
try {
|
|
919
|
+
const { status } = await call(fetchImpl, STRIPE_URL, {
|
|
920
|
+
method: "GET",
|
|
921
|
+
headers: { Authorization: `Bearer ${secret}` }
|
|
922
|
+
});
|
|
923
|
+
return classifyHttp(status);
|
|
924
|
+
} catch {
|
|
925
|
+
return "unknown";
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
async function verifySlack(secret, fetchImpl) {
|
|
929
|
+
try {
|
|
930
|
+
const { status, json } = await call(fetchImpl, SLACK_URL, {
|
|
931
|
+
method: "POST",
|
|
932
|
+
headers: {
|
|
933
|
+
Authorization: `Bearer ${secret}`,
|
|
934
|
+
"Content-Type": "application/x-www-form-urlencoded"
|
|
935
|
+
},
|
|
936
|
+
body: ""
|
|
937
|
+
});
|
|
938
|
+
if (status !== 200) return classifyHttp(status);
|
|
939
|
+
if (typeof json === "object" && json !== null && "ok" in json) {
|
|
940
|
+
return json.ok === true ? "live" : "revoked";
|
|
941
|
+
}
|
|
942
|
+
return "unknown";
|
|
943
|
+
} catch {
|
|
944
|
+
return "unknown";
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
function providerFor(ruleId, secret) {
|
|
948
|
+
if (ruleId === "SECRET-AWS-001" || secret.startsWith("AKIA")) return verifyAws;
|
|
949
|
+
if (ruleId === "SECRET-GITHUB-001" || /^(?:ghp|gho|ghu|ghs|ghr)_/.test(secret)) return verifyGithub;
|
|
950
|
+
if (ruleId === "SECRET-STRIPE-001" || /^sk_(?:live|test)_/.test(secret)) return verifyStripe;
|
|
951
|
+
if (ruleId === "SECRET-SLACK-001" || /^xox[baprs]-/.test(secret)) return verifySlack;
|
|
952
|
+
return void 0;
|
|
953
|
+
}
|
|
954
|
+
async function verifySecretHits(hits, options = {}) {
|
|
955
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
956
|
+
const out = [];
|
|
957
|
+
for (const hit of hits) {
|
|
958
|
+
const secret = hit.secret;
|
|
959
|
+
if (secret === void 0 || secret.length === 0) {
|
|
960
|
+
out.push({ finding: { ...hit.finding, verification: "unknown" }, secret: hit.secret });
|
|
961
|
+
continue;
|
|
962
|
+
}
|
|
963
|
+
const run = providerFor(hit.finding.ruleId, secret);
|
|
964
|
+
const verification = run === void 0 ? "unknown" : await run(secret, fetchImpl);
|
|
965
|
+
out.push({ finding: { ...hit.finding, verification }, secret: hit.secret });
|
|
966
|
+
}
|
|
967
|
+
return out;
|
|
968
|
+
}
|
|
969
|
+
async function applyVerification(findings, secretById, options = {}) {
|
|
970
|
+
const hits = findings.filter((f) => f.module === "secrets").map((finding4) => ({ finding: finding4, secret: secretById.get(finding4.id) }));
|
|
971
|
+
const verified = await verifySecretHits(hits, options);
|
|
972
|
+
const byId = new Map(verified.map((h) => [h.finding.id, h.finding]));
|
|
973
|
+
return findings.map((f) => byId.get(f.id) ?? f);
|
|
974
|
+
}
|
|
975
|
+
var AWS_URL, GITHUB_URL, STRIPE_URL, SLACK_URL;
|
|
976
|
+
var init_verify = __esm({
|
|
977
|
+
"src/modules/secrets/verify.ts"() {
|
|
978
|
+
AWS_URL = "https://sts.amazonaws.com/";
|
|
979
|
+
GITHUB_URL = "https://api.github.com/user";
|
|
980
|
+
STRIPE_URL = "https://api.stripe.com/v1/balance";
|
|
981
|
+
SLACK_URL = "https://slack.com/api/auth.test";
|
|
40
982
|
}
|
|
41
983
|
});
|
|
42
984
|
|
|
@@ -54,6 +996,24 @@ function calleeName(node) {
|
|
|
54
996
|
if (node.callee.type === "MemberExpression") return propName(node.callee);
|
|
55
997
|
return void 0;
|
|
56
998
|
}
|
|
999
|
+
function calleeChainHas(node, method) {
|
|
1000
|
+
let current = node.callee;
|
|
1001
|
+
for (let i = 0; i < 16 && current !== void 0; i += 1) {
|
|
1002
|
+
if (current.type === "CallExpression") {
|
|
1003
|
+
if (calleeName(current) === method) return true;
|
|
1004
|
+
current = current.callee;
|
|
1005
|
+
continue;
|
|
1006
|
+
}
|
|
1007
|
+
if (current.type === "MemberExpression") {
|
|
1008
|
+
if (propName(current) === method) return true;
|
|
1009
|
+
current = current.object;
|
|
1010
|
+
continue;
|
|
1011
|
+
}
|
|
1012
|
+
if (current.type === "Identifier") return current.name === method;
|
|
1013
|
+
break;
|
|
1014
|
+
}
|
|
1015
|
+
return false;
|
|
1016
|
+
}
|
|
57
1017
|
function literalString(node) {
|
|
58
1018
|
if (node === void 0) return void 0;
|
|
59
1019
|
if (node.type === "Literal" && typeof node.value === "string") return node.value;
|
|
@@ -124,17 +1084,19 @@ function attachParents(node, parent) {
|
|
|
124
1084
|
}
|
|
125
1085
|
}
|
|
126
1086
|
}
|
|
127
|
-
var
|
|
1087
|
+
var REQUEST_SOURCE_PROPS = /* @__PURE__ */ new Set(["query", "body", "params", "headers", "files"]);
|
|
1088
|
+
var SOURCE_PROPS = REQUEST_SOURCE_PROPS;
|
|
128
1089
|
function isTaintSource(node) {
|
|
129
|
-
if (node.type
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
1090
|
+
if (node.type === "MemberExpression") {
|
|
1091
|
+
const name = propName(node);
|
|
1092
|
+
if (node.object.type === "Identifier" && node.object.name === "req" && name !== void 0 && SOURCE_PROPS.has(name)) {
|
|
1093
|
+
return true;
|
|
1094
|
+
}
|
|
1095
|
+
if (node.object.type === "Identifier" && node.object.name === "process" && name === "argv") {
|
|
1096
|
+
return true;
|
|
1097
|
+
}
|
|
1098
|
+
if (node.object.type === "MemberExpression" && isTaintSource(node.object)) return true;
|
|
136
1099
|
}
|
|
137
|
-
if (node.object.type === "MemberExpression" && isTaintSource(node.object)) return true;
|
|
138
1100
|
return false;
|
|
139
1101
|
}
|
|
140
1102
|
function isRequestObjectNode(node) {
|
|
@@ -154,6 +1116,7 @@ function assignedNameOf(node) {
|
|
|
154
1116
|
return parent.id.name;
|
|
155
1117
|
}
|
|
156
1118
|
if (parent.type === "AssignmentExpression" && parent.right === node) {
|
|
1119
|
+
if (parent.left.type === "MemberExpression") return propName(parent.left);
|
|
157
1120
|
const names = boundNames(parent.left);
|
|
158
1121
|
return names[0];
|
|
159
1122
|
}
|
|
@@ -162,35 +1125,23 @@ function assignedNameOf(node) {
|
|
|
162
1125
|
}
|
|
163
1126
|
return void 0;
|
|
164
1127
|
}
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
function
|
|
168
|
-
return
|
|
169
|
-
}
|
|
170
|
-
function isModuleName(value) {
|
|
171
|
-
return MODULE_NAMES.includes(value);
|
|
172
|
-
}
|
|
173
|
-
function severityRank(severity) {
|
|
174
|
-
return SEVERITIES.indexOf(severity);
|
|
1128
|
+
|
|
1129
|
+
// src/modules/sast/experimental.ts
|
|
1130
|
+
function sastGapsEnabled() {
|
|
1131
|
+
return process.env.FULLSHIELD_EXPERIMENTAL_SAST_GAPS === "1";
|
|
175
1132
|
}
|
|
176
|
-
function
|
|
177
|
-
|
|
178
|
-
return createHash("sha256").update(`${ruleId}\0${file ?? ""}\0${normalized}`).digest("hex").slice(0, 16);
|
|
1133
|
+
function frameworksEnabled() {
|
|
1134
|
+
return process.env.FULLSHIELD_EXPERIMENTAL_FRAMEWORKS === "1";
|
|
179
1135
|
}
|
|
180
|
-
function
|
|
181
|
-
return
|
|
182
|
-
(a, b) => severityRank(b.severity) - severityRank(a.severity) || (a.file ?? "").localeCompare(b.file ?? "") || (a.line ?? 0) - (b.line ?? 0) || a.ruleId.localeCompare(b.ruleId)
|
|
183
|
-
);
|
|
1136
|
+
function classesEnabled() {
|
|
1137
|
+
return process.env.FULLSHIELD_EXPERIMENTAL_CLASSES === "1";
|
|
184
1138
|
}
|
|
185
|
-
function
|
|
186
|
-
|
|
187
|
-
for (const finding2 of findings) {
|
|
188
|
-
bySeverity[finding2.severity] += 1;
|
|
189
|
-
}
|
|
190
|
-
return { total: findings.length, bySeverity };
|
|
1139
|
+
function mcpCfgEnabled() {
|
|
1140
|
+
return process.env.FULLSHIELD_EXPERIMENTAL_MCPCFG === "1";
|
|
191
1141
|
}
|
|
192
1142
|
|
|
193
1143
|
// src/modules/config/finding.ts
|
|
1144
|
+
init_finding();
|
|
194
1145
|
function configFinding(ruleId, title, severity, file, message, remediation, line) {
|
|
195
1146
|
return {
|
|
196
1147
|
id: makeFindingId(ruleId, file, `${line ?? 0}|${title}`),
|
|
@@ -207,6 +1158,13 @@ function configFinding(ruleId, title, severity, file, message, remediation, line
|
|
|
207
1158
|
}
|
|
208
1159
|
|
|
209
1160
|
// src/modules/config/astChecks.ts
|
|
1161
|
+
function lineAt(source, index) {
|
|
1162
|
+
let line = 1;
|
|
1163
|
+
for (let i = 0; i < index; i += 1) {
|
|
1164
|
+
if (source[i] === "\n") line += 1;
|
|
1165
|
+
}
|
|
1166
|
+
return line;
|
|
1167
|
+
}
|
|
210
1168
|
function objectProp(node, name) {
|
|
211
1169
|
if (node.type !== "ObjectExpression") return void 0;
|
|
212
1170
|
for (const prop of node.properties) {
|
|
@@ -228,12 +1186,16 @@ async function runConfigAstChecks(file, source) {
|
|
|
228
1186
|
});
|
|
229
1187
|
const findings = [];
|
|
230
1188
|
let createsApp = false;
|
|
1189
|
+
let createsAppLine;
|
|
231
1190
|
let usesHelmet = false;
|
|
232
1191
|
walk(ast, (node) => {
|
|
233
1192
|
if (node.type === "CallExpression") {
|
|
234
1193
|
const name = calleeName(node);
|
|
235
1194
|
if (name === "helmet") usesHelmet = true;
|
|
236
|
-
if (name === "express" && node.callee.type === "Identifier")
|
|
1195
|
+
if (name === "express" && node.callee.type === "Identifier") {
|
|
1196
|
+
createsApp = true;
|
|
1197
|
+
createsAppLine = node.loc?.start.line;
|
|
1198
|
+
}
|
|
237
1199
|
if (node.callee.type === "MemberExpression" && node.callee.object.type === "Identifier" && node.callee.object.name === "express" && name === "json") {
|
|
238
1200
|
const opts = node.arguments[0];
|
|
239
1201
|
const hasLimit = opts !== void 0 && opts.type !== "SpreadElement" && objectProp(opts, "limit") !== void 0;
|
|
@@ -251,6 +1213,19 @@ async function runConfigAstChecks(file, source) {
|
|
|
251
1213
|
);
|
|
252
1214
|
}
|
|
253
1215
|
}
|
|
1216
|
+
if (name === "serveIndex") {
|
|
1217
|
+
findings.push(
|
|
1218
|
+
configFinding(
|
|
1219
|
+
"CONFIG-DIRLIST-001",
|
|
1220
|
+
"Directory listing enabled",
|
|
1221
|
+
"medium",
|
|
1222
|
+
file,
|
|
1223
|
+
"serveIndex() exposes directory contents over HTTP.",
|
|
1224
|
+
"Remove serve-index or restrict it behind auth; do not list ftp/logs/keys.",
|
|
1225
|
+
node.loc?.start.line
|
|
1226
|
+
)
|
|
1227
|
+
);
|
|
1228
|
+
}
|
|
254
1229
|
if (name === "cors") {
|
|
255
1230
|
const opts = node.arguments[0];
|
|
256
1231
|
if (opts !== void 0 && opts.type === "ObjectExpression") {
|
|
@@ -309,6 +1284,26 @@ async function runConfigAstChecks(file, source) {
|
|
|
309
1284
|
);
|
|
310
1285
|
}
|
|
311
1286
|
});
|
|
1287
|
+
const commentedLines = [];
|
|
1288
|
+
if (!usesHelmet) {
|
|
1289
|
+
const commented = /^\s*\/\/\s*(?:(?:const|let|var)\s+helmet\b|app\.use\(\s*helmet\b|.*require\(\s*['"]helmet['"]\s*\))/gim;
|
|
1290
|
+
for (const match of source.matchAll(commented)) {
|
|
1291
|
+
const index = match.index ?? 0;
|
|
1292
|
+
const line = lineAt(source, index);
|
|
1293
|
+
commentedLines.push(line);
|
|
1294
|
+
findings.push(
|
|
1295
|
+
configFinding(
|
|
1296
|
+
"CONFIG-HELMET-001",
|
|
1297
|
+
"Express app does not use helmet",
|
|
1298
|
+
"medium",
|
|
1299
|
+
file,
|
|
1300
|
+
"helmet is commented out; security headers are not applied.",
|
|
1301
|
+
"Uncomment or add app.use(helmet()) to set standard security headers.",
|
|
1302
|
+
line
|
|
1303
|
+
)
|
|
1304
|
+
);
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
312
1307
|
if (createsApp && !usesHelmet) {
|
|
313
1308
|
findings.push(
|
|
314
1309
|
configFinding(
|
|
@@ -317,10 +1312,52 @@ async function runConfigAstChecks(file, source) {
|
|
|
317
1312
|
"medium",
|
|
318
1313
|
file,
|
|
319
1314
|
"An Express app is created but helmet() is never applied.",
|
|
320
|
-
"Add app.use(helmet()) to set standard security headers."
|
|
1315
|
+
"Add app.use(helmet()) to set standard security headers.",
|
|
1316
|
+
// Keep a file-level finding when a comment already carries the line so
|
|
1317
|
+
// scoring treats that as line-attribution, not a new detection.
|
|
1318
|
+
commentedLines.length > 0 ? void 0 : createsAppLine
|
|
321
1319
|
)
|
|
322
1320
|
);
|
|
323
1321
|
}
|
|
1322
|
+
if (classesEnabled()) {
|
|
1323
|
+
let trustProxyTrue = false;
|
|
1324
|
+
let ipRateLimit = false;
|
|
1325
|
+
let trustLine;
|
|
1326
|
+
walk(ast, (node) => {
|
|
1327
|
+
if (node.type !== "CallExpression") return;
|
|
1328
|
+
const name = calleeName(node);
|
|
1329
|
+
if (name === "set" && literalString(node.arguments[0]) === "trust proxy") {
|
|
1330
|
+
const value = node.arguments[1];
|
|
1331
|
+
if (value !== void 0 && value.type === "Literal" && value.value === true) {
|
|
1332
|
+
trustProxyTrue = true;
|
|
1333
|
+
trustLine = node.loc?.start.line;
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
if (name === "enable" && literalString(node.arguments[0]) === "trust proxy") {
|
|
1337
|
+
trustProxyTrue = true;
|
|
1338
|
+
trustLine = node.loc?.start.line;
|
|
1339
|
+
}
|
|
1340
|
+
if (name === "rateLimit" || name === "slowDown") {
|
|
1341
|
+
const opts = node.arguments[0];
|
|
1342
|
+
const keyGen = opts !== void 0 && opts.type !== "SpreadElement" ? objectProp(opts, "keyGenerator") : void 0;
|
|
1343
|
+
const keyedByIp = keyGen === void 0 || keyGen.type !== "SpreadElement" && /ip/i.test(source.slice(keyGen.range?.[0] ?? 0, keyGen.range?.[1] ?? 0));
|
|
1344
|
+
if (keyedByIp) ipRateLimit = true;
|
|
1345
|
+
}
|
|
1346
|
+
});
|
|
1347
|
+
if (trustProxyTrue && ipRateLimit) {
|
|
1348
|
+
findings.push(
|
|
1349
|
+
configFinding(
|
|
1350
|
+
"CONFIG-TRUSTPROXY-001",
|
|
1351
|
+
"trust proxy true with IP-based rate limiting",
|
|
1352
|
+
"high",
|
|
1353
|
+
file,
|
|
1354
|
+
"app.set('trust proxy', true) makes X-Forwarded-For spoofable while rate limits key on IP.",
|
|
1355
|
+
"Set trust proxy to a hop count or subnet, and key rate limits on a verified identity\u2014not the raw client IP.",
|
|
1356
|
+
trustLine
|
|
1357
|
+
)
|
|
1358
|
+
);
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
324
1361
|
return findings;
|
|
325
1362
|
}
|
|
326
1363
|
|
|
@@ -474,6 +1511,9 @@ var configModule = {
|
|
|
474
1511
|
}
|
|
475
1512
|
};
|
|
476
1513
|
|
|
1514
|
+
// src/modules/deps/index.ts
|
|
1515
|
+
init_finding();
|
|
1516
|
+
|
|
477
1517
|
// src/modules/deps/lockfile.ts
|
|
478
1518
|
function basename2(filename) {
|
|
479
1519
|
const parts = filename.replace(/\\/g, "/").split("/");
|
|
@@ -865,6 +1905,30 @@ function isExactNpmVersion(spec) {
|
|
|
865
1905
|
if (/[~^><*xX|]/.test(bare)) return false;
|
|
866
1906
|
return /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(bare);
|
|
867
1907
|
}
|
|
1908
|
+
var MANIFEST_DEP_FIELDS = /"(dependencies|devDependencies|optionalDependencies)"\s*:/;
|
|
1909
|
+
function lineOfManifestDep(raw, name) {
|
|
1910
|
+
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1911
|
+
const depLine = new RegExp(`^\\s*"${escaped}"\\s*:`);
|
|
1912
|
+
let inBlock = false;
|
|
1913
|
+
let depth = 0;
|
|
1914
|
+
const lines = raw.split(/\n/);
|
|
1915
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
1916
|
+
const line = lines[i] ?? "";
|
|
1917
|
+
const startsBlock = MANIFEST_DEP_FIELDS.test(line);
|
|
1918
|
+
if (startsBlock) {
|
|
1919
|
+
inBlock = true;
|
|
1920
|
+
depth = 0;
|
|
1921
|
+
}
|
|
1922
|
+
if (!inBlock) continue;
|
|
1923
|
+
for (const ch of line) {
|
|
1924
|
+
if (ch === "{") depth += 1;
|
|
1925
|
+
if (ch === "}") depth -= 1;
|
|
1926
|
+
}
|
|
1927
|
+
if (depLine.test(line) && depth >= 1) return i + 1;
|
|
1928
|
+
if (depth <= 0 && !startsBlock) inBlock = false;
|
|
1929
|
+
}
|
|
1930
|
+
return void 0;
|
|
1931
|
+
}
|
|
868
1932
|
function exactPinsFromManifest(pkg) {
|
|
869
1933
|
const out = [];
|
|
870
1934
|
for (const field of MANIFEST_FIELDS) {
|
|
@@ -880,6 +1944,7 @@ function exactPinsFromManifest(pkg) {
|
|
|
880
1944
|
}
|
|
881
1945
|
|
|
882
1946
|
// src/modules/deps/reachability.ts
|
|
1947
|
+
init_finding();
|
|
883
1948
|
function packageNameFromSnippet(snippet) {
|
|
884
1949
|
if (snippet.startsWith("@")) {
|
|
885
1950
|
const second = snippet.indexOf("@", 1);
|
|
@@ -1019,26 +2084,26 @@ function isDevOnly(pkgName, ctx) {
|
|
|
1019
2084
|
return directDev.has(pkgName) || reachableFrom(pkgName, directDev, edges);
|
|
1020
2085
|
}
|
|
1021
2086
|
function applyReachability(findings, ctx) {
|
|
1022
|
-
return findings.map((
|
|
1023
|
-
if (
|
|
1024
|
-
if (
|
|
1025
|
-
const pkg2 = packageNameFromSnippet(
|
|
1026
|
-
if (pkg2 === void 0 || pkg2.length === 0) return
|
|
2087
|
+
return findings.map((finding4) => {
|
|
2088
|
+
if (finding4.ruleId === "DEPS-MALICIOUS-001") {
|
|
2089
|
+
if (finding4.snippet === void 0) return finding4;
|
|
2090
|
+
const pkg2 = packageNameFromSnippet(finding4.snippet);
|
|
2091
|
+
if (pkg2 === void 0 || pkg2.length === 0) return finding4;
|
|
1027
2092
|
const classified2 = classifyReachability(pkg2, ctx.productionUsage, ctx.graph);
|
|
1028
2093
|
return {
|
|
1029
|
-
...
|
|
2094
|
+
...finding4,
|
|
1030
2095
|
severity: "critical",
|
|
1031
2096
|
reachability: classified2.reachability,
|
|
1032
2097
|
...classified2.path !== void 0 ? { dependencyPath: classified2.path } : {}
|
|
1033
2098
|
};
|
|
1034
2099
|
}
|
|
1035
|
-
if (
|
|
1036
|
-
const pkg = packageNameFromSnippet(
|
|
1037
|
-
if (pkg === void 0 || pkg.length === 0) return
|
|
2100
|
+
if (finding4.ruleId !== "DEPS-VULN-001" || finding4.snippet === void 0) return finding4;
|
|
2101
|
+
const pkg = packageNameFromSnippet(finding4.snippet);
|
|
2102
|
+
if (pkg === void 0 || pkg.length === 0) return finding4;
|
|
1038
2103
|
const classified = classifyReachability(pkg, ctx.productionUsage, ctx.graph);
|
|
1039
2104
|
const steps = reachabilitySteps(classified.reachability);
|
|
1040
|
-
let severity = steps === 0 ?
|
|
1041
|
-
let message =
|
|
2105
|
+
let severity = steps === 0 ? finding4.severity : downgradeSeverity(finding4.severity, steps);
|
|
2106
|
+
let message = finding4.message;
|
|
1042
2107
|
if (classified.path !== void 0) {
|
|
1043
2108
|
const shown = classified.path.join(" > ");
|
|
1044
2109
|
message = `${message} Reachable via ${shown}.`;
|
|
@@ -1049,7 +2114,7 @@ function applyReachability(findings, ctx) {
|
|
|
1049
2114
|
message = `${message} (dev-only)`;
|
|
1050
2115
|
}
|
|
1051
2116
|
return {
|
|
1052
|
-
...
|
|
2117
|
+
...finding4,
|
|
1053
2118
|
reachability: classified.reachability,
|
|
1054
2119
|
...classified.path !== void 0 ? { dependencyPath: classified.path } : {},
|
|
1055
2120
|
severity,
|
|
@@ -2947,9 +4012,253 @@ function findTyposquats(packages, options = {}) {
|
|
|
2947
4012
|
};
|
|
2948
4013
|
}
|
|
2949
4014
|
}
|
|
2950
|
-
if (best !== void 0) hits.push(best);
|
|
4015
|
+
if (best !== void 0) hits.push(best);
|
|
4016
|
+
}
|
|
4017
|
+
return hits;
|
|
4018
|
+
}
|
|
4019
|
+
|
|
4020
|
+
// src/supply/experimental.ts
|
|
4021
|
+
function experimentalVex() {
|
|
4022
|
+
return process.env.FULLSHIELD_EXPERIMENTAL_VEX === "1";
|
|
4023
|
+
}
|
|
4024
|
+
|
|
4025
|
+
// src/fixer/eol.ts
|
|
4026
|
+
function detectEol(text) {
|
|
4027
|
+
return text.includes("\r\n") ? "\r\n" : "\n";
|
|
4028
|
+
}
|
|
4029
|
+
function normalizeNewlines(text) {
|
|
4030
|
+
return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
4031
|
+
}
|
|
4032
|
+
function applyEol(text, eol) {
|
|
4033
|
+
const normalized = normalizeNewlines(text);
|
|
4034
|
+
return eol === "\n" ? normalized : normalized.replace(/\n/g, "\r\n");
|
|
4035
|
+
}
|
|
4036
|
+
|
|
4037
|
+
// src/fixer/diff.ts
|
|
4038
|
+
function unifiedDiff(filename, before, after) {
|
|
4039
|
+
const oldNorm = normalizeNewlines(before);
|
|
4040
|
+
const newNorm = normalizeNewlines(after);
|
|
4041
|
+
if (oldNorm === newNorm) return "";
|
|
4042
|
+
const oldLines = oldNorm.split("\n");
|
|
4043
|
+
const newLines = newNorm.split("\n");
|
|
4044
|
+
const hunks = [`--- a/${filename}`, `+++ b/${filename}`];
|
|
4045
|
+
hunks.push(`@@ -1,${oldLines.length} +1,${newLines.length} @@`);
|
|
4046
|
+
for (const line of oldLines) hunks.push(`-${line}`);
|
|
4047
|
+
for (const line of newLines) hunks.push(`+${line}`);
|
|
4048
|
+
return hunks.join("\n");
|
|
4049
|
+
}
|
|
4050
|
+
|
|
4051
|
+
// src/fixer/override.ts
|
|
4052
|
+
function lockfileKind(filename) {
|
|
4053
|
+
const base = filename.replace(/\\/g, "/").split("/").pop() ?? filename;
|
|
4054
|
+
if (base === "package-lock.json") return "npm";
|
|
4055
|
+
if (base === "yarn.lock") return "yarn";
|
|
4056
|
+
if (base === "pnpm-lock.yaml") return "pnpm";
|
|
4057
|
+
return void 0;
|
|
4058
|
+
}
|
|
4059
|
+
function stringMap(value) {
|
|
4060
|
+
if (value === void 0 || value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
4061
|
+
return {};
|
|
4062
|
+
}
|
|
4063
|
+
const out = {};
|
|
4064
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
4065
|
+
if (typeof entry === "string") out[key] = entry;
|
|
4066
|
+
}
|
|
4067
|
+
return out;
|
|
4068
|
+
}
|
|
4069
|
+
function objectMap(value) {
|
|
4070
|
+
if (value === void 0 || value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
4071
|
+
return {};
|
|
4072
|
+
}
|
|
4073
|
+
return { ...value };
|
|
4074
|
+
}
|
|
4075
|
+
function withManifestOverride(manifestRaw, kind, name, version) {
|
|
4076
|
+
let pkg;
|
|
4077
|
+
try {
|
|
4078
|
+
pkg = JSON.parse(manifestRaw);
|
|
4079
|
+
} catch {
|
|
4080
|
+
return manifestRaw;
|
|
4081
|
+
}
|
|
4082
|
+
if (kind === "yarn") {
|
|
4083
|
+
pkg.resolutions = { ...stringMap(pkg.resolutions), [name]: version };
|
|
4084
|
+
} else if (kind === "pnpm") {
|
|
4085
|
+
const pnpm = objectMap(pkg.pnpm);
|
|
4086
|
+
pnpm.overrides = { ...stringMap(pnpm.overrides), [name]: version };
|
|
4087
|
+
pkg.pnpm = pnpm;
|
|
4088
|
+
} else {
|
|
4089
|
+
pkg.overrides = { ...stringMap(pkg.overrides), [name]: version };
|
|
4090
|
+
}
|
|
4091
|
+
return `${JSON.stringify(pkg, null, 2)}
|
|
4092
|
+
`;
|
|
4093
|
+
}
|
|
4094
|
+
|
|
4095
|
+
// src/supply/index.ts
|
|
4096
|
+
init_finding();
|
|
4097
|
+
function finding(ruleId, title, message, file, severity = "high") {
|
|
4098
|
+
return {
|
|
4099
|
+
id: makeFindingId(ruleId, file, message),
|
|
4100
|
+
ruleId,
|
|
4101
|
+
module: "deps",
|
|
4102
|
+
title,
|
|
4103
|
+
severity,
|
|
4104
|
+
confidence: "high",
|
|
4105
|
+
file,
|
|
4106
|
+
message,
|
|
4107
|
+
remediation: "Do not install this package until the behavior is reviewed. Use --ignore-scripts."
|
|
4108
|
+
};
|
|
4109
|
+
}
|
|
4110
|
+
function walkJs(dir, out = []) {
|
|
4111
|
+
if (!existsSync(dir)) return out;
|
|
4112
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
4113
|
+
const full = join(dir, entry.name);
|
|
4114
|
+
if (entry.isDirectory()) {
|
|
4115
|
+
if (entry.name === "node_modules") continue;
|
|
4116
|
+
walkJs(full, out);
|
|
4117
|
+
} else if (entry.isFile() && /\.(js|cjs|mjs|ts)$/.test(entry.name)) {
|
|
4118
|
+
out.push(full);
|
|
4119
|
+
}
|
|
4120
|
+
}
|
|
4121
|
+
return out;
|
|
4122
|
+
}
|
|
4123
|
+
function readJson(path) {
|
|
4124
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
4125
|
+
}
|
|
4126
|
+
var TOKEN_RE = /(?:~\/)?\.npmrc\b|\bNPM_TOKEN\b|\bGITHUB_TOKEN\b/;
|
|
4127
|
+
var PUBLISH_RE = /\bnpm\s+publish\b|\bnpmPublish\b|\blibnpmpublish\b/;
|
|
4128
|
+
var REGISTRY_PUT_RE = /registry\.npmjs\.org[\s\S]{0,800}\bPUT\b|\bmethod\s*:\s*['"]PUT['"][\s\S]{0,400}registry\.npmjs\.org/;
|
|
4129
|
+
var GITHUB_CREATE_RE = /api\.github\.com\/(?:user\/repos|repos\/)|\/actions\/workflows/;
|
|
4130
|
+
var WORKFLOW_PATH_RE = /\.github\/workflows/;
|
|
4131
|
+
var CI_WRITE_RE = /\.github\/|\.git\/hooks\/|\.gitlab-ci\.yml|\.travis\.yml|azure-pipelines(?:\.yml)?|Jenkinsfile|\.circleci\//;
|
|
4132
|
+
function packageSources(dir) {
|
|
4133
|
+
const manifestPath = join(dir, "package.json");
|
|
4134
|
+
let scripts = "";
|
|
4135
|
+
if (existsSync(manifestPath) && statSync(manifestPath).isFile()) {
|
|
4136
|
+
try {
|
|
4137
|
+
const pkg = readJson(manifestPath);
|
|
4138
|
+
const block = pkg.scripts ?? {};
|
|
4139
|
+
scripts = Object.values(block).join("\n");
|
|
4140
|
+
} catch {
|
|
4141
|
+
scripts = "";
|
|
4142
|
+
}
|
|
4143
|
+
}
|
|
4144
|
+
const sources = walkJs(dir).map((file) => ({
|
|
4145
|
+
file: relative(dir, file) || file,
|
|
4146
|
+
text: readFileSync(file, "utf8")
|
|
4147
|
+
}));
|
|
4148
|
+
return { sources, scripts };
|
|
4149
|
+
}
|
|
4150
|
+
function analyzeWormIndicators(dir) {
|
|
4151
|
+
const { sources, scripts } = packageSources(dir);
|
|
4152
|
+
if (sources.length === 0 && scripts.length === 0) return [];
|
|
4153
|
+
const text = `${sources.map((s) => s.text).join("\n")}
|
|
4154
|
+
${scripts}`;
|
|
4155
|
+
if (!TOKEN_RE.test(text)) return [];
|
|
4156
|
+
const replicates = PUBLISH_RE.test(text) || REGISTRY_PUT_RE.test(text) || GITHUB_CREATE_RE.test(text) || WORKFLOW_PATH_RE.test(text);
|
|
4157
|
+
if (!replicates) return [];
|
|
4158
|
+
const hit = sources.find(
|
|
4159
|
+
(s) => TOKEN_RE.test(s.text) && (PUBLISH_RE.test(s.text) || REGISTRY_PUT_RE.test(s.text) || GITHUB_CREATE_RE.test(s.text) || WORKFLOW_PATH_RE.test(s.text))
|
|
4160
|
+
) ?? sources.find((s) => TOKEN_RE.test(s.text));
|
|
4161
|
+
return [
|
|
4162
|
+
finding(
|
|
4163
|
+
"SUPPLY-WORM-001",
|
|
4164
|
+
"Self-replicating worm indicators in dependency code",
|
|
4165
|
+
"static: npm/GitHub token read combined with publish, registry PUT, GitHub repo/workflow creation, or writing .github/workflows",
|
|
4166
|
+
hit?.file,
|
|
4167
|
+
"critical"
|
|
4168
|
+
)
|
|
4169
|
+
];
|
|
4170
|
+
}
|
|
4171
|
+
function analyzeWorkflowTampering(dir) {
|
|
4172
|
+
const { sources, scripts } = packageSources(dir);
|
|
4173
|
+
const text = `${sources.map((s) => s.text).join("\n")}
|
|
4174
|
+
${scripts}`;
|
|
4175
|
+
if (!CI_WRITE_RE.test(text)) return [];
|
|
4176
|
+
const hit = sources.find((s) => CI_WRITE_RE.test(s.text));
|
|
4177
|
+
return [
|
|
4178
|
+
finding(
|
|
4179
|
+
"SUPPLY-WORKFLOW-001",
|
|
4180
|
+
"Dependency writes GitHub workflow, git hooks, or CI config",
|
|
4181
|
+
"static: install script or package code writes .github/, .git/hooks/, or CI configuration",
|
|
4182
|
+
hit?.file ?? (CI_WRITE_RE.test(scripts) ? "package.json" : void 0),
|
|
4183
|
+
"critical"
|
|
4184
|
+
)
|
|
4185
|
+
];
|
|
4186
|
+
}
|
|
4187
|
+
|
|
4188
|
+
// src/modules/deps/phase31.ts
|
|
4189
|
+
function packageNameFromSnippet2(snippet) {
|
|
4190
|
+
if (snippet.startsWith("@")) {
|
|
4191
|
+
const second = snippet.indexOf("@", 1);
|
|
4192
|
+
return second === -1 ? snippet : snippet.slice(0, second);
|
|
4193
|
+
}
|
|
4194
|
+
const at = snippet.indexOf("@");
|
|
4195
|
+
return at <= 0 ? snippet : snippet.slice(0, at);
|
|
4196
|
+
}
|
|
4197
|
+
function parentsOf(name, edges) {
|
|
4198
|
+
const out = [];
|
|
4199
|
+
for (const [from, tos] of edges) {
|
|
4200
|
+
if (tos.has(name)) out.push(from);
|
|
4201
|
+
}
|
|
4202
|
+
return out;
|
|
4203
|
+
}
|
|
4204
|
+
function isDirect(name, graph) {
|
|
4205
|
+
return graph.directProd.has(name) || graph.directDev.has(name);
|
|
4206
|
+
}
|
|
4207
|
+
function packagesWithAFixedRelease(findings) {
|
|
4208
|
+
const names = /* @__PURE__ */ new Set();
|
|
4209
|
+
for (const finding4 of findings) {
|
|
4210
|
+
if (finding4.ruleId !== "DEPS-VULN-001" || finding4.snippet === void 0) continue;
|
|
4211
|
+
if (!/Upgrade \S+ to \S+ or later/.test(finding4.remediation)) continue;
|
|
4212
|
+
names.add(packageNameFromSnippet2(finding4.snippet));
|
|
4213
|
+
}
|
|
4214
|
+
return names;
|
|
4215
|
+
}
|
|
4216
|
+
function targetVersion(finding4) {
|
|
4217
|
+
const match = /Upgrade \S+ to (\S+) or later/.exec(finding4.remediation);
|
|
4218
|
+
return match?.[1];
|
|
4219
|
+
}
|
|
4220
|
+
function kindFromLockfile(lockFile) {
|
|
4221
|
+
return lockfileKind(lockFile) ?? "npm";
|
|
4222
|
+
}
|
|
4223
|
+
function attachOverrideProposals(findings, graph, lockFile, manifestRaw) {
|
|
4224
|
+
if (!graph.edgesKnown || manifestRaw === void 0) return findings;
|
|
4225
|
+
const withFix = packagesWithAFixedRelease(findings);
|
|
4226
|
+
const kind = kindFromLockfile(lockFile);
|
|
4227
|
+
return findings.map((finding4) => {
|
|
4228
|
+
if (finding4.ruleId !== "DEPS-VULN-001" || finding4.snippet === void 0) return finding4;
|
|
4229
|
+
const name = packageNameFromSnippet2(finding4.snippet);
|
|
4230
|
+
if (name.length === 0 || isDirect(name, graph)) return finding4;
|
|
4231
|
+
const version = targetVersion(finding4);
|
|
4232
|
+
if (version === void 0) return finding4;
|
|
4233
|
+
const parentFixed = parentsOf(name, graph.edges).some((parent) => withFix.has(parent));
|
|
4234
|
+
if (parentFixed) return finding4;
|
|
4235
|
+
const after = withManifestOverride(manifestRaw, kind, name, version);
|
|
4236
|
+
const diff = unifiedDiff("package.json", manifestRaw, after);
|
|
4237
|
+
if (diff.length === 0) return finding4;
|
|
4238
|
+
return {
|
|
4239
|
+
...finding4,
|
|
4240
|
+
fix: { kind: "dependency-override", safe: false, diff }
|
|
4241
|
+
};
|
|
4242
|
+
});
|
|
4243
|
+
}
|
|
4244
|
+
function packageDir(cwd, pkg) {
|
|
4245
|
+
const candidates = [join(cwd, pkg.path), join(cwd, "node_modules", pkg.name)];
|
|
4246
|
+
return candidates.find((dir) => existsSync(dir));
|
|
4247
|
+
}
|
|
4248
|
+
function scanInstalledPackages(cwd, packages) {
|
|
4249
|
+
const out = [];
|
|
4250
|
+
for (const pkg of packages) {
|
|
4251
|
+
const dir = packageDir(cwd, pkg);
|
|
4252
|
+
if (dir === void 0) continue;
|
|
4253
|
+
const prefix = pkg.path.startsWith("node_modules/") ? pkg.path : `node_modules/${pkg.name}`;
|
|
4254
|
+
for (const hit of [...analyzeWormIndicators(dir), ...analyzeWorkflowTampering(dir)]) {
|
|
4255
|
+
out.push({
|
|
4256
|
+
...hit,
|
|
4257
|
+
file: hit.file !== void 0 && hit.file.length > 0 ? `${prefix}/${hit.file}` : prefix
|
|
4258
|
+
});
|
|
4259
|
+
}
|
|
2951
4260
|
}
|
|
2952
|
-
return
|
|
4261
|
+
return out;
|
|
2953
4262
|
}
|
|
2954
4263
|
|
|
2955
4264
|
// src/modules/deps/index.ts
|
|
@@ -2974,27 +4283,29 @@ async function readDirectDependencyNames(cwd) {
|
|
|
2974
4283
|
return void 0;
|
|
2975
4284
|
}
|
|
2976
4285
|
}
|
|
2977
|
-
function vulnFinding(lockFile, pkg, vuln) {
|
|
4286
|
+
function vulnFinding(lockFile, pkg, vuln, loc) {
|
|
2978
4287
|
const snippet = `${pkg.name}@${pkg.version}`;
|
|
2979
4288
|
const aliases = (vuln.aliases ?? []).filter((a) => a.startsWith("CVE-"));
|
|
2980
4289
|
const references = [vuln.id, ...aliases];
|
|
2981
4290
|
const summary = vuln.summary ?? "known vulnerability";
|
|
2982
4291
|
const fixed = minFixedVersion(vuln.affected ?? []);
|
|
2983
|
-
const
|
|
2984
|
-
|
|
4292
|
+
const file = loc?.file ?? lockFile;
|
|
4293
|
+
const finding4 = {
|
|
4294
|
+
id: makeFindingId("DEPS-VULN-001", file, `${snippet}|${vuln.id}`),
|
|
2985
4295
|
ruleId: "DEPS-VULN-001",
|
|
2986
4296
|
module: "deps",
|
|
2987
4297
|
title: `Vulnerable dependency ${snippet}`,
|
|
2988
4298
|
severity: osvSeverity(vuln),
|
|
2989
4299
|
confidence: "high",
|
|
2990
|
-
file
|
|
4300
|
+
file,
|
|
4301
|
+
line: loc?.line,
|
|
2991
4302
|
snippet,
|
|
2992
4303
|
message: `${snippet} is affected by ${vuln.id} (${summary}).`,
|
|
2993
4304
|
remediation: fixed ? `Upgrade ${pkg.name} to ${fixed} or later (data from OSV).` : `Upgrade ${pkg.name} to a version that OSV does not mark as affected.`,
|
|
2994
4305
|
references
|
|
2995
4306
|
};
|
|
2996
4307
|
if (fixed !== void 0) {
|
|
2997
|
-
|
|
4308
|
+
finding4.fix = {
|
|
2998
4309
|
kind: "dependency-bump",
|
|
2999
4310
|
safe: sameMajor(pkg.version, fixed),
|
|
3000
4311
|
diff: `--- a/package.json
|
|
@@ -3003,7 +4314,7 @@ function vulnFinding(lockFile, pkg, vuln) {
|
|
|
3003
4314
|
`
|
|
3004
4315
|
};
|
|
3005
4316
|
}
|
|
3006
|
-
return
|
|
4317
|
+
return finding4;
|
|
3007
4318
|
}
|
|
3008
4319
|
var MALICIOUS_REMEDIATION = "Remove the package from package.json and the lockfile, delete node_modules, reinstall, and rotate any credentials on machines that installed it. Install scripts run at npm install regardless of whether application code imports the package.";
|
|
3009
4320
|
function maliciousFinding(lockFile, pkg, vuln) {
|
|
@@ -3071,12 +4382,23 @@ var depsModule = {
|
|
|
3071
4382
|
const lockFile = LOCKFILE_NAMES.find((name) => ctx.files.includes(name));
|
|
3072
4383
|
const cacheDir = ctx.config.deps.cacheDir !== void 0 ? resolve(ctx.cwd, ctx.config.deps.cacheDir) : defaultCacheDir();
|
|
3073
4384
|
const osvOpts = { cacheDir, offline: ctx.config.deps.offline };
|
|
4385
|
+
let manifestRaw;
|
|
4386
|
+
try {
|
|
4387
|
+
manifestRaw = await readFile(join(ctx.cwd, "package.json"), "utf8");
|
|
4388
|
+
} catch {
|
|
4389
|
+
manifestRaw = void 0;
|
|
4390
|
+
}
|
|
4391
|
+
const depLoc = (name) => {
|
|
4392
|
+
if (manifestRaw === void 0) return void 0;
|
|
4393
|
+
const line = lineOfManifestDep(manifestRaw, name);
|
|
4394
|
+
return line === void 0 ? void 0 : { file: "package.json", line };
|
|
4395
|
+
};
|
|
3074
4396
|
if (lockFile === void 0) {
|
|
3075
4397
|
if (!ctx.files.includes("package.json")) return [];
|
|
3076
4398
|
const findings2 = [noLockFinding()];
|
|
3077
4399
|
let manifest = {};
|
|
3078
4400
|
try {
|
|
3079
|
-
manifest = JSON.parse(
|
|
4401
|
+
manifest = JSON.parse(manifestRaw ?? "{}");
|
|
3080
4402
|
} catch {
|
|
3081
4403
|
return findings2;
|
|
3082
4404
|
}
|
|
@@ -3089,7 +4411,7 @@ var depsModule = {
|
|
|
3089
4411
|
for (const hit of osvHits2) {
|
|
3090
4412
|
for (const vuln of hit.vulns) {
|
|
3091
4413
|
findings2.push(
|
|
3092
|
-
isMaliciousAdvisory(vuln) ? maliciousFinding("package.json", hit.pkg, vuln) : vulnFinding("package.json", hit.pkg, vuln)
|
|
4414
|
+
isMaliciousAdvisory(vuln) ? maliciousFinding("package.json", hit.pkg, vuln) : vulnFinding("package.json", hit.pkg, vuln, depLoc(hit.pkg.name))
|
|
3093
4415
|
);
|
|
3094
4416
|
}
|
|
3095
4417
|
}
|
|
@@ -3117,11 +4439,16 @@ var depsModule = {
|
|
|
3117
4439
|
for (const hit of osvHits) {
|
|
3118
4440
|
for (const vuln of hit.vulns) {
|
|
3119
4441
|
findings.push(
|
|
3120
|
-
isMaliciousAdvisory(vuln) ? maliciousFinding(lockFile, hit.pkg, vuln) : vulnFinding(lockFile, hit.pkg, vuln)
|
|
4442
|
+
isMaliciousAdvisory(vuln) ? maliciousFinding(lockFile, hit.pkg, vuln) : vulnFinding(lockFile, hit.pkg, vuln, depLoc(hit.pkg.name))
|
|
3121
4443
|
);
|
|
3122
4444
|
}
|
|
3123
4445
|
}
|
|
3124
|
-
|
|
4446
|
+
let reached = applyReachability(findings, await reachabilityCtx(ctx.cwd, ctx.files, graph));
|
|
4447
|
+
if (experimentalVex()) {
|
|
4448
|
+
reached = attachOverrideProposals(reached, graph, lockFile, manifestRaw);
|
|
4449
|
+
reached.push(...scanInstalledPackages(ctx.cwd, packages));
|
|
4450
|
+
}
|
|
4451
|
+
return reached;
|
|
3125
4452
|
}
|
|
3126
4453
|
};
|
|
3127
4454
|
function graphFromManifest(manifest) {
|
|
@@ -3153,6 +4480,489 @@ async function reachabilityCtx(cwd, files, graph) {
|
|
|
3153
4480
|
return { productionUsage: indexPackageUsage(prodSources), graph };
|
|
3154
4481
|
}
|
|
3155
4482
|
|
|
4483
|
+
// src/modules/mcp/index.ts
|
|
4484
|
+
init_finding();
|
|
4485
|
+
init_mask();
|
|
4486
|
+
var FLAG = "FULLSHIELD_EXPERIMENTAL_MCPCFG";
|
|
4487
|
+
var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", "dist", ".git", "coverage"]);
|
|
4488
|
+
var SECRET_NAME = /(key|secret|token|passw(or)?d|pwd|credential|auth)/i;
|
|
4489
|
+
var ENV_REF = /^\s*(?:\$\{[A-Za-z_][\w]*\}|\$[A-Za-z_][\w]*|%[A-Za-z_][\w]*%)\s*$/;
|
|
4490
|
+
var INJECT_PHRASE = /ignore previous|ignore all previous|hidden instruction|do not tell (?:the )?user|you are now|system prompt/i;
|
|
4491
|
+
var ZERO_WIDTH = /[\u200B\u200C\u200D\uFEFF\u2060\u202E\u2066\u2067\u2068\u2069]|\\u(?:200b|200c|200d|feff|2060|202e|206[6-9])/i;
|
|
4492
|
+
var DANGEROUS_TOOL = /\.(?:tool|registerTool|addTool)\(\s*['"`](shell|exec|bash|cmd|run_command|execute|write_file|writeFile|fs_write)['"`]/i;
|
|
4493
|
+
var ALLOWLIST = /allowed(?:Directories|Paths|Commands|Roots)|allowlist/;
|
|
4494
|
+
var FS_PKG = /server-filesystem|mcp-filesystem/i;
|
|
4495
|
+
var SHELL_PKG = /server-shell|mcp-server-commands|mcp-shell|server-everything/i;
|
|
4496
|
+
var NPX = /^(npx|bunx|pnpx)(?:\.cmd)?$/i;
|
|
4497
|
+
var DLX_CMD = /^(pnpm|yarn|npm)(?:\.cmd)?$/i;
|
|
4498
|
+
function mcpCfgEnabled2() {
|
|
4499
|
+
return process.env[FLAG] === "1";
|
|
4500
|
+
}
|
|
4501
|
+
function finding2(ruleId, title, message, file, severity, extra) {
|
|
4502
|
+
return {
|
|
4503
|
+
id: makeFindingId(ruleId, file, `${extra.line ?? 0}|${extra.snippet ?? message}`),
|
|
4504
|
+
ruleId,
|
|
4505
|
+
module: "config",
|
|
4506
|
+
title,
|
|
4507
|
+
severity,
|
|
4508
|
+
confidence: "medium",
|
|
4509
|
+
cwe: extra.cwe,
|
|
4510
|
+
file,
|
|
4511
|
+
line: extra.line,
|
|
4512
|
+
snippet: extra.snippet,
|
|
4513
|
+
message,
|
|
4514
|
+
remediation: extra.remediation
|
|
4515
|
+
};
|
|
4516
|
+
}
|
|
4517
|
+
function lineOf(text, needle) {
|
|
4518
|
+
const idx = text.indexOf(needle);
|
|
4519
|
+
if (idx < 0) return 1;
|
|
4520
|
+
return text.slice(0, idx).split(/\r?\n/).length;
|
|
4521
|
+
}
|
|
4522
|
+
function stripJsonc(text) {
|
|
4523
|
+
return text.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "");
|
|
4524
|
+
}
|
|
4525
|
+
function parseJson(text) {
|
|
4526
|
+
try {
|
|
4527
|
+
return JSON.parse(stripJsonc(text));
|
|
4528
|
+
} catch {
|
|
4529
|
+
return void 0;
|
|
4530
|
+
}
|
|
4531
|
+
}
|
|
4532
|
+
function commandBase(command) {
|
|
4533
|
+
if (typeof command !== "string") return "";
|
|
4534
|
+
const norm = command.replaceAll("\\", "/");
|
|
4535
|
+
return norm.split("/").pop() ?? command;
|
|
4536
|
+
}
|
|
4537
|
+
function asStringArray(value) {
|
|
4538
|
+
if (!Array.isArray(value)) return [];
|
|
4539
|
+
return value.filter((v) => typeof v === "string");
|
|
4540
|
+
}
|
|
4541
|
+
function isNpmSpec(spec) {
|
|
4542
|
+
if (spec.startsWith("-") || spec.startsWith(".") || spec.startsWith("/") || spec.includes("\\")) return false;
|
|
4543
|
+
if (spec.includes("://") || /\.[cm]?[jt]sx?$/.test(spec)) return false;
|
|
4544
|
+
return true;
|
|
4545
|
+
}
|
|
4546
|
+
function specUnpinned(spec) {
|
|
4547
|
+
if (!isNpmSpec(spec)) return false;
|
|
4548
|
+
if (/@latest(?:[/]|$)/i.test(spec) || spec.endsWith("@*")) return true;
|
|
4549
|
+
const rest = spec.startsWith("@") ? spec.slice(1) : spec;
|
|
4550
|
+
const at = rest.lastIndexOf("@");
|
|
4551
|
+
if (at > 0) {
|
|
4552
|
+
const ver = rest.slice(at + 1);
|
|
4553
|
+
if (/^\d/.test(ver)) return false;
|
|
4554
|
+
if (ver === "latest" || ver === "*") return true;
|
|
4555
|
+
}
|
|
4556
|
+
return /^(@[A-Za-z0-9._-]+\/)?[A-Za-z0-9._-]+$/.test(spec);
|
|
4557
|
+
}
|
|
4558
|
+
function looksSecretValue(value) {
|
|
4559
|
+
if (ENV_REF.test(value)) return false;
|
|
4560
|
+
if (/\b(?:AKIA[0-9A-Z]{16}|sk-[A-Za-z0-9_-]{20,}|ghp_[A-Za-z0-9]{36})\b/.test(value)) return true;
|
|
4561
|
+
if (value.length >= 20 && shannonEntropy(value) > 4.5) return true;
|
|
4562
|
+
return value.length >= 12;
|
|
4563
|
+
}
|
|
4564
|
+
function unrestrictedPath(path) {
|
|
4565
|
+
const n = path.replaceAll("\\", "/").trim();
|
|
4566
|
+
return n === "/" || n === "/*" || n === "*" || n === "~" || /^[A-Za-z]:\/?$/.test(path);
|
|
4567
|
+
}
|
|
4568
|
+
function pathAllowArgs(args) {
|
|
4569
|
+
const out = [];
|
|
4570
|
+
for (const arg of args) {
|
|
4571
|
+
if (arg.startsWith("-") || specUnpinned(arg) || /^(@[A-Za-z0-9._-]+\/)?[A-Za-z0-9._-]+@/.test(arg)) continue;
|
|
4572
|
+
if (arg.startsWith(".") || arg.startsWith("/") || arg.includes("\\") || arg === "~" || arg === "*") {
|
|
4573
|
+
out.push(arg);
|
|
4574
|
+
}
|
|
4575
|
+
}
|
|
4576
|
+
return out;
|
|
4577
|
+
}
|
|
4578
|
+
function hasAllowlist(server, text) {
|
|
4579
|
+
for (const key of ["allowedDirectories", "allowedPaths", "allowedCommands", "roots", "allowlist", "allow"]) {
|
|
4580
|
+
const v = server[key];
|
|
4581
|
+
if (Array.isArray(v) && v.length > 0 && !v.every((p) => typeof p === "string" && unrestrictedPath(p))) {
|
|
4582
|
+
return true;
|
|
4583
|
+
}
|
|
4584
|
+
}
|
|
4585
|
+
return ALLOWLIST.test(text);
|
|
4586
|
+
}
|
|
4587
|
+
function serverEntries(root) {
|
|
4588
|
+
if (root === null || typeof root !== "object" || Array.isArray(root)) return [];
|
|
4589
|
+
const obj = root;
|
|
4590
|
+
const block = obj.mcpServers ?? obj.servers;
|
|
4591
|
+
if (block === null || typeof block !== "object" || Array.isArray(block)) return [];
|
|
4592
|
+
const out = [];
|
|
4593
|
+
for (const [name, value] of Object.entries(block)) {
|
|
4594
|
+
if (value !== null && typeof value === "object" && !Array.isArray(value)) {
|
|
4595
|
+
out.push({ name, server: value });
|
|
4596
|
+
}
|
|
4597
|
+
}
|
|
4598
|
+
return out;
|
|
4599
|
+
}
|
|
4600
|
+
function visitStrings(value, visit, key = "") {
|
|
4601
|
+
if (typeof value === "string") {
|
|
4602
|
+
visit(key, value);
|
|
4603
|
+
return;
|
|
4604
|
+
}
|
|
4605
|
+
if (Array.isArray(value)) {
|
|
4606
|
+
for (const item of value) visitStrings(item, visit, key);
|
|
4607
|
+
return;
|
|
4608
|
+
}
|
|
4609
|
+
if (value !== null && typeof value === "object") {
|
|
4610
|
+
for (const [k, v] of Object.entries(value)) visitStrings(v, visit, k);
|
|
4611
|
+
}
|
|
4612
|
+
}
|
|
4613
|
+
function isMcpConfigRel(rel, name) {
|
|
4614
|
+
const n = rel.replaceAll("\\", "/");
|
|
4615
|
+
if (name === ".mcp.json" || n.endsWith("/.mcp.json")) return true;
|
|
4616
|
+
if (name === "claude_desktop_config.json") return true;
|
|
4617
|
+
if (name === "mcp.json" && (n === ".cursor/mcp.json" || n.endsWith("/.cursor/mcp.json"))) return true;
|
|
4618
|
+
if (name === "mcp.json" && (n === ".vscode/mcp.json" || n.endsWith("/.vscode/mcp.json"))) return true;
|
|
4619
|
+
return false;
|
|
4620
|
+
}
|
|
4621
|
+
function isSourceName(name) {
|
|
4622
|
+
return /\.[cm]?[jt]sx?$/.test(name);
|
|
4623
|
+
}
|
|
4624
|
+
function collectRepoFiles(dir, out = [], rel = "") {
|
|
4625
|
+
if (!existsSync(dir)) return out;
|
|
4626
|
+
let entries;
|
|
4627
|
+
try {
|
|
4628
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
4629
|
+
} catch {
|
|
4630
|
+
return out;
|
|
4631
|
+
}
|
|
4632
|
+
for (const entry of entries) {
|
|
4633
|
+
if (entry.isSymbolicLink()) continue;
|
|
4634
|
+
const childRel = rel === "" ? entry.name : `${rel}/${entry.name}`;
|
|
4635
|
+
const abs = join(dir, entry.name);
|
|
4636
|
+
if (entry.isDirectory()) {
|
|
4637
|
+
if (SKIP_DIRS.has(entry.name)) continue;
|
|
4638
|
+
collectRepoFiles(abs, out, childRel);
|
|
4639
|
+
} else if (entry.isFile()) {
|
|
4640
|
+
if (isMcpConfigRel(childRel, entry.name) || isSourceName(entry.name)) {
|
|
4641
|
+
out.push({ abs, rel: childRel });
|
|
4642
|
+
}
|
|
4643
|
+
}
|
|
4644
|
+
}
|
|
4645
|
+
return out;
|
|
4646
|
+
}
|
|
4647
|
+
function mcpHomeConfigPaths(home) {
|
|
4648
|
+
const paths = [join(home, ".cursor", "mcp.json"), join(home, ".vscode", "mcp.json"), join(home, ".mcp.json")];
|
|
4649
|
+
if (process.platform === "darwin") {
|
|
4650
|
+
paths.push(join(home, "Library", "Application Support", "Claude", "claude_desktop_config.json"));
|
|
4651
|
+
} else if (process.platform === "win32") {
|
|
4652
|
+
const appData = process.env.APPDATA ?? join(home, "AppData", "Roaming");
|
|
4653
|
+
paths.push(join(appData, "Claude", "claude_desktop_config.json"));
|
|
4654
|
+
} else {
|
|
4655
|
+
paths.push(join(home, ".config", "Claude", "claude_desktop_config.json"));
|
|
4656
|
+
}
|
|
4657
|
+
return paths;
|
|
4658
|
+
}
|
|
4659
|
+
function scanJsonConfig(file, text, findings) {
|
|
4660
|
+
const parsed = parseJson(text);
|
|
4661
|
+
for (const { server } of serverEntries(parsed)) {
|
|
4662
|
+
const args = asStringArray(server.args);
|
|
4663
|
+
const cmd = commandBase(server.command);
|
|
4664
|
+
const env = server.env;
|
|
4665
|
+
if (env !== null && typeof env === "object" && !Array.isArray(env)) {
|
|
4666
|
+
for (const [key, value] of Object.entries(env)) {
|
|
4667
|
+
if (typeof value !== "string" || ENV_REF.test(value)) continue;
|
|
4668
|
+
if (!SECRET_NAME.test(key) && !looksSecretValue(value)) continue;
|
|
4669
|
+
findings.push(
|
|
4670
|
+
finding2(
|
|
4671
|
+
"MCP-001",
|
|
4672
|
+
"Secret inlined in MCP config",
|
|
4673
|
+
"MCP server env has an inline secret; use an environment-variable reference instead.",
|
|
4674
|
+
file,
|
|
4675
|
+
"high",
|
|
4676
|
+
{
|
|
4677
|
+
line: lineOf(text, key),
|
|
4678
|
+
snippet: maskSecret(value),
|
|
4679
|
+
remediation: "Replace inline secrets with env refs such as ${GITHUB_TOKEN} and rotate the exposed value.",
|
|
4680
|
+
cwe: "CWE-798"
|
|
4681
|
+
}
|
|
4682
|
+
)
|
|
4683
|
+
);
|
|
4684
|
+
}
|
|
4685
|
+
}
|
|
4686
|
+
const npxish = NPX.test(cmd) || DLX_CMD.test(cmd) && args.some((a) => a === "dlx" || a === "exec");
|
|
4687
|
+
if (npxish && args.some((a) => specUnpinned(a))) {
|
|
4688
|
+
findings.push(
|
|
4689
|
+
finding2(
|
|
4690
|
+
"MCP-002",
|
|
4691
|
+
"Unpinned npx MCP server",
|
|
4692
|
+
"MCP server is launched via unpinned npx / @latest (supply-chain risk).",
|
|
4693
|
+
file,
|
|
4694
|
+
"medium",
|
|
4695
|
+
{
|
|
4696
|
+
line: lineOf(text, "npx") || lineOf(text, "@latest"),
|
|
4697
|
+
snippet: args.find((a) => specUnpinned(a))?.slice(0, 80),
|
|
4698
|
+
remediation: "Pin the MCP server package to an exact version (pkg@1.2.3), not @latest or a bare name.",
|
|
4699
|
+
cwe: "CWE-494"
|
|
4700
|
+
}
|
|
4701
|
+
)
|
|
4702
|
+
);
|
|
4703
|
+
}
|
|
4704
|
+
const joinedArgs = args.join(" ");
|
|
4705
|
+
const commandText = typeof server.command === "string" ? server.command : "";
|
|
4706
|
+
const fsServer = FS_PKG.test(joinedArgs) || FS_PKG.test(commandText);
|
|
4707
|
+
const shellServer = SHELL_PKG.test(joinedArgs) || SHELL_PKG.test(commandText);
|
|
4708
|
+
const paths = pathAllowArgs(args);
|
|
4709
|
+
const unrestrictedFs = fsServer && (paths.length === 0 || paths.some(unrestrictedPath));
|
|
4710
|
+
if ((unrestrictedFs || shellServer) && !hasAllowlist(server, text)) {
|
|
4711
|
+
findings.push(
|
|
4712
|
+
finding2(
|
|
4713
|
+
"MCP-003",
|
|
4714
|
+
"MCP shell/exec or unrestricted filesystem tool",
|
|
4715
|
+
"MCP server exposes shell/exec or unrestricted filesystem writes without an allowlist.",
|
|
4716
|
+
file,
|
|
4717
|
+
"medium",
|
|
4718
|
+
{
|
|
4719
|
+
line: lineOf(text, args[0] ?? "mcp"),
|
|
4720
|
+
remediation: "Restrict filesystem roots to project directories and deny shell/exec tools unless an allowlist is enforced.",
|
|
4721
|
+
cwe: "CWE-250"
|
|
4722
|
+
}
|
|
4723
|
+
)
|
|
4724
|
+
);
|
|
4725
|
+
}
|
|
4726
|
+
}
|
|
4727
|
+
visitStrings(parsed, (key, val) => {
|
|
4728
|
+
if (key === "description" && (INJECT_PHRASE.test(val) || ZERO_WIDTH.test(val) || val.includes("<!--"))) {
|
|
4729
|
+
findings.push(
|
|
4730
|
+
finding2(
|
|
4731
|
+
"MCP-004",
|
|
4732
|
+
"MCP tool description instruction injection",
|
|
4733
|
+
"Tool description contains instruction-injection text, hidden markup, or zero-width characters.",
|
|
4734
|
+
file,
|
|
4735
|
+
"high",
|
|
4736
|
+
{
|
|
4737
|
+
line: lineOf(text, val.slice(0, 24)) || lineOf(text, "description"),
|
|
4738
|
+
snippet: val.replace(ZERO_WIDTH, "").slice(0, 80),
|
|
4739
|
+
remediation: "Keep tool descriptions literal and user-facing; strip hidden/zero-width characters and model-directed instructions.",
|
|
4740
|
+
cwe: "CWE-74"
|
|
4741
|
+
}
|
|
4742
|
+
)
|
|
4743
|
+
);
|
|
4744
|
+
}
|
|
4745
|
+
if ((key === "url" || key === "serverUrl" || key === "href" || key === "endpoint") && /^http:\/\//i.test(val.trim())) {
|
|
4746
|
+
findings.push(
|
|
4747
|
+
finding2(
|
|
4748
|
+
"MCP-005",
|
|
4749
|
+
"Remote MCP over plaintext HTTP",
|
|
4750
|
+
"Remote MCP server is configured with http:// instead of https://.",
|
|
4751
|
+
file,
|
|
4752
|
+
"medium",
|
|
4753
|
+
{
|
|
4754
|
+
line: lineOf(text, val),
|
|
4755
|
+
snippet: val.slice(0, 80),
|
|
4756
|
+
remediation: "Use https:// for remote MCP servers so tokens and tool traffic are not sent in the clear.",
|
|
4757
|
+
cwe: "CWE-319"
|
|
4758
|
+
}
|
|
4759
|
+
)
|
|
4760
|
+
);
|
|
4761
|
+
}
|
|
4762
|
+
});
|
|
4763
|
+
}
|
|
4764
|
+
function scanServerSource(file, text, findings) {
|
|
4765
|
+
if (!/\.(?:tool|registerTool|addTool)\s*\(/.test(text) && !/@modelcontextprotocol/.test(text)) return;
|
|
4766
|
+
if (DANGEROUS_TOOL.test(text) && !ALLOWLIST.test(text)) {
|
|
4767
|
+
findings.push(
|
|
4768
|
+
finding2(
|
|
4769
|
+
"MCP-003",
|
|
4770
|
+
"MCP shell/exec or unrestricted filesystem tool",
|
|
4771
|
+
"MCP server code registers shell/exec or filesystem-write tools without an allowlist.",
|
|
4772
|
+
file,
|
|
4773
|
+
"medium",
|
|
4774
|
+
{
|
|
4775
|
+
line: lineOf(text, "tool("),
|
|
4776
|
+
remediation: "Do not expose shell/exec or unrestricted writes; gate them with an allowlist and a confirm step (see guardTools).",
|
|
4777
|
+
cwe: "CWE-250"
|
|
4778
|
+
}
|
|
4779
|
+
)
|
|
4780
|
+
);
|
|
4781
|
+
}
|
|
4782
|
+
if (INJECT_PHRASE.test(text) || ZERO_WIDTH.test(text)) {
|
|
4783
|
+
findings.push(
|
|
4784
|
+
finding2(
|
|
4785
|
+
"MCP-004",
|
|
4786
|
+
"MCP tool description instruction injection",
|
|
4787
|
+
"MCP tool description contains instruction-injection text, hidden markup, or zero-width characters.",
|
|
4788
|
+
file,
|
|
4789
|
+
"high",
|
|
4790
|
+
{
|
|
4791
|
+
line: lineOf(text, "Ignore previous") || lineOf(text, "ignore previous"),
|
|
4792
|
+
remediation: "Keep tool descriptions literal and user-facing; strip hidden/zero-width characters and model-directed instructions.",
|
|
4793
|
+
cwe: "CWE-74"
|
|
4794
|
+
}
|
|
4795
|
+
)
|
|
4796
|
+
);
|
|
4797
|
+
}
|
|
4798
|
+
}
|
|
4799
|
+
function scanOne(abs, file, findings) {
|
|
4800
|
+
let text;
|
|
4801
|
+
try {
|
|
4802
|
+
text = readFileSync(abs, "utf8");
|
|
4803
|
+
} catch {
|
|
4804
|
+
return;
|
|
4805
|
+
}
|
|
4806
|
+
if (text.length > 1024 * 1024 || text.includes("\0")) return;
|
|
4807
|
+
if (file.endsWith(".json") || file.endsWith(".mcp.json")) scanJsonConfig(file, text, findings);
|
|
4808
|
+
else scanServerSource(file, text, findings);
|
|
4809
|
+
}
|
|
4810
|
+
function scanMcpConfig(dir, opts = {}) {
|
|
4811
|
+
if (!opts.experimental && !mcpCfgEnabled2()) return [];
|
|
4812
|
+
if (!existsSync(dir) || !statSync(dir).isDirectory()) return [];
|
|
4813
|
+
const findings = [];
|
|
4814
|
+
for (const file of collectRepoFiles(dir)) {
|
|
4815
|
+
scanOne(file.abs, file.rel, findings);
|
|
4816
|
+
}
|
|
4817
|
+
if (opts.includeHome === true) {
|
|
4818
|
+
for (const abs of mcpHomeConfigPaths(homedir())) {
|
|
4819
|
+
if (!existsSync(abs) || !statSync(abs).isFile()) continue;
|
|
4820
|
+
scanOne(abs, abs, findings);
|
|
4821
|
+
}
|
|
4822
|
+
}
|
|
4823
|
+
return findings;
|
|
4824
|
+
}
|
|
4825
|
+
|
|
4826
|
+
// src/modules/sast/engine.ts
|
|
4827
|
+
init_finding();
|
|
4828
|
+
|
|
4829
|
+
// src/modules/sast/interproc.ts
|
|
4830
|
+
init_finding();
|
|
4831
|
+
|
|
4832
|
+
// src/modules/sast/frameworks.ts
|
|
4833
|
+
var NEXT_HANDLERS = /* @__PURE__ */ new Set(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]);
|
|
4834
|
+
var NEST_HTTP = /^(Get|Post|Put|Patch|Delete|All|Head|Options)$/;
|
|
4835
|
+
var NEST_INPUT = /^(Body|Query|Param|Headers)$/;
|
|
4836
|
+
var REQ_READERS = /* @__PURE__ */ new Set(["json", "formData", "text", "arrayBuffer"]);
|
|
4837
|
+
var HONO_READERS = /* @__PURE__ */ new Set(["query", "param", "header", "json", "parseBody", "text"]);
|
|
4838
|
+
function decoratorExpr(dec) {
|
|
4839
|
+
if (dec.type !== "Decorator") return void 0;
|
|
4840
|
+
return dec.expression;
|
|
4841
|
+
}
|
|
4842
|
+
function decoratorName(dec) {
|
|
4843
|
+
const expr = decoratorExpr(dec);
|
|
4844
|
+
if (expr === void 0) return void 0;
|
|
4845
|
+
if (expr.type === "Identifier") return expr.name;
|
|
4846
|
+
if (expr.type === "CallExpression") return calleeName(expr);
|
|
4847
|
+
return void 0;
|
|
4848
|
+
}
|
|
4849
|
+
function nodeDecorators(node) {
|
|
4850
|
+
const decs = node.decorators;
|
|
4851
|
+
return Array.isArray(decs) ? decs : [];
|
|
4852
|
+
}
|
|
4853
|
+
function nestInputDecorator(param) {
|
|
4854
|
+
if (!frameworksEnabled()) return void 0;
|
|
4855
|
+
for (const dec of nodeDecorators(param)) {
|
|
4856
|
+
const name = decoratorName(dec);
|
|
4857
|
+
if (name !== void 0 && NEST_INPUT.test(name)) return name;
|
|
4858
|
+
}
|
|
4859
|
+
return void 0;
|
|
4860
|
+
}
|
|
4861
|
+
function nestDecoratorArgCount(param, name) {
|
|
4862
|
+
for (const dec of nodeDecorators(param)) {
|
|
4863
|
+
if (decoratorName(dec) !== name) continue;
|
|
4864
|
+
const expr = decoratorExpr(dec);
|
|
4865
|
+
if (expr?.type === "CallExpression") return expr.arguments.length;
|
|
4866
|
+
return 0;
|
|
4867
|
+
}
|
|
4868
|
+
return 0;
|
|
4869
|
+
}
|
|
4870
|
+
function isCtxIdent(node) {
|
|
4871
|
+
return node.type === "Identifier" && node.name === "ctx";
|
|
4872
|
+
}
|
|
4873
|
+
function isCReq(node) {
|
|
4874
|
+
return node.type === "MemberExpression" && node.object.type === "Identifier" && node.object.name === "c" && propName(node) === "req";
|
|
4875
|
+
}
|
|
4876
|
+
function isCtxRequest(node) {
|
|
4877
|
+
return node.type === "MemberExpression" && isCtxIdent(node.object) && propName(node) === "request";
|
|
4878
|
+
}
|
|
4879
|
+
function isFrameworkMemberTaint(node) {
|
|
4880
|
+
if (!frameworksEnabled() || node.type !== "MemberExpression") return false;
|
|
4881
|
+
const name = propName(node);
|
|
4882
|
+
if (name === void 0) return false;
|
|
4883
|
+
if (isCtxIdent(node.object) && (REQUEST_SOURCE_PROPS.has(name) || name === "query")) return true;
|
|
4884
|
+
if (isCtxRequest(node.object) && REQUEST_SOURCE_PROPS.has(name)) return true;
|
|
4885
|
+
return false;
|
|
4886
|
+
}
|
|
4887
|
+
function isFrameworkTaintCall(node) {
|
|
4888
|
+
if (!frameworksEnabled() || node.type !== "CallExpression") return false;
|
|
4889
|
+
const name = calleeName(node);
|
|
4890
|
+
if (name === void 0 || node.callee.type !== "MemberExpression") return false;
|
|
4891
|
+
const obj = node.callee.object;
|
|
4892
|
+
if (obj.type === "Identifier" && obj.name === "request" && REQ_READERS.has(name)) return true;
|
|
4893
|
+
if (obj.type === "Identifier" && (obj.name === "formData" || obj.name === "searchParams") && name === "get") {
|
|
4894
|
+
return true;
|
|
4895
|
+
}
|
|
4896
|
+
if (obj.type === "MemberExpression" && propName(obj) === "searchParams" && name === "get") return true;
|
|
4897
|
+
if (isCReq(obj) && HONO_READERS.has(name)) return true;
|
|
4898
|
+
return false;
|
|
4899
|
+
}
|
|
4900
|
+
function isFrameworkRequestObject(node) {
|
|
4901
|
+
if (!frameworksEnabled()) return false;
|
|
4902
|
+
if (node.type === "AwaitExpression") return isFrameworkRequestObject(node.argument);
|
|
4903
|
+
if (node.type === "CallExpression") {
|
|
4904
|
+
const name = calleeName(node);
|
|
4905
|
+
if (name !== "json" && name !== "formData") return false;
|
|
4906
|
+
if (node.callee.type !== "MemberExpression") return false;
|
|
4907
|
+
const obj = node.callee.object;
|
|
4908
|
+
if (obj.type === "Identifier" && obj.name === "request") return true;
|
|
4909
|
+
return isCReq(obj);
|
|
4910
|
+
}
|
|
4911
|
+
if (node.type === "MemberExpression") {
|
|
4912
|
+
const name = propName(node);
|
|
4913
|
+
if (name !== "body" && name !== "query" && name !== "params") return false;
|
|
4914
|
+
return isCtxRequest(node.object) || isCtxIdent(node.object);
|
|
4915
|
+
}
|
|
4916
|
+
return false;
|
|
4917
|
+
}
|
|
4918
|
+
function isNextHandler(node) {
|
|
4919
|
+
if (!frameworksEnabled() || node.type !== "FunctionDeclaration" || node.id === null) return false;
|
|
4920
|
+
if (!NEXT_HANDLERS.has(node.id.name)) return false;
|
|
4921
|
+
const parent = node.parent;
|
|
4922
|
+
return parent?.type === "ExportNamedDeclaration";
|
|
4923
|
+
}
|
|
4924
|
+
function isNestHttpMethod(node) {
|
|
4925
|
+
if (!frameworksEnabled() || node.type !== "MethodDefinition") return false;
|
|
4926
|
+
return nodeDecorators(node).some((d) => {
|
|
4927
|
+
const n = decoratorName(d);
|
|
4928
|
+
return n !== void 0 && NEST_HTTP.test(n);
|
|
4929
|
+
});
|
|
4930
|
+
}
|
|
4931
|
+
function nestMethodHasGuard(node) {
|
|
4932
|
+
if (nodeDecorators(node).some((d) => decoratorName(d) === "UseGuards")) return true;
|
|
4933
|
+
const cls = node.parent?.parent;
|
|
4934
|
+
if (cls) return nodeDecorators(cls).some((d) => decoratorName(d) === "UseGuards");
|
|
4935
|
+
return false;
|
|
4936
|
+
}
|
|
4937
|
+
function handlerHasFrameworkAuth(handler) {
|
|
4938
|
+
let yes = false;
|
|
4939
|
+
walk(handler, (child) => {
|
|
4940
|
+
if (child.type !== "CallExpression") return;
|
|
4941
|
+
const name = calleeName(child);
|
|
4942
|
+
if (name !== void 0 && /^(auth|cookies|getServerSession|getToken|requireAuth|authenticate)$/i.test(name)) {
|
|
4943
|
+
yes = true;
|
|
4944
|
+
}
|
|
4945
|
+
});
|
|
4946
|
+
return yes;
|
|
4947
|
+
}
|
|
4948
|
+
|
|
4949
|
+
// src/modules/sast/mcp.ts
|
|
4950
|
+
function isMcpToolHandler(fn) {
|
|
4951
|
+
if (!mcpCfgEnabled()) return false;
|
|
4952
|
+
const parent = fn.parent;
|
|
4953
|
+
if (parent === void 0 || parent.type !== "CallExpression") return false;
|
|
4954
|
+
const name = calleeName(parent);
|
|
4955
|
+
if (name !== "tool" && name !== "registerTool" && name !== "addTool") return false;
|
|
4956
|
+
const args = parent.arguments;
|
|
4957
|
+
return args[args.length - 1] === fn;
|
|
4958
|
+
}
|
|
4959
|
+
function isMcpArgsTaintSource(node) {
|
|
4960
|
+
if (!mcpCfgEnabled() || node.type !== "MemberExpression") return false;
|
|
4961
|
+
if (propName(node) !== "arguments") return false;
|
|
4962
|
+
const obj = node.object;
|
|
4963
|
+
return obj.type === "MemberExpression" && propName(obj) === "params";
|
|
4964
|
+
}
|
|
4965
|
+
|
|
3156
4966
|
// src/modules/sast/taint.ts
|
|
3157
4967
|
var SCHEMA_ROOT = /^(z|zod|joi|yup|schema)$/i;
|
|
3158
4968
|
function objectChainMentions(node, pattern) {
|
|
@@ -3186,6 +4996,15 @@ function isUrlSanitizerCall(node) {
|
|
|
3186
4996
|
const name = calleeName(node);
|
|
3187
4997
|
return name === "encodeURIComponent" || name === "encodeURI";
|
|
3188
4998
|
}
|
|
4999
|
+
function isCoercionCall(node) {
|
|
5000
|
+
if (node === void 0 || node.type !== "CallExpression") return false;
|
|
5001
|
+
return node.callee.type === "Identifier" && (node.callee.name === "String" || node.callee.name === "Number" || node.callee.name === "Boolean");
|
|
5002
|
+
}
|
|
5003
|
+
function isMongoSanitizeCall(node) {
|
|
5004
|
+
if (node === void 0 || node.type !== "CallExpression") return false;
|
|
5005
|
+
const name = calleeName(node);
|
|
5006
|
+
return name === "mongoSanitize" || name === "mongo_sanitize";
|
|
5007
|
+
}
|
|
3189
5008
|
function isXssSanitizerCall(node) {
|
|
3190
5009
|
if (node === void 0 || node.type !== "CallExpression") return false;
|
|
3191
5010
|
const name = calleeName(node);
|
|
@@ -3195,44 +5014,60 @@ function isXssSanitizerCall(node) {
|
|
|
3195
5014
|
}
|
|
3196
5015
|
return false;
|
|
3197
5016
|
}
|
|
3198
|
-
|
|
5017
|
+
var NO_RETURNERS = /* @__PURE__ */ new Set();
|
|
5018
|
+
function exprIsTainted(node, tainted, returners = NO_RETURNERS) {
|
|
3199
5019
|
if (node === void 0) return false;
|
|
3200
|
-
if (isTaintSource(node))
|
|
5020
|
+
if (isTaintSource(node) || isFrameworkMemberTaint(node) || isFrameworkTaintCall(node) || isMcpArgsTaintSource(node)) {
|
|
5021
|
+
return true;
|
|
5022
|
+
}
|
|
3201
5023
|
switch (node.type) {
|
|
5024
|
+
case "AwaitExpression":
|
|
5025
|
+
return exprIsTainted(node.argument, tainted, returners);
|
|
3202
5026
|
case "Identifier":
|
|
3203
5027
|
return tainted.has(node.name);
|
|
3204
5028
|
case "MemberExpression":
|
|
3205
|
-
return exprIsTainted(node.object, tainted);
|
|
5029
|
+
return exprIsTainted(node.object, tainted, returners);
|
|
3206
5030
|
case "CallExpression":
|
|
3207
5031
|
if (isSchemaValidatorCall(node)) return false;
|
|
3208
|
-
|
|
5032
|
+
if (node.callee.type === "Identifier" && returners.has(node.callee.name)) return true;
|
|
5033
|
+
if (node.callee.type === "MemberExpression" && exprIsTainted(node.callee.object, tainted, returners)) {
|
|
5034
|
+
return true;
|
|
5035
|
+
}
|
|
5036
|
+
return node.arguments.some(
|
|
5037
|
+
(arg) => arg.type !== "SpreadElement" && exprIsTainted(arg, tainted, returners)
|
|
5038
|
+
);
|
|
3209
5039
|
case "NewExpression":
|
|
3210
|
-
|
|
5040
|
+
if (exprIsTainted(node.callee, tainted, returners)) return true;
|
|
5041
|
+
return node.arguments.some(
|
|
5042
|
+
(arg) => arg.type !== "SpreadElement" && exprIsTainted(arg, tainted, returners)
|
|
5043
|
+
);
|
|
3211
5044
|
case "TemplateLiteral":
|
|
3212
|
-
return node.expressions.some((expr) => exprIsTainted(expr, tainted));
|
|
5045
|
+
return node.expressions.some((expr) => exprIsTainted(expr, tainted, returners));
|
|
5046
|
+
case "TaggedTemplateExpression":
|
|
5047
|
+
return exprIsTainted(node.quasi, tainted, returners);
|
|
3213
5048
|
case "BinaryExpression":
|
|
3214
|
-
return node.operator === "+" && (exprIsTainted(node.left, tainted) || exprIsTainted(node.right, tainted));
|
|
5049
|
+
return node.operator === "+" && (exprIsTainted(node.left, tainted, returners) || exprIsTainted(node.right, tainted, returners));
|
|
3215
5050
|
case "AssignmentExpression":
|
|
3216
|
-
return exprIsTainted(node.right, tainted);
|
|
5051
|
+
return exprIsTainted(node.right, tainted, returners);
|
|
3217
5052
|
case "LogicalExpression":
|
|
3218
|
-
return exprIsTainted(node.left, tainted) || exprIsTainted(node.right, tainted);
|
|
5053
|
+
return exprIsTainted(node.left, tainted, returners) || exprIsTainted(node.right, tainted, returners);
|
|
3219
5054
|
case "ConditionalExpression":
|
|
3220
|
-
return exprIsTainted(node.consequent, tainted) || exprIsTainted(node.alternate, tainted);
|
|
5055
|
+
return exprIsTainted(node.consequent, tainted, returners) || exprIsTainted(node.alternate, tainted, returners);
|
|
3221
5056
|
case "ChainExpression":
|
|
3222
|
-
return exprIsTainted(node.expression, tainted);
|
|
5057
|
+
return exprIsTainted(node.expression, tainted, returners);
|
|
3223
5058
|
case "TSAsExpression":
|
|
3224
5059
|
case "TSNonNullExpression":
|
|
3225
5060
|
case "TSTypeAssertion":
|
|
3226
|
-
return exprIsTainted(node.expression, tainted);
|
|
5061
|
+
return exprIsTainted(node.expression, tainted, returners);
|
|
3227
5062
|
default:
|
|
3228
5063
|
return false;
|
|
3229
5064
|
}
|
|
3230
5065
|
}
|
|
3231
|
-
function exprIsSanitized(node, tainted, names, isCall) {
|
|
5066
|
+
function exprIsSanitized(node, tainted, names, isCall, returners = NO_RETURNERS) {
|
|
3232
5067
|
if (node === void 0) return true;
|
|
3233
5068
|
if (isCall(node)) return true;
|
|
3234
5069
|
if (node.type === "Identifier") return names.has(node.name) || !tainted.has(node.name);
|
|
3235
|
-
if (!exprIsTainted(node, tainted)) return true;
|
|
5070
|
+
if (!exprIsTainted(node, tainted, returners)) return true;
|
|
3236
5071
|
if (node.type === "BinaryExpression" && node.operator === "+") {
|
|
3237
5072
|
return exprIsSanitized(node.left, tainted, names, isCall) && exprIsSanitized(node.right, tainted, names, isCall);
|
|
3238
5073
|
}
|
|
@@ -3264,16 +5099,50 @@ function walkScope(scope, visit) {
|
|
|
3264
5099
|
}
|
|
3265
5100
|
inner(scope);
|
|
3266
5101
|
}
|
|
3267
|
-
function
|
|
5102
|
+
function seedDestructuredRequestParams(scope, tainted) {
|
|
5103
|
+
if (!isFunctionNode(scope)) return;
|
|
5104
|
+
for (const param of scope.params) {
|
|
5105
|
+
if (param.type !== "ObjectPattern") continue;
|
|
5106
|
+
for (const prop of param.properties) {
|
|
5107
|
+
if (prop.type !== "Property") continue;
|
|
5108
|
+
const key = prop.key.type === "Identifier" ? prop.key.name : void 0;
|
|
5109
|
+
if (key === void 0 || !REQUEST_SOURCE_PROPS.has(key)) continue;
|
|
5110
|
+
for (const name of boundNames(prop.value)) tainted.add(name);
|
|
5111
|
+
}
|
|
5112
|
+
}
|
|
5113
|
+
}
|
|
5114
|
+
function seedFrameworkParams(scope, tainted) {
|
|
5115
|
+
if (!frameworksEnabled() || !isFunctionNode(scope)) return;
|
|
5116
|
+
for (const param of scope.params) {
|
|
5117
|
+
if (param.type === "Identifier" && param.name === "formData") tainted.add("formData");
|
|
5118
|
+
const nest = nestInputDecorator(param);
|
|
5119
|
+
if (nest === void 0) continue;
|
|
5120
|
+
for (const name of boundNames(param)) tainted.add(name);
|
|
5121
|
+
}
|
|
5122
|
+
}
|
|
5123
|
+
function seedMcpToolParams(scope, tainted) {
|
|
5124
|
+
if (!isFunctionNode(scope) || !isMcpToolHandler(scope)) return;
|
|
5125
|
+
for (const param of scope.params) {
|
|
5126
|
+
for (const name of boundNames(param)) tainted.add(name);
|
|
5127
|
+
}
|
|
5128
|
+
}
|
|
5129
|
+
function analyzeScope(scope, seed = [], returners = NO_RETURNERS) {
|
|
3268
5130
|
const tainted = new Set(seed);
|
|
5131
|
+
seedDestructuredRequestParams(scope, tainted);
|
|
5132
|
+
seedFrameworkParams(scope, tainted);
|
|
5133
|
+
seedMcpToolParams(scope, tainted);
|
|
3269
5134
|
const urlSanitized = /* @__PURE__ */ new Set();
|
|
3270
5135
|
const xssSanitized = /* @__PURE__ */ new Set();
|
|
5136
|
+
const nosqlSanitized = /* @__PURE__ */ new Set();
|
|
3271
5137
|
let changed = true;
|
|
3272
5138
|
while (changed) {
|
|
3273
5139
|
changed = false;
|
|
3274
5140
|
walkScope(scope, (node) => {
|
|
3275
5141
|
if (node.type === "VariableDeclarator") {
|
|
3276
5142
|
const init = node.init ?? void 0;
|
|
5143
|
+
if (isSchemaValidatorCall(init) || isCoercionCall(init) || isMongoSanitizeCall(init)) {
|
|
5144
|
+
for (const name of boundNames(node.id)) nosqlSanitized.add(name);
|
|
5145
|
+
}
|
|
3277
5146
|
if (isSchemaValidatorCall(init)) return;
|
|
3278
5147
|
if (isUrlSanitizerCall(init)) {
|
|
3279
5148
|
for (const name of boundNames(node.id)) urlSanitized.add(name);
|
|
@@ -3281,7 +5150,7 @@ function analyzeScope(scope, seed = []) {
|
|
|
3281
5150
|
if (isXssSanitizerCall(init)) {
|
|
3282
5151
|
for (const name of boundNames(node.id)) xssSanitized.add(name);
|
|
3283
5152
|
}
|
|
3284
|
-
if (exprIsTainted(init, tainted)) {
|
|
5153
|
+
if (exprIsTainted(init, tainted, returners)) {
|
|
3285
5154
|
for (const name of boundNames(node.id)) {
|
|
3286
5155
|
if (!tainted.has(name)) {
|
|
3287
5156
|
tainted.add(name);
|
|
@@ -3291,6 +5160,9 @@ function analyzeScope(scope, seed = []) {
|
|
|
3291
5160
|
}
|
|
3292
5161
|
}
|
|
3293
5162
|
if (node.type === "AssignmentExpression") {
|
|
5163
|
+
if (isSchemaValidatorCall(node.right) || isCoercionCall(node.right) || isMongoSanitizeCall(node.right)) {
|
|
5164
|
+
for (const name of boundNames(node.left)) nosqlSanitized.add(name);
|
|
5165
|
+
}
|
|
3294
5166
|
if (isSchemaValidatorCall(node.right)) return;
|
|
3295
5167
|
if (isUrlSanitizerCall(node.right)) {
|
|
3296
5168
|
for (const name of boundNames(node.left)) urlSanitized.add(name);
|
|
@@ -3298,7 +5170,7 @@ function analyzeScope(scope, seed = []) {
|
|
|
3298
5170
|
if (isXssSanitizerCall(node.right)) {
|
|
3299
5171
|
for (const name of boundNames(node.left)) xssSanitized.add(name);
|
|
3300
5172
|
}
|
|
3301
|
-
if (exprIsTainted(node.right, tainted)) {
|
|
5173
|
+
if (exprIsTainted(node.right, tainted, returners)) {
|
|
3302
5174
|
for (const name of boundNames(node.left)) {
|
|
3303
5175
|
if (!tainted.has(name)) {
|
|
3304
5176
|
tainted.add(name);
|
|
@@ -3336,6 +5208,16 @@ function analyzeScope(scope, seed = []) {
|
|
|
3336
5208
|
hasPasswordName = true;
|
|
3337
5209
|
}
|
|
3338
5210
|
const requestObjects = /* @__PURE__ */ new Set();
|
|
5211
|
+
if (frameworksEnabled() && isFunctionNode(scope)) {
|
|
5212
|
+
for (const param of scope.params) {
|
|
5213
|
+
const nest = nestInputDecorator(param);
|
|
5214
|
+
if (nest === "Body" || nest === "Query" || nest === "Param") {
|
|
5215
|
+
if (nestDecoratorArgCount(param, nest) === 0) {
|
|
5216
|
+
for (const name of boundNames(param)) requestObjects.add(name);
|
|
5217
|
+
}
|
|
5218
|
+
}
|
|
5219
|
+
}
|
|
5220
|
+
}
|
|
3339
5221
|
walkScope(scope, (node) => {
|
|
3340
5222
|
if (node.type === "VariableDeclarator" && node.init !== null && isRequestObjectNode(node.init)) {
|
|
3341
5223
|
for (const name of boundNames(node.id)) requestObjects.add(name);
|
|
@@ -3343,18 +5225,110 @@ function analyzeScope(scope, seed = []) {
|
|
|
3343
5225
|
if (node.type === "AssignmentExpression" && isRequestObjectNode(node.right)) {
|
|
3344
5226
|
for (const name of boundNames(node.left)) requestObjects.add(name);
|
|
3345
5227
|
}
|
|
5228
|
+
if (node.type === "VariableDeclarator" && node.init !== null && isFrameworkRequestObject(node.init)) {
|
|
5229
|
+
for (const name of boundNames(node.id)) requestObjects.add(name);
|
|
5230
|
+
}
|
|
5231
|
+
if (node.type === "AssignmentExpression" && isFrameworkRequestObject(node.right)) {
|
|
5232
|
+
for (const name of boundNames(node.left)) requestObjects.add(name);
|
|
5233
|
+
}
|
|
3346
5234
|
});
|
|
3347
|
-
return {
|
|
5235
|
+
return {
|
|
5236
|
+
tainted,
|
|
5237
|
+
pathSanitized,
|
|
5238
|
+
urlSanitized,
|
|
5239
|
+
xssSanitized,
|
|
5240
|
+
nosqlSanitized,
|
|
5241
|
+
hasPasswordName,
|
|
5242
|
+
requestObjects,
|
|
5243
|
+
returners: new Set(returners)
|
|
5244
|
+
};
|
|
5245
|
+
}
|
|
5246
|
+
function functionName(fn) {
|
|
5247
|
+
if (fn.type === "FunctionDeclaration" && fn.id !== null) return fn.id.name;
|
|
5248
|
+
if (isFunctionNode(fn) && fn.id !== null && fn.id.type === "Identifier") return fn.id.name;
|
|
5249
|
+
return assignedNameOf(fn);
|
|
5250
|
+
}
|
|
5251
|
+
function scopeReturnsTaint(scope, tainted, returners) {
|
|
5252
|
+
let yes = false;
|
|
5253
|
+
walkScope(scope, (node) => {
|
|
5254
|
+
if (node.type === "ReturnStatement" && node.argument !== null) {
|
|
5255
|
+
if (exprIsTainted(node.argument, tainted, returners)) yes = true;
|
|
5256
|
+
}
|
|
5257
|
+
});
|
|
5258
|
+
return yes;
|
|
3348
5259
|
}
|
|
3349
|
-
|
|
5260
|
+
var MAX_TAINT_HOPS = 5;
|
|
5261
|
+
function analyzeFile(ast, extraReturners = NO_RETURNERS) {
|
|
3350
5262
|
attachParents(ast);
|
|
3351
5263
|
const facts = /* @__PURE__ */ new WeakMap();
|
|
3352
|
-
|
|
5264
|
+
const functions = [];
|
|
3353
5265
|
walk(ast, (node) => {
|
|
3354
|
-
if (isFunctionNode(node))
|
|
5266
|
+
if (isFunctionNode(node)) functions.push(node);
|
|
3355
5267
|
});
|
|
5268
|
+
const nameToFn = /* @__PURE__ */ new Map();
|
|
5269
|
+
for (const fn of functions) {
|
|
5270
|
+
const name = functionName(fn);
|
|
5271
|
+
if (name !== void 0) nameToFn.set(name, fn);
|
|
5272
|
+
}
|
|
5273
|
+
const seeds = /* @__PURE__ */ new Map();
|
|
5274
|
+
for (const fn of functions) seeds.set(fn, /* @__PURE__ */ new Set());
|
|
5275
|
+
const returners = new Set(extraReturners);
|
|
5276
|
+
for (let hop = 0; hop < MAX_TAINT_HOPS; hop += 1) {
|
|
5277
|
+
let changed = false;
|
|
5278
|
+
facts.set(ast, analyzeScope(ast, [], returners));
|
|
5279
|
+
for (const fn of functions) {
|
|
5280
|
+
facts.set(fn, analyzeScope(fn, seeds.get(fn) ?? [], returners));
|
|
5281
|
+
}
|
|
5282
|
+
for (const fn of functions) {
|
|
5283
|
+
const name = functionName(fn);
|
|
5284
|
+
const f = facts.get(fn);
|
|
5285
|
+
if (name === void 0 || f === void 0) continue;
|
|
5286
|
+
if (scopeReturnsTaint(fn, f.tainted, returners) && !returners.has(name)) {
|
|
5287
|
+
returners.add(name);
|
|
5288
|
+
changed = true;
|
|
5289
|
+
}
|
|
5290
|
+
}
|
|
5291
|
+
for (const fn of functions) {
|
|
5292
|
+
const f = facts.get(fn);
|
|
5293
|
+
if (f === void 0) continue;
|
|
5294
|
+
walkScope(fn, (node) => {
|
|
5295
|
+
if (node.type !== "CallExpression") return;
|
|
5296
|
+
if (node.callee.type !== "Identifier") return;
|
|
5297
|
+
const callee = nameToFn.get(node.callee.name);
|
|
5298
|
+
if (callee === void 0 || !isFunctionNode(callee)) return;
|
|
5299
|
+
node.arguments.forEach((arg, i) => {
|
|
5300
|
+
if (arg.type === "SpreadElement") return;
|
|
5301
|
+
if (!exprIsTainted(arg, f.tainted, returners)) return;
|
|
5302
|
+
const param = callee.params[i];
|
|
5303
|
+
if (param === void 0) return;
|
|
5304
|
+
const pname = boundNames(param)[0];
|
|
5305
|
+
if (pname === void 0) return;
|
|
5306
|
+
const set = seeds.get(callee) ?? /* @__PURE__ */ new Set();
|
|
5307
|
+
if (!set.has(pname)) {
|
|
5308
|
+
set.add(pname);
|
|
5309
|
+
seeds.set(callee, set);
|
|
5310
|
+
changed = true;
|
|
5311
|
+
}
|
|
5312
|
+
});
|
|
5313
|
+
});
|
|
5314
|
+
}
|
|
5315
|
+
if (!changed) break;
|
|
5316
|
+
}
|
|
3356
5317
|
return facts;
|
|
3357
5318
|
}
|
|
5319
|
+
function functionsThatReturnTaint(ast) {
|
|
5320
|
+
attachParents(ast);
|
|
5321
|
+
const names = /* @__PURE__ */ new Set();
|
|
5322
|
+
const facts = analyzeFile(ast);
|
|
5323
|
+
walk(ast, (node) => {
|
|
5324
|
+
if (!isFunctionNode(node)) return;
|
|
5325
|
+
const name = functionName(node);
|
|
5326
|
+
const f = facts.get(node);
|
|
5327
|
+
if (name === void 0 || f === void 0) return;
|
|
5328
|
+
if (scopeReturnsTaint(node, f.tainted, f.returners)) names.add(name);
|
|
5329
|
+
});
|
|
5330
|
+
return names;
|
|
5331
|
+
}
|
|
3358
5332
|
function analyzeFunctionWithSeed(fn, seed) {
|
|
3359
5333
|
attachParents(fn);
|
|
3360
5334
|
return analyzeScope(fn, seed);
|
|
@@ -3365,12 +5339,15 @@ function factsFor(node, map) {
|
|
|
3365
5339
|
pathSanitized: /* @__PURE__ */ new Set(),
|
|
3366
5340
|
urlSanitized: /* @__PURE__ */ new Set(),
|
|
3367
5341
|
xssSanitized: /* @__PURE__ */ new Set(),
|
|
5342
|
+
nosqlSanitized: /* @__PURE__ */ new Set(),
|
|
3368
5343
|
hasPasswordName: false,
|
|
3369
|
-
requestObjects: /* @__PURE__ */ new Set()
|
|
5344
|
+
requestObjects: /* @__PURE__ */ new Set(),
|
|
5345
|
+
returners: /* @__PURE__ */ new Set()
|
|
3370
5346
|
};
|
|
3371
5347
|
}
|
|
3372
5348
|
function nodeIsTainted(node, map) {
|
|
3373
|
-
|
|
5349
|
+
const facts = factsFor(node, map);
|
|
5350
|
+
return exprIsTainted(node, facts.tainted, facts.returners);
|
|
3374
5351
|
}
|
|
3375
5352
|
function nodeIsPathSanitized(node, map) {
|
|
3376
5353
|
if (node.type !== "Identifier") return false;
|
|
@@ -3378,14 +5355,21 @@ function nodeIsPathSanitized(node, map) {
|
|
|
3378
5355
|
}
|
|
3379
5356
|
function nodeIsSanitizedFor(node, map, kind) {
|
|
3380
5357
|
const facts = factsFor(node, map);
|
|
3381
|
-
if (kind === "url")
|
|
3382
|
-
|
|
5358
|
+
if (kind === "url") {
|
|
5359
|
+
return exprIsSanitized(node, facts.tainted, facts.urlSanitized, isUrlSanitizerCall, facts.returners);
|
|
5360
|
+
}
|
|
5361
|
+
return exprIsSanitized(node, facts.tainted, facts.xssSanitized, isXssSanitizerCall, facts.returners);
|
|
3383
5362
|
}
|
|
3384
5363
|
function nodeIsRequestObject(node, map) {
|
|
3385
|
-
if (isRequestObjectNode(node)) return true;
|
|
5364
|
+
if (isRequestObjectNode(node) || isFrameworkRequestObject(node)) return true;
|
|
3386
5365
|
if (node.type === "Identifier") return factsFor(node, map).requestObjects.has(node.name);
|
|
3387
5366
|
return false;
|
|
3388
5367
|
}
|
|
5368
|
+
function nodeIsNosqlSanitized(node, map) {
|
|
5369
|
+
if (isCoercionCall(node) || isSchemaValidatorCall(node) || isMongoSanitizeCall(node)) return true;
|
|
5370
|
+
if (node.type === "Identifier") return factsFor(node, map).nosqlSanitized.has(node.name);
|
|
5371
|
+
return false;
|
|
5372
|
+
}
|
|
3389
5373
|
|
|
3390
5374
|
// src/modules/sast/interproc.ts
|
|
3391
5375
|
var MAX_HOPS = 5;
|
|
@@ -3725,6 +5709,22 @@ var authRule = {
|
|
|
3725
5709
|
node,
|
|
3726
5710
|
message: `Route ${path ?? "(dynamic)"} touches database/user data with no auth middleware in the chain.`
|
|
3727
5711
|
});
|
|
5712
|
+
},
|
|
5713
|
+
FunctionDeclaration(node) {
|
|
5714
|
+
if (!isNextHandler(node) || !handlerTouchesDb(node) || handlerHasFrameworkAuth(node)) return;
|
|
5715
|
+
ctx.report({
|
|
5716
|
+
node,
|
|
5717
|
+
message: `Next.js handler ${node.type === "FunctionDeclaration" && node.id ? node.id.name : ""} touches database/user data with no auth() / session check.`
|
|
5718
|
+
});
|
|
5719
|
+
},
|
|
5720
|
+
MethodDefinition(node) {
|
|
5721
|
+
if (node.type !== "MethodDefinition" || !isNestHttpMethod(node)) return;
|
|
5722
|
+
if (nestMethodHasGuard(node)) return;
|
|
5723
|
+
if (!handlerTouchesDb(node.value)) return;
|
|
5724
|
+
ctx.report({
|
|
5725
|
+
node,
|
|
5726
|
+
message: "NestJS handler touches database/user data with no UseGuards / AuthGuard."
|
|
5727
|
+
});
|
|
3728
5728
|
}
|
|
3729
5729
|
};
|
|
3730
5730
|
}
|
|
@@ -3733,6 +5733,8 @@ var authRule = {
|
|
|
3733
5733
|
// src/modules/sast/rules/cmdi.ts
|
|
3734
5734
|
var ALWAYS_SHELL = /* @__PURE__ */ new Set(["exec", "execSync"]);
|
|
3735
5735
|
var OPTIONAL_SHELL = /* @__PURE__ */ new Set(["spawn", "spawnSync", "execFile", "execFileSync"]);
|
|
5736
|
+
var INTERPRETERS = /* @__PURE__ */ new Set(["sh", "bash", "zsh", "dash", "cmd", "cmd.exe", "powershell", "pwsh"]);
|
|
5737
|
+
var DASH_C_CALLEES = /* @__PURE__ */ new Set(["spawn", "spawnSync", "execFile", "execFileSync", "execa"]);
|
|
3736
5738
|
function optionsObject(node) {
|
|
3737
5739
|
for (const arg of node.arguments) {
|
|
3738
5740
|
if (arg.type === "ObjectExpression") return arg;
|
|
@@ -3763,6 +5765,45 @@ function argumentTainted(node, isTainted) {
|
|
|
3763
5765
|
}
|
|
3764
5766
|
return false;
|
|
3765
5767
|
}
|
|
5768
|
+
function interpreterBase(node) {
|
|
5769
|
+
const cmd = literalString(node.arguments[0]);
|
|
5770
|
+
if (cmd === void 0) return void 0;
|
|
5771
|
+
return cmd.replace(/^.*[/\\]/, "").toLowerCase();
|
|
5772
|
+
}
|
|
5773
|
+
function isInterpreterDashC(node, isTainted) {
|
|
5774
|
+
const name = calleeName(node);
|
|
5775
|
+
if (name === void 0 || !DASH_C_CALLEES.has(name)) return false;
|
|
5776
|
+
const base = interpreterBase(node);
|
|
5777
|
+
if (base === void 0 || !INTERPRETERS.has(base)) return false;
|
|
5778
|
+
const args = node.arguments[1];
|
|
5779
|
+
if (args === void 0 || args.type !== "ArrayExpression") return false;
|
|
5780
|
+
for (let i = 0; i < args.elements.length; i += 1) {
|
|
5781
|
+
const el = args.elements[i];
|
|
5782
|
+
if (el == null || el.type === "SpreadElement") continue;
|
|
5783
|
+
if (literalString(el) !== "-c") continue;
|
|
5784
|
+
const next = args.elements[i + 1];
|
|
5785
|
+
return next !== null && next !== void 0 && next.type !== "SpreadElement" && isTainted(next);
|
|
5786
|
+
}
|
|
5787
|
+
return false;
|
|
5788
|
+
}
|
|
5789
|
+
function isUnsafeZxTag(tag) {
|
|
5790
|
+
if (tag.type === "MemberExpression" && propName(tag) === "raw") {
|
|
5791
|
+
return tag.object.type === "Identifier" && tag.object.name === "$";
|
|
5792
|
+
}
|
|
5793
|
+
if (tag.type === "CallExpression" && tag.callee.type === "Identifier" && tag.callee.name === "$") {
|
|
5794
|
+
const opts = tag.arguments[0];
|
|
5795
|
+
if (opts === void 0 || opts.type !== "ObjectExpression") return false;
|
|
5796
|
+
for (const prop of opts.properties) {
|
|
5797
|
+
if (prop.type !== "Property") continue;
|
|
5798
|
+
const key = prop.key.type === "Identifier" ? prop.key.name : literalString(prop.key);
|
|
5799
|
+
if (key === "quote" && prop.value.type === "Literal" && prop.value.value === false) return true;
|
|
5800
|
+
}
|
|
5801
|
+
}
|
|
5802
|
+
return false;
|
|
5803
|
+
}
|
|
5804
|
+
function isExecaShellTag(tag) {
|
|
5805
|
+
return tag.type === "CallExpression" && calleeName(tag) === "execa" && hasShellTrue(tag);
|
|
5806
|
+
}
|
|
3766
5807
|
var cmdiRule = {
|
|
3767
5808
|
meta: {
|
|
3768
5809
|
id: "SAST-CMDI-001",
|
|
@@ -3772,22 +5813,36 @@ var cmdiRule = {
|
|
|
3772
5813
|
docs: "https://cwe.mitre.org/data/definitions/78.html"
|
|
3773
5814
|
},
|
|
3774
5815
|
create(ctx) {
|
|
5816
|
+
const tainted = (n) => ctx.isTainted(n);
|
|
3775
5817
|
return {
|
|
3776
5818
|
CallExpression(node) {
|
|
3777
5819
|
if (node.type !== "CallExpression") return;
|
|
3778
5820
|
const name = calleeName(node);
|
|
3779
5821
|
if (name === void 0) return;
|
|
3780
|
-
const
|
|
5822
|
+
const dashC = sastGapsEnabled() && isInterpreterDashC(node, tainted);
|
|
5823
|
+
const execaCall = sastGapsEnabled() && name === "execa" && hasShellTrue(node);
|
|
5824
|
+
const shell = ALWAYS_SHELL.has(name) || OPTIONAL_SHELL.has(name) && hasShellTrue(node) || dashC || execaCall;
|
|
3781
5825
|
if (!shell) return;
|
|
3782
5826
|
if (node.callee.type === "MemberExpression") {
|
|
3783
5827
|
const obj = node.callee.object;
|
|
3784
5828
|
const fromChild = obj.type === "Identifier" && (obj.name === "child_process" || obj.name === "cp") || propName(node.callee) !== void 0;
|
|
3785
|
-
if (!fromChild && !ALWAYS_SHELL.has(name) && !OPTIONAL_SHELL.has(name))
|
|
5829
|
+
if (!fromChild && !ALWAYS_SHELL.has(name) && !OPTIONAL_SHELL.has(name) && !dashC && !execaCall) {
|
|
5830
|
+
return;
|
|
5831
|
+
}
|
|
3786
5832
|
}
|
|
3787
|
-
if (!argumentTainted(node,
|
|
5833
|
+
if (!argumentTainted(node, tainted) && !dashC) return;
|
|
5834
|
+
ctx.report({
|
|
5835
|
+
node,
|
|
5836
|
+
message: "Tainted input reaches a shell command (exec/execSync/spawn with shell: true)."
|
|
5837
|
+
});
|
|
5838
|
+
},
|
|
5839
|
+
TaggedTemplateExpression(node) {
|
|
5840
|
+
if (node.type !== "TaggedTemplateExpression" || !sastGapsEnabled()) return;
|
|
5841
|
+
if (!isUnsafeZxTag(node.tag) && !isExecaShellTag(node.tag)) return;
|
|
5842
|
+
if (!tainted(node.quasi)) return;
|
|
3788
5843
|
ctx.report({
|
|
3789
5844
|
node,
|
|
3790
|
-
message: "Tainted input reaches a shell
|
|
5845
|
+
message: "Tainted input reaches a shell tagged template that does not auto-escape interpolations."
|
|
3791
5846
|
});
|
|
3792
5847
|
}
|
|
3793
5848
|
};
|
|
@@ -3819,7 +5874,18 @@ var cryptoRule = {
|
|
|
3819
5874
|
}
|
|
3820
5875
|
return;
|
|
3821
5876
|
}
|
|
3822
|
-
|
|
5877
|
+
const name = calleeName(node);
|
|
5878
|
+
if (name === "md5") {
|
|
5879
|
+
const arg = node.arguments[0];
|
|
5880
|
+
if (arg !== void 0 && arg.type !== "SpreadElement" && ctx.isTainted(arg)) {
|
|
5881
|
+
ctx.report({
|
|
5882
|
+
node,
|
|
5883
|
+
message: "The md5 helper is used as a token or identifier over tainted input."
|
|
5884
|
+
});
|
|
5885
|
+
}
|
|
5886
|
+
return;
|
|
5887
|
+
}
|
|
5888
|
+
if (name !== "createHash") return;
|
|
3823
5889
|
const algo = literalString(node.arguments[0]);
|
|
3824
5890
|
if (algo === void 0 || !WEAK_HASH.has(algo.toLowerCase())) return;
|
|
3825
5891
|
if (!ctx.functionHasPasswordName(node)) return;
|
|
@@ -3944,6 +6010,90 @@ var MONGO_SINKS = /* @__PURE__ */ new Set([
|
|
|
3944
6010
|
"remove",
|
|
3945
6011
|
"aggregate"
|
|
3946
6012
|
]);
|
|
6013
|
+
var SQL_ONLY_METHODS = /* @__PURE__ */ new Set([
|
|
6014
|
+
"findUnique",
|
|
6015
|
+
"findMany",
|
|
6016
|
+
"findFirst",
|
|
6017
|
+
"findAll",
|
|
6018
|
+
"findAndCountAll",
|
|
6019
|
+
"findByPk",
|
|
6020
|
+
"findById"
|
|
6021
|
+
]);
|
|
6022
|
+
var SQL_ORM_IMPORT = /(?:from|require\s*\(\s*)['"](?:sequelize|@prisma\/client|typeorm|knex|drizzle-orm)['"]/;
|
|
6023
|
+
var MONGO_IMPORT = /(?:from|require\s*\(\s*)['"](?:mongodb|mongoose)['"]/;
|
|
6024
|
+
function objectKey(prop) {
|
|
6025
|
+
if (prop.type !== "Property" || prop.key === void 0) return void 0;
|
|
6026
|
+
const key = prop.key;
|
|
6027
|
+
if (key.type === "Identifier") return key.name;
|
|
6028
|
+
if (key.type === "Literal") return String(key.value);
|
|
6029
|
+
return literalString(key);
|
|
6030
|
+
}
|
|
6031
|
+
function objectHasKey(node, name) {
|
|
6032
|
+
if (node.type !== "ObjectExpression") return false;
|
|
6033
|
+
return node.properties.some((prop) => objectKey(prop) === name);
|
|
6034
|
+
}
|
|
6035
|
+
function isPascalModel(name) {
|
|
6036
|
+
return /^[A-Z][A-Za-z0-9]*$/.test(name);
|
|
6037
|
+
}
|
|
6038
|
+
function isCollectionName(name) {
|
|
6039
|
+
return /^(collection|col)$/i.test(name) || /Collection$/i.test(name);
|
|
6040
|
+
}
|
|
6041
|
+
function isMongoReceiver(node, source) {
|
|
6042
|
+
if (node.type !== "CallExpression" || node.callee.type !== "MemberExpression") return false;
|
|
6043
|
+
const fileHasMongo = MONGO_IMPORT.test(source);
|
|
6044
|
+
const fileHasSqlOrm = SQL_ORM_IMPORT.test(source) && !fileHasMongo;
|
|
6045
|
+
if (fileHasSqlOrm) return false;
|
|
6046
|
+
const obj = node.callee.object;
|
|
6047
|
+
if (obj.type === "Identifier") {
|
|
6048
|
+
if (isCollectionName(obj.name)) return true;
|
|
6049
|
+
if (isPascalModel(obj.name)) return !fileHasSqlOrm;
|
|
6050
|
+
return fileHasMongo;
|
|
6051
|
+
}
|
|
6052
|
+
if (obj.type === "MemberExpression") {
|
|
6053
|
+
const prop = propName(obj);
|
|
6054
|
+
if (prop !== void 0 && (prop === "collection" || isCollectionName(prop))) return true;
|
|
6055
|
+
if (prop !== void 0 && isPascalModel(prop)) return fileHasMongo;
|
|
6056
|
+
if (obj.object.type === "Identifier" && obj.object.name === "mongoose") return true;
|
|
6057
|
+
}
|
|
6058
|
+
return fileHasMongo;
|
|
6059
|
+
}
|
|
6060
|
+
function objectHasTaintedWhere(node, isTainted) {
|
|
6061
|
+
if (node.type !== "ObjectExpression") return false;
|
|
6062
|
+
for (const prop of node.properties) {
|
|
6063
|
+
if (prop.type !== "Property") continue;
|
|
6064
|
+
const key = prop.key.type === "Identifier" ? prop.key.name : prop.key.type === "Literal" ? String(prop.key.value) : literalString(prop.key);
|
|
6065
|
+
if (key === "$where" && isTainted(prop.value)) return true;
|
|
6066
|
+
}
|
|
6067
|
+
return false;
|
|
6068
|
+
}
|
|
6069
|
+
function fileHasMongoSanitizeMiddleware(source) {
|
|
6070
|
+
return /(?:express-)?mongo-sanitize/.test(source) && /(?:app|router)\.use\(\s*(?:mongoSanitize|sanitize)\s*\(/.test(source);
|
|
6071
|
+
}
|
|
6072
|
+
function isSanitizeWrap(node, source) {
|
|
6073
|
+
if (node.type !== "CallExpression") return false;
|
|
6074
|
+
const name = calleeName(node);
|
|
6075
|
+
return name === "sanitize" && /(?:express-)?mongo-sanitize/.test(source);
|
|
6076
|
+
}
|
|
6077
|
+
function isNosqlSafeValue(node, source, isNosqlSanitized) {
|
|
6078
|
+
if (isNosqlSanitized(node) || isCoercionCall(node) || isSchemaValidatorCall(node) || isMongoSanitizeCall(node)) {
|
|
6079
|
+
return true;
|
|
6080
|
+
}
|
|
6081
|
+
return isSanitizeWrap(node, source);
|
|
6082
|
+
}
|
|
6083
|
+
function objectHasUnsafeTaintedValue(node, isTainted, isSafe) {
|
|
6084
|
+
if (node.type === "ArrayExpression") {
|
|
6085
|
+
return node.elements.some((el) => el !== null && el.type !== "SpreadElement" && objectHasUnsafeTaintedValue(el, isTainted, isSafe));
|
|
6086
|
+
}
|
|
6087
|
+
if (node.type !== "ObjectExpression") return false;
|
|
6088
|
+
for (const prop of node.properties) {
|
|
6089
|
+
if (prop.type !== "Property") continue;
|
|
6090
|
+
const value = prop.value;
|
|
6091
|
+
if (isSafe(value)) continue;
|
|
6092
|
+
if (isTainted(value)) return true;
|
|
6093
|
+
if (objectHasUnsafeTaintedValue(value, isTainted, isSafe)) return true;
|
|
6094
|
+
}
|
|
6095
|
+
return false;
|
|
6096
|
+
}
|
|
3947
6097
|
var nosqliRule = {
|
|
3948
6098
|
meta: {
|
|
3949
6099
|
id: "SAST-NOSQLI-001",
|
|
@@ -3953,24 +6103,58 @@ var nosqliRule = {
|
|
|
3953
6103
|
docs: "https://cwe.mitre.org/data/definitions/943.html"
|
|
3954
6104
|
},
|
|
3955
6105
|
create(ctx) {
|
|
6106
|
+
const middleware = fileHasMongoSanitizeMiddleware(ctx.source);
|
|
6107
|
+
const isSafe = (n) => isNosqlSafeValue(n, ctx.source, (x) => ctx.isNosqlSanitized(x));
|
|
3956
6108
|
return {
|
|
3957
6109
|
CallExpression(node) {
|
|
3958
6110
|
if (node.type !== "CallExpression") return;
|
|
3959
6111
|
const name = calleeName(node);
|
|
6112
|
+
if (name === "$where") {
|
|
6113
|
+
if (!MONGO_IMPORT.test(ctx.source) && !isMongoReceiver(node, ctx.source)) return;
|
|
6114
|
+
const expr = node.arguments[0];
|
|
6115
|
+
if (expr !== void 0 && expr.type !== "SpreadElement" && ctx.isTainted(expr)) {
|
|
6116
|
+
ctx.report({
|
|
6117
|
+
node,
|
|
6118
|
+
message: "Tainted input is passed to mongoose $where (server-side JS)."
|
|
6119
|
+
});
|
|
6120
|
+
}
|
|
6121
|
+
return;
|
|
6122
|
+
}
|
|
6123
|
+
if (name !== void 0 && SQL_ONLY_METHODS.has(name)) return;
|
|
3960
6124
|
if (name === void 0 || !MONGO_SINKS.has(name)) return;
|
|
6125
|
+
if (!isMongoReceiver(node, ctx.source)) return;
|
|
3961
6126
|
const filter = node.arguments[0];
|
|
3962
6127
|
if (filter === void 0 || filter.type === "SpreadElement") return;
|
|
3963
|
-
if (
|
|
3964
|
-
ctx.
|
|
3965
|
-
|
|
3966
|
-
|
|
3967
|
-
|
|
6128
|
+
if (objectHasKey(filter, "where")) return;
|
|
6129
|
+
if (ctx.isRequestObject(filter)) {
|
|
6130
|
+
ctx.report({
|
|
6131
|
+
node,
|
|
6132
|
+
message: "req.body/req.query is passed directly into a Mongo-style filter."
|
|
6133
|
+
});
|
|
6134
|
+
return;
|
|
6135
|
+
}
|
|
6136
|
+
if (objectHasTaintedWhere(filter, (n) => ctx.isTainted(n))) {
|
|
6137
|
+
ctx.report({
|
|
6138
|
+
node,
|
|
6139
|
+
message: "Tainted input is passed to a Mongo $where filter."
|
|
6140
|
+
});
|
|
6141
|
+
return;
|
|
6142
|
+
}
|
|
6143
|
+
if (middleware) return;
|
|
6144
|
+
if (objectHasUnsafeTaintedValue(filter, (n) => ctx.isTainted(n), isSafe)) {
|
|
6145
|
+
ctx.report({
|
|
6146
|
+
node,
|
|
6147
|
+
message: "Request input is used as a Mongo filter value without coercion, schema validation, or mongo-sanitize.",
|
|
6148
|
+
confidence: "medium"
|
|
6149
|
+
});
|
|
6150
|
+
}
|
|
3968
6151
|
}
|
|
3969
6152
|
};
|
|
3970
6153
|
}
|
|
3971
6154
|
};
|
|
3972
6155
|
|
|
3973
6156
|
// src/modules/sast/rules/path.ts
|
|
6157
|
+
var EXTRACT_SINKS = /* @__PURE__ */ new Set(["extractAllTo", "extractAll", "extract"]);
|
|
3974
6158
|
var PATH_SINKS = /* @__PURE__ */ new Set([
|
|
3975
6159
|
"sendFile",
|
|
3976
6160
|
"readFile",
|
|
@@ -4012,7 +6196,15 @@ var pathRule = {
|
|
|
4012
6196
|
CallExpression(node) {
|
|
4013
6197
|
if (node.type !== "CallExpression") return;
|
|
4014
6198
|
const name = calleeName(node);
|
|
4015
|
-
if (name === void 0
|
|
6199
|
+
if (name === void 0) return;
|
|
6200
|
+
if (EXTRACT_SINKS.has(name) && node.callee.type === "MemberExpression" && ctx.isTainted(node.callee.object)) {
|
|
6201
|
+
ctx.report({
|
|
6202
|
+
node,
|
|
6203
|
+
message: "A tainted archive is extracted to the filesystem (zip-slip)."
|
|
6204
|
+
});
|
|
6205
|
+
return;
|
|
6206
|
+
}
|
|
6207
|
+
if (!PATH_SINKS.has(name) && !EXTRACT_SINKS.has(name)) return;
|
|
4016
6208
|
const pathArg = node.arguments[0];
|
|
4017
6209
|
if (pathArg === void 0 || pathArg.type === "SpreadElement") return;
|
|
4018
6210
|
if (!ctx.isTainted(pathArg)) return;
|
|
@@ -4083,10 +6275,34 @@ var redirectRule = {
|
|
|
4083
6275
|
};
|
|
4084
6276
|
|
|
4085
6277
|
// src/modules/sast/rules/sqli.ts
|
|
6278
|
+
var STABLE_SQL_SINKS = /* @__PURE__ */ new Set(["query", "execute"]);
|
|
6279
|
+
var GAP_SQL_SINKS = /* @__PURE__ */ new Set([
|
|
6280
|
+
"$queryRawUnsafe",
|
|
6281
|
+
"$executeRawUnsafe",
|
|
6282
|
+
"raw",
|
|
6283
|
+
"literal",
|
|
6284
|
+
"whereRaw",
|
|
6285
|
+
"orderByRaw"
|
|
6286
|
+
]);
|
|
6287
|
+
function hasBindings(node) {
|
|
6288
|
+
const second = node.arguments[1];
|
|
6289
|
+
if (second === void 0 || second.type === "SpreadElement") return false;
|
|
6290
|
+
return second.type === "ArrayExpression" || second.type === "ObjectExpression";
|
|
6291
|
+
}
|
|
4086
6292
|
function isSqlSink2(node) {
|
|
4087
|
-
if (node.type !== "CallExpression") return false;
|
|
4088
6293
|
const name = calleeName(node);
|
|
4089
|
-
|
|
6294
|
+
if (name === void 0) return false;
|
|
6295
|
+
if (STABLE_SQL_SINKS.has(name)) return true;
|
|
6296
|
+
if (!sastGapsEnabled()) return false;
|
|
6297
|
+
if (GAP_SQL_SINKS.has(name)) {
|
|
6298
|
+
if ((name === "whereRaw" || name === "orderByRaw") && hasBindings(node)) return false;
|
|
6299
|
+
return true;
|
|
6300
|
+
}
|
|
6301
|
+
if (name === "where" && calleeChainHas(node, "createQueryBuilder")) {
|
|
6302
|
+
const first = node.arguments[0];
|
|
6303
|
+
return first !== void 0 && first.type !== "ObjectExpression";
|
|
6304
|
+
}
|
|
6305
|
+
return false;
|
|
4090
6306
|
}
|
|
4091
6307
|
var sqliRule = {
|
|
4092
6308
|
meta: {
|
|
@@ -4195,6 +6411,19 @@ var xssRule = {
|
|
|
4195
6411
|
return {
|
|
4196
6412
|
CallExpression(node) {
|
|
4197
6413
|
if (node.type !== "CallExpression") return;
|
|
6414
|
+
for (const arg2 of node.arguments) {
|
|
6415
|
+
if (arg2.type !== "ObjectExpression") continue;
|
|
6416
|
+
for (const prop of arg2.properties) {
|
|
6417
|
+
if (prop.type !== "Property") continue;
|
|
6418
|
+
const key = prop.key.type === "Identifier" ? prop.key.name : literalString(prop.key);
|
|
6419
|
+
if (key === "autoescape" && prop.value.type === "Literal" && prop.value.value === false) {
|
|
6420
|
+
ctx.report({
|
|
6421
|
+
node,
|
|
6422
|
+
message: "Template engine autoescape is disabled; interpolations are XSS sinks."
|
|
6423
|
+
});
|
|
6424
|
+
}
|
|
6425
|
+
}
|
|
6426
|
+
}
|
|
4198
6427
|
const name = calleeName(node);
|
|
4199
6428
|
if (name !== "send" && name !== "write") return;
|
|
4200
6429
|
const arg = node.arguments[0];
|
|
@@ -4210,497 +6439,845 @@ var xssRule = {
|
|
|
4210
6439
|
}
|
|
4211
6440
|
};
|
|
4212
6441
|
|
|
4213
|
-
// src/
|
|
4214
|
-
|
|
4215
|
-
|
|
4216
|
-
|
|
4217
|
-
|
|
6442
|
+
// src/modules/sast/rules/phase29.ts
|
|
6443
|
+
function gated(rule) {
|
|
6444
|
+
return {
|
|
6445
|
+
meta: rule.meta,
|
|
6446
|
+
create(ctx) {
|
|
6447
|
+
if (!classesEnabled()) return {};
|
|
6448
|
+
return rule.create(ctx);
|
|
6449
|
+
}
|
|
6450
|
+
};
|
|
4218
6451
|
}
|
|
4219
|
-
function
|
|
4220
|
-
|
|
4221
|
-
const
|
|
4222
|
-
|
|
4223
|
-
|
|
4224
|
-
if (
|
|
4225
|
-
|
|
4226
|
-
|
|
4227
|
-
|
|
4228
|
-
|
|
4229
|
-
|
|
4230
|
-
|
|
4231
|
-
|
|
4232
|
-
|
|
4233
|
-
|
|
4234
|
-
|
|
4235
|
-
|
|
4236
|
-
|
|
4237
|
-
|
|
4238
|
-
|
|
4239
|
-
|
|
6452
|
+
function objectProp2(node, name) {
|
|
6453
|
+
if (node === void 0 || node.type !== "ObjectExpression") return void 0;
|
|
6454
|
+
for (const prop of node.properties) {
|
|
6455
|
+
if (prop.type !== "Property") continue;
|
|
6456
|
+
const key = prop.key.type === "Identifier" ? prop.key.name : literalString(prop.key);
|
|
6457
|
+
if (key === name) return prop.value;
|
|
6458
|
+
}
|
|
6459
|
+
return void 0;
|
|
6460
|
+
}
|
|
6461
|
+
function boolLiteral(node) {
|
|
6462
|
+
if (node === void 0 || node.type !== "Literal" || typeof node.value !== "boolean") return void 0;
|
|
6463
|
+
return node.value;
|
|
6464
|
+
}
|
|
6465
|
+
function numberLiteral(node) {
|
|
6466
|
+
if (node === void 0 || node.type !== "Literal" || typeof node.value !== "number") return void 0;
|
|
6467
|
+
return node.value;
|
|
6468
|
+
}
|
|
6469
|
+
function containsIdentMember(node, objectName) {
|
|
6470
|
+
let hit = false;
|
|
6471
|
+
walk(node, (child) => {
|
|
6472
|
+
if (child.type === "MemberExpression" && child.object.type === "Identifier" && child.object.name === objectName) {
|
|
6473
|
+
hit = true;
|
|
6474
|
+
}
|
|
4240
6475
|
});
|
|
4241
|
-
return
|
|
6476
|
+
return hit;
|
|
4242
6477
|
}
|
|
4243
|
-
|
|
4244
|
-
|
|
4245
|
-
|
|
4246
|
-
|
|
4247
|
-
cmdiRule,
|
|
4248
|
-
pathRule,
|
|
4249
|
-
evalRule,
|
|
4250
|
-
jwtRule,
|
|
4251
|
-
cryptoRule,
|
|
4252
|
-
nosqliRule,
|
|
4253
|
-
ssrfRule,
|
|
4254
|
-
redirectRule,
|
|
4255
|
-
protoRule,
|
|
4256
|
-
xssRule,
|
|
4257
|
-
authRule
|
|
4258
|
-
];
|
|
4259
|
-
var SOURCE_EXT = /\.[cm]?[jt]sx?$/;
|
|
4260
|
-
async function parseSource(code) {
|
|
4261
|
-
const { parse } = await import('@typescript-eslint/typescript-estree');
|
|
4262
|
-
return parse(code, {
|
|
4263
|
-
loc: true,
|
|
4264
|
-
range: true,
|
|
4265
|
-
comment: false,
|
|
4266
|
-
jsx: true,
|
|
4267
|
-
errorOnUnknownASTType: false,
|
|
4268
|
-
errorOnTypeScriptSyntacticAndSemanticIssues: false
|
|
6478
|
+
function containsCallNamed(node, name) {
|
|
6479
|
+
let hit = false;
|
|
6480
|
+
walk(node, (child) => {
|
|
6481
|
+
if (child.type === "CallExpression" && calleeName(child) === name) hit = true;
|
|
4269
6482
|
});
|
|
6483
|
+
return hit;
|
|
4270
6484
|
}
|
|
4271
|
-
function
|
|
4272
|
-
|
|
4273
|
-
|
|
6485
|
+
function containsOriginalName(node) {
|
|
6486
|
+
let hit = false;
|
|
6487
|
+
walk(node, (child) => {
|
|
6488
|
+
if (child.type === "MemberExpression" && propName(child) === "originalname") hit = true;
|
|
6489
|
+
});
|
|
6490
|
+
return hit;
|
|
4274
6491
|
}
|
|
4275
|
-
function
|
|
4276
|
-
|
|
4277
|
-
const
|
|
4278
|
-
|
|
4279
|
-
const ctx = {
|
|
4280
|
-
filename,
|
|
4281
|
-
source,
|
|
4282
|
-
report: ({ node, message }) => {
|
|
4283
|
-
const snippet = snippetOf(source, node);
|
|
4284
|
-
findings.push({
|
|
4285
|
-
id: makeFindingId(rule.meta.id, filename, `${node.loc?.start.line ?? 0}|${snippet}`),
|
|
4286
|
-
ruleId: rule.meta.id,
|
|
4287
|
-
module: "sast",
|
|
4288
|
-
title: rule.meta.title,
|
|
4289
|
-
severity: rule.meta.severity,
|
|
4290
|
-
confidence: "high",
|
|
4291
|
-
cwe: rule.meta.cwe,
|
|
4292
|
-
file: filename,
|
|
4293
|
-
line: node.loc?.start.line,
|
|
4294
|
-
column: node.loc?.start.column,
|
|
4295
|
-
snippet,
|
|
4296
|
-
message,
|
|
4297
|
-
remediation: `${rule.meta.title}. See ${rule.meta.docs}`,
|
|
4298
|
-
references: [rule.meta.docs, rule.meta.cwe]
|
|
4299
|
-
});
|
|
4300
|
-
},
|
|
4301
|
-
isTainted: (node) => nodeIsTainted(node, facts),
|
|
4302
|
-
isPathSanitized: (node) => nodeIsPathSanitized(node, facts),
|
|
4303
|
-
isSanitizedFor: (node, kind) => nodeIsSanitizedFor(node, facts, kind),
|
|
4304
|
-
isRequestObject: (node) => nodeIsRequestObject(node, facts),
|
|
4305
|
-
functionHasPasswordName: (node) => factsFor(node, facts).hasPasswordName,
|
|
4306
|
-
assignedName: (node) => assignedNameOf(node)
|
|
4307
|
-
};
|
|
4308
|
-
const visitors = rule.create(ctx);
|
|
4309
|
-
walk(ast, (node) => {
|
|
4310
|
-
visitors[node.type]?.(node);
|
|
4311
|
-
});
|
|
4312
|
-
}
|
|
4313
|
-
findings.push(...scanPromptInjection(filename, source));
|
|
4314
|
-
return findings;
|
|
6492
|
+
function schemaLooksSafe(schema) {
|
|
6493
|
+
if (schema === void 0) return false;
|
|
6494
|
+
const name = schema.type === "Identifier" ? schema.name : schema.type === "MemberExpression" ? propName(schema) : void 0;
|
|
6495
|
+
return name !== void 0 && /^(JSON_SCHEMA|CORE_SCHEMA|FAILSAFE_SCHEMA|SAFE_SCHEMA)$/.test(name);
|
|
4315
6496
|
}
|
|
4316
|
-
function
|
|
4317
|
-
|
|
4318
|
-
|
|
4319
|
-
|
|
4320
|
-
source,
|
|
4321
|
-
report: ({ node, message }) => {
|
|
4322
|
-
const snippet = source.split(/\r?\n/)[(node.loc?.start.line ?? 1) - 1]?.trim().slice(0, 120) ?? "";
|
|
4323
|
-
findings.push({
|
|
4324
|
-
id: makeFindingId(xssRule.meta.id, filename, `${node.loc?.start.line ?? 0}|${snippet}`),
|
|
4325
|
-
ruleId: xssRule.meta.id,
|
|
4326
|
-
module: "sast",
|
|
4327
|
-
title: xssRule.meta.title,
|
|
4328
|
-
severity: xssRule.meta.severity,
|
|
4329
|
-
confidence: "high",
|
|
4330
|
-
cwe: xssRule.meta.cwe,
|
|
4331
|
-
file: filename,
|
|
4332
|
-
line: node.loc?.start.line,
|
|
4333
|
-
column: node.loc?.start.column,
|
|
4334
|
-
snippet,
|
|
4335
|
-
message,
|
|
4336
|
-
remediation: `${xssRule.meta.title}. See ${xssRule.meta.docs}`,
|
|
4337
|
-
references: [xssRule.meta.docs, xssRule.meta.cwe]
|
|
4338
|
-
});
|
|
4339
|
-
},
|
|
4340
|
-
isTainted: () => false,
|
|
4341
|
-
isPathSanitized: () => false,
|
|
4342
|
-
isSanitizedFor: () => false,
|
|
4343
|
-
isRequestObject: () => false,
|
|
4344
|
-
functionHasPasswordName: () => false,
|
|
4345
|
-
assignedName: () => void 0
|
|
4346
|
-
};
|
|
4347
|
-
xssRule.create(ctx);
|
|
4348
|
-
return findings;
|
|
6497
|
+
function firstsOverlap(a, b) {
|
|
6498
|
+
if (a.kind === "none" || b.kind === "none") return false;
|
|
6499
|
+
if (a.kind === "any" || b.kind === "any") return true;
|
|
6500
|
+
return a.c === b.c;
|
|
4349
6501
|
}
|
|
4350
|
-
|
|
4351
|
-
|
|
4352
|
-
|
|
4353
|
-
|
|
4354
|
-
const
|
|
4355
|
-
|
|
4356
|
-
|
|
4357
|
-
|
|
4358
|
-
|
|
4359
|
-
|
|
4360
|
-
|
|
4361
|
-
|
|
4362
|
-
|
|
4363
|
-
|
|
4364
|
-
|
|
4365
|
-
|
|
4366
|
-
|
|
4367
|
-
|
|
4368
|
-
|
|
4369
|
-
}
|
|
4370
|
-
|
|
4371
|
-
const ast = await parseSource(source);
|
|
4372
|
-
parsed.push({ file: rel, source, ast });
|
|
4373
|
-
findings.push(...findingsFromAst(rel, source, ast));
|
|
4374
|
-
} catch {
|
|
6502
|
+
function patternHasRedos(pattern) {
|
|
6503
|
+
if (pattern.length > 2e3) return false;
|
|
6504
|
+
const st = { i: 0 };
|
|
6505
|
+
try {
|
|
6506
|
+
const frag = parseAlts(pattern, st);
|
|
6507
|
+
return frag.nested || frag.overlap;
|
|
6508
|
+
} catch {
|
|
6509
|
+
return false;
|
|
6510
|
+
}
|
|
6511
|
+
}
|
|
6512
|
+
function parseAlts(s, st) {
|
|
6513
|
+
const parts = [parseSeq(s, st)];
|
|
6514
|
+
while (s[st.i] === "|") {
|
|
6515
|
+
st.i += 1;
|
|
6516
|
+
parts.push(parseSeq(s, st));
|
|
6517
|
+
}
|
|
6518
|
+
let altOverlap = false;
|
|
6519
|
+
for (let i = 0; i < parts.length; i += 1) {
|
|
6520
|
+
for (let j = i + 1; j < parts.length; j += 1) {
|
|
6521
|
+
if (firstsOverlap(parts[i]?.first ?? { kind: "none" }, parts[j]?.first ?? { kind: "none" })) {
|
|
6522
|
+
altOverlap = true;
|
|
4375
6523
|
}
|
|
4376
6524
|
}
|
|
4377
|
-
findings.push(...runInterproc(parsed));
|
|
4378
|
-
return findings;
|
|
4379
6525
|
}
|
|
4380
|
-
|
|
4381
|
-
|
|
4382
|
-
|
|
4383
|
-
|
|
4384
|
-
|
|
4385
|
-
|
|
6526
|
+
return {
|
|
6527
|
+
nested: parts.some((p) => p.nested),
|
|
6528
|
+
overlap: parts.some((p) => p.overlap),
|
|
6529
|
+
canRepeatInner: parts.some((p) => p.canRepeatInner),
|
|
6530
|
+
altOverlap: altOverlap || parts.some((p) => p.altOverlap),
|
|
6531
|
+
first: parts[0]?.first ?? { kind: "none" }
|
|
6532
|
+
};
|
|
4386
6533
|
}
|
|
4387
|
-
function
|
|
4388
|
-
|
|
6534
|
+
function parseSeq(s, st) {
|
|
6535
|
+
const parts = [];
|
|
6536
|
+
while (st.i < s.length && s[st.i] !== "|" && s[st.i] !== ")") {
|
|
6537
|
+
parts.push(parseAtom(s, st));
|
|
6538
|
+
}
|
|
6539
|
+
if (parts.length === 0) {
|
|
6540
|
+
return { nested: false, overlap: false, canRepeatInner: false, altOverlap: false, first: { kind: "none" } };
|
|
6541
|
+
}
|
|
6542
|
+
return {
|
|
6543
|
+
nested: parts.some((p) => p.nested),
|
|
6544
|
+
overlap: parts.some((p) => p.overlap),
|
|
6545
|
+
canRepeatInner: parts.some((p) => p.canRepeatInner),
|
|
6546
|
+
altOverlap: false,
|
|
6547
|
+
first: parts.find((p) => p.first.kind !== "none")?.first ?? { kind: "none" }
|
|
6548
|
+
};
|
|
4389
6549
|
}
|
|
4390
|
-
function
|
|
4391
|
-
|
|
6550
|
+
function parseAtom(s, st) {
|
|
6551
|
+
let frag;
|
|
6552
|
+
const c = s[st.i];
|
|
6553
|
+
if (c === void 0) {
|
|
6554
|
+
return { nested: false, overlap: false, canRepeatInner: false, altOverlap: false, first: { kind: "none" } };
|
|
6555
|
+
}
|
|
6556
|
+
if (c === "(") {
|
|
6557
|
+
st.i += 1;
|
|
6558
|
+
if (s.startsWith("?:", st.i) || s.startsWith("?=", st.i) || s.startsWith("?!", st.i)) st.i += 2;
|
|
6559
|
+
frag = parseAlts(s, st);
|
|
6560
|
+
if (s[st.i] === ")") st.i += 1;
|
|
6561
|
+
} else if (c === "[") {
|
|
6562
|
+
st.i += 1;
|
|
6563
|
+
if (s[st.i] === "^") st.i += 1;
|
|
6564
|
+
while (st.i < s.length && s[st.i] !== "]") {
|
|
6565
|
+
if (s[st.i] === "\\") st.i += 2;
|
|
6566
|
+
else st.i += 1;
|
|
6567
|
+
}
|
|
6568
|
+
if (s[st.i] === "]") st.i += 1;
|
|
6569
|
+
frag = { nested: false, overlap: false, canRepeatInner: false, altOverlap: false, first: { kind: "any" } };
|
|
6570
|
+
} else if (c === "\\") {
|
|
6571
|
+
st.i += 1;
|
|
6572
|
+
const esc = s[st.i];
|
|
6573
|
+
st.i += 1;
|
|
6574
|
+
const any = esc !== void 0 && /[dDwWsS]/.test(esc);
|
|
6575
|
+
frag = {
|
|
6576
|
+
nested: false,
|
|
6577
|
+
overlap: false,
|
|
6578
|
+
canRepeatInner: false,
|
|
6579
|
+
altOverlap: false,
|
|
6580
|
+
first: any ? { kind: "any" } : { kind: "char", c: esc ?? "" }
|
|
6581
|
+
};
|
|
6582
|
+
} else if (c === ".") {
|
|
6583
|
+
st.i += 1;
|
|
6584
|
+
frag = { nested: false, overlap: false, canRepeatInner: false, altOverlap: false, first: { kind: "any" } };
|
|
6585
|
+
} else if (c === "^" || c === "$") {
|
|
6586
|
+
st.i += 1;
|
|
6587
|
+
frag = { nested: false, overlap: false, canRepeatInner: false, altOverlap: false, first: { kind: "none" } };
|
|
6588
|
+
} else {
|
|
6589
|
+
st.i += 1;
|
|
6590
|
+
frag = { nested: false, overlap: false, canRepeatInner: false, altOverlap: false, first: { kind: "char", c } };
|
|
6591
|
+
}
|
|
6592
|
+
const q = parseQuantifier(s, st);
|
|
6593
|
+
if (q === void 0) return frag;
|
|
6594
|
+
if (!q.repeating) return frag;
|
|
6595
|
+
return {
|
|
6596
|
+
nested: frag.nested || frag.canRepeatInner,
|
|
6597
|
+
overlap: frag.overlap || frag.altOverlap,
|
|
6598
|
+
canRepeatInner: true,
|
|
6599
|
+
altOverlap: false,
|
|
6600
|
+
first: frag.first
|
|
6601
|
+
};
|
|
4392
6602
|
}
|
|
4393
|
-
function
|
|
4394
|
-
|
|
4395
|
-
|
|
4396
|
-
|
|
4397
|
-
|
|
6603
|
+
function parseQuantifier(s, st) {
|
|
6604
|
+
const c = s[st.i];
|
|
6605
|
+
if (c === "*" || c === "+") {
|
|
6606
|
+
st.i += 1;
|
|
6607
|
+
if (s[st.i] === "?") st.i += 1;
|
|
6608
|
+
return { repeating: true };
|
|
6609
|
+
}
|
|
6610
|
+
if (c === "?") {
|
|
6611
|
+
st.i += 1;
|
|
6612
|
+
return { repeating: false };
|
|
6613
|
+
}
|
|
6614
|
+
if (c !== "{") return void 0;
|
|
6615
|
+
const rest = s.slice(st.i);
|
|
6616
|
+
const m = rest.match(/^\{(\d+)(,(\d+)?)?\}/);
|
|
6617
|
+
if (m === null) return void 0;
|
|
6618
|
+
st.i += m[0].length;
|
|
6619
|
+
if (s[st.i] === "?") st.i += 1;
|
|
6620
|
+
const min = Number(m[1]);
|
|
6621
|
+
const hasComma = m[2] !== void 0;
|
|
6622
|
+
const max = m[3] !== void 0 ? Number(m[3]) : hasComma ? Number.POSITIVE_INFINITY : min;
|
|
6623
|
+
return { repeating: max > 1 };
|
|
6624
|
+
}
|
|
6625
|
+
function regexPatternOf(node) {
|
|
6626
|
+
if (node === void 0) return void 0;
|
|
6627
|
+
if (node.type === "Literal" && "regex" in node && node.regex !== null) {
|
|
6628
|
+
return node.regex.pattern;
|
|
4398
6629
|
}
|
|
4399
|
-
|
|
4400
|
-
|
|
4401
|
-
const p = count / value.length;
|
|
4402
|
-
entropy -= p * Math.log2(p);
|
|
6630
|
+
if ((node.type === "NewExpression" || node.type === "CallExpression") && node.callee.type === "Identifier" && node.callee.name === "RegExp") {
|
|
6631
|
+
return literalString(node.arguments[0]);
|
|
4403
6632
|
}
|
|
4404
|
-
return
|
|
6633
|
+
return void 0;
|
|
4405
6634
|
}
|
|
4406
|
-
|
|
4407
|
-
|
|
4408
|
-
|
|
4409
|
-
|
|
6635
|
+
function secretishName(name) {
|
|
6636
|
+
if (name === void 0) return false;
|
|
6637
|
+
return /secret|token|password|passwd|pwd|hmac|signature|hash|apikey|api_key|credential/i.test(name);
|
|
6638
|
+
}
|
|
6639
|
+
function secretish(node) {
|
|
6640
|
+
if (node.type === "Identifier") return secretishName(node.name);
|
|
6641
|
+
if (node.type === "MemberExpression") return secretishName(propName(node));
|
|
6642
|
+
return false;
|
|
6643
|
+
}
|
|
6644
|
+
function isTimingSafeEqual(node) {
|
|
6645
|
+
return node.type === "CallExpression" && calleeName(node) === "timingSafeEqual";
|
|
6646
|
+
}
|
|
6647
|
+
function cookieFlagsComplete(opts) {
|
|
6648
|
+
if (opts === void 0 || opts.type !== "ObjectExpression") return false;
|
|
6649
|
+
const httpOnly = boolLiteral(objectProp2(opts, "httpOnly")) === true;
|
|
6650
|
+
const secure = boolLiteral(objectProp2(opts, "secure")) === true;
|
|
6651
|
+
const sameSite = objectProp2(opts, "sameSite");
|
|
6652
|
+
const hasSameSite = sameSite !== void 0 && (sameSite.type === "Literal" && typeof sameSite.value === "string" || sameSite.type === "Identifier");
|
|
6653
|
+
return httpOnly && secure && hasSameSite;
|
|
6654
|
+
}
|
|
6655
|
+
var PATH_CALLS = /* @__PURE__ */ new Set([
|
|
6656
|
+
"writeFile",
|
|
6657
|
+
"writeFileSync",
|
|
6658
|
+
"appendFile",
|
|
6659
|
+
"appendFileSync",
|
|
6660
|
+
"createWriteStream",
|
|
6661
|
+
"rename",
|
|
6662
|
+
"copyFile",
|
|
6663
|
+
"join",
|
|
6664
|
+
"resolve",
|
|
6665
|
+
"sendFile",
|
|
6666
|
+
"open",
|
|
6667
|
+
"openSync"
|
|
6668
|
+
]);
|
|
6669
|
+
var redosRule = {
|
|
4410
6670
|
meta: {
|
|
4411
|
-
id: "
|
|
4412
|
-
title: "
|
|
4413
|
-
severity: "
|
|
4414
|
-
|
|
4415
|
-
|
|
6671
|
+
id: "SAST-REDOS-001",
|
|
6672
|
+
title: "Regular expression with nested or overlapping quantifiers on tainted input",
|
|
6673
|
+
severity: "medium",
|
|
6674
|
+
cwe: "CWE-1333",
|
|
6675
|
+
docs: "https://cwe.mitre.org/data/definitions/1333.html"
|
|
4416
6676
|
},
|
|
4417
|
-
|
|
4418
|
-
|
|
4419
|
-
|
|
4420
|
-
|
|
6677
|
+
create(ctx) {
|
|
6678
|
+
const redosNames = /* @__PURE__ */ new Set();
|
|
6679
|
+
return {
|
|
6680
|
+
VariableDeclarator(node) {
|
|
6681
|
+
if (node.type !== "VariableDeclarator" || node.id.type !== "Identifier" || node.init === null) return;
|
|
6682
|
+
const pattern = regexPatternOf(node.init);
|
|
6683
|
+
if (pattern !== void 0 && patternHasRedos(pattern)) redosNames.add(node.id.name);
|
|
6684
|
+
},
|
|
6685
|
+
CallExpression(node) {
|
|
6686
|
+
if (node.type !== "CallExpression") return;
|
|
6687
|
+
const name = calleeName(node);
|
|
6688
|
+
if (name === "test" || name === "exec") {
|
|
6689
|
+
const arg = node.arguments[0];
|
|
6690
|
+
if (arg === void 0 || arg.type === "SpreadElement" || !ctx.isTainted(arg)) return;
|
|
6691
|
+
if (node.callee.type !== "MemberExpression") return;
|
|
6692
|
+
const pattern = regexPatternOf(node.callee.object);
|
|
6693
|
+
const named = node.callee.object.type === "Identifier" && redosNames.has(node.callee.object.name);
|
|
6694
|
+
if (pattern !== void 0 && patternHasRedos(pattern) || named) {
|
|
6695
|
+
ctx.report({ node, message: "Tainted input is matched against a regex with nested or overlapping quantifiers." });
|
|
6696
|
+
}
|
|
6697
|
+
return;
|
|
6698
|
+
}
|
|
6699
|
+
if (name === "match" || name === "search") {
|
|
6700
|
+
if (node.callee.type !== "MemberExpression" || !ctx.isTainted(node.callee.object)) return;
|
|
6701
|
+
const arg = node.arguments[0];
|
|
6702
|
+
if (arg === void 0 || arg.type === "SpreadElement") return;
|
|
6703
|
+
const pattern = regexPatternOf(arg);
|
|
6704
|
+
if (pattern !== void 0 && patternHasRedos(pattern)) {
|
|
6705
|
+
ctx.report({ node, message: "Tainted input is matched against a regex with nested or overlapping quantifiers." });
|
|
6706
|
+
}
|
|
6707
|
+
}
|
|
4421
6708
|
}
|
|
4422
|
-
}
|
|
6709
|
+
};
|
|
4423
6710
|
}
|
|
4424
6711
|
};
|
|
4425
|
-
|
|
4426
|
-
// src/modules/secrets/rules/aws.ts
|
|
4427
|
-
var PATTERN2 = /\bAKIA[0-9A-Z]{16}\b/g;
|
|
4428
|
-
var awsRule = {
|
|
6712
|
+
var deserRule = {
|
|
4429
6713
|
meta: {
|
|
4430
|
-
id: "
|
|
4431
|
-
title: "
|
|
6714
|
+
id: "SAST-DESER-001",
|
|
6715
|
+
title: "Insecure deserialization of untrusted data",
|
|
4432
6716
|
severity: "high",
|
|
4433
|
-
|
|
4434
|
-
|
|
6717
|
+
cwe: "CWE-502",
|
|
6718
|
+
docs: "https://cwe.mitre.org/data/definitions/502.html"
|
|
4435
6719
|
},
|
|
4436
|
-
|
|
4437
|
-
|
|
4438
|
-
|
|
4439
|
-
|
|
6720
|
+
create(ctx) {
|
|
6721
|
+
return {
|
|
6722
|
+
CallExpression(node) {
|
|
6723
|
+
if (node.type !== "CallExpression") return;
|
|
6724
|
+
const name = calleeName(node);
|
|
6725
|
+
const arg0 = node.arguments[0];
|
|
6726
|
+
const taintedFirst = arg0 !== void 0 && arg0.type !== "SpreadElement" && ctx.isTainted(arg0);
|
|
6727
|
+
if ((name === "unserialize" || name === "deserialize" || name === "deepDeserialize" || name === "unsafeLoad") && taintedFirst) {
|
|
6728
|
+
ctx.report({ node, message: "Tainted input is deserialized with an unsafe API (node-serialize, funcster, or yaml.unsafeLoad)." });
|
|
6729
|
+
return;
|
|
6730
|
+
}
|
|
6731
|
+
if (name === "load" && taintedFirst) {
|
|
6732
|
+
const opts = node.arguments[1];
|
|
6733
|
+
const schema = opts !== void 0 && opts.type !== "SpreadElement" ? objectProp2(opts, "schema") : void 0;
|
|
6734
|
+
if (!schemaLooksSafe(schema)) {
|
|
6735
|
+
ctx.report({ node, message: "js-yaml load() of tainted input uses an unsafe schema or default types." });
|
|
6736
|
+
}
|
|
6737
|
+
return;
|
|
6738
|
+
}
|
|
6739
|
+
if (name === "serialize") {
|
|
6740
|
+
const opts = node.arguments[1];
|
|
6741
|
+
const unsafe = opts !== void 0 && opts.type !== "SpreadElement" && boolLiteral(objectProp2(opts, "unsafe")) === true;
|
|
6742
|
+
if (unsafe) {
|
|
6743
|
+
ctx.report({ node, message: "serialize-javascript is called with unsafe: true (eval path)." });
|
|
6744
|
+
}
|
|
6745
|
+
return;
|
|
6746
|
+
}
|
|
6747
|
+
if (name === "eval" || name === "runInNewContext") {
|
|
6748
|
+
const arg = node.arguments[0];
|
|
6749
|
+
if (arg !== void 0 && arg.type !== "SpreadElement" && containsCallNamed(arg, "serialize")) {
|
|
6750
|
+
ctx.report({ node, message: "serialize-javascript output is passed to eval / vm (insecure deserialize)." });
|
|
6751
|
+
}
|
|
6752
|
+
}
|
|
4440
6753
|
}
|
|
4441
|
-
}
|
|
6754
|
+
};
|
|
4442
6755
|
}
|
|
4443
6756
|
};
|
|
4444
|
-
|
|
4445
|
-
// src/modules/secrets/rules/dbUrl.ts
|
|
4446
|
-
var PATTERN3 = /\b(?:postgres(?:ql)?|mysql|mariadb|mongodb(?:\+srv)?|redis|amqp):\/\/([^\s'"@/:]+):([^\s'"@]+)@/g;
|
|
4447
|
-
function isPlaceholderPassword(password) {
|
|
4448
|
-
if (password.includes("${")) return true;
|
|
4449
|
-
if (/^<[^>]+>$/.test(password)) return true;
|
|
4450
|
-
if (/^\{[^}]+\}$/.test(password)) return true;
|
|
4451
|
-
return false;
|
|
4452
|
-
}
|
|
4453
|
-
var dbUrlRule = {
|
|
6757
|
+
var xxeRule = {
|
|
4454
6758
|
meta: {
|
|
4455
|
-
id: "
|
|
4456
|
-
title: "
|
|
6759
|
+
id: "SAST-XXE-001",
|
|
6760
|
+
title: "XML parser with external entities enabled on tainted input",
|
|
4457
6761
|
severity: "high",
|
|
4458
|
-
|
|
4459
|
-
|
|
6762
|
+
cwe: "CWE-611",
|
|
6763
|
+
docs: "https://cwe.mitre.org/data/definitions/611.html"
|
|
4460
6764
|
},
|
|
4461
|
-
|
|
4462
|
-
ctx.
|
|
4463
|
-
|
|
4464
|
-
|
|
4465
|
-
if (
|
|
4466
|
-
|
|
6765
|
+
create(ctx) {
|
|
6766
|
+
const customEntities = /ENTITIES/.test(ctx.source);
|
|
6767
|
+
return {
|
|
6768
|
+
CallExpression(node) {
|
|
6769
|
+
if (node.type !== "CallExpression") return;
|
|
6770
|
+
const name = calleeName(node);
|
|
6771
|
+
const arg0 = node.arguments[0];
|
|
6772
|
+
const taintedFirst = arg0 !== void 0 && arg0.type !== "SpreadElement" && ctx.isTainted(arg0);
|
|
6773
|
+
if (!taintedFirst) return;
|
|
6774
|
+
if (name === "parseXml" || name === "parseXmlString") {
|
|
6775
|
+
const opts = node.arguments[1];
|
|
6776
|
+
if (opts !== void 0 && opts.type !== "SpreadElement" && boolLiteral(objectProp2(opts, "noent")) === true) {
|
|
6777
|
+
ctx.report({ node, message: "libxmljs parses tainted XML with noent: true (external entities)." });
|
|
6778
|
+
}
|
|
6779
|
+
return;
|
|
6780
|
+
}
|
|
6781
|
+
if (name === "parseString" || name === "Parser") {
|
|
6782
|
+
const opts = node.arguments[1];
|
|
6783
|
+
const customOpt = opts !== void 0 && opts.type !== "SpreadElement" && (objectProp2(opts, "xmlns") !== void 0 || objectProp2(opts, "entities") !== void 0);
|
|
6784
|
+
if (customEntities || customOpt) {
|
|
6785
|
+
ctx.report({ node, message: "xml2js parses tainted XML with custom entity handling." });
|
|
6786
|
+
}
|
|
6787
|
+
}
|
|
4467
6788
|
}
|
|
4468
|
-
}
|
|
6789
|
+
};
|
|
4469
6790
|
}
|
|
4470
6791
|
};
|
|
4471
|
-
|
|
4472
|
-
// src/modules/secrets/rules/entropy.ts
|
|
4473
|
-
var ASSIGNMENT = /\b([A-Za-z_$][\w$]*)\s*[:=]\s*["'`]([^"'`]{20,})["'`]/g;
|
|
4474
|
-
var SECRET_NAME = /(key|secret|token|passw(or)?d|pwd|credential)/i;
|
|
4475
|
-
var ENTROPY_THRESHOLD = 4.5;
|
|
4476
|
-
var entropyRule = {
|
|
6792
|
+
var uploadRule = {
|
|
4477
6793
|
meta: {
|
|
4478
|
-
id: "
|
|
4479
|
-
title: "
|
|
4480
|
-
severity: "
|
|
4481
|
-
|
|
4482
|
-
|
|
6794
|
+
id: "SAST-UPLOAD-001",
|
|
6795
|
+
title: "Unrestricted file upload or originalname used as a path",
|
|
6796
|
+
severity: "high",
|
|
6797
|
+
cwe: "CWE-434",
|
|
6798
|
+
docs: "https://cwe.mitre.org/data/definitions/434.html"
|
|
4483
6799
|
},
|
|
4484
|
-
|
|
4485
|
-
|
|
4486
|
-
|
|
4487
|
-
|
|
4488
|
-
|
|
4489
|
-
if (
|
|
4490
|
-
|
|
4491
|
-
|
|
4492
|
-
|
|
6800
|
+
create(ctx) {
|
|
6801
|
+
return {
|
|
6802
|
+
CallExpression(node) {
|
|
6803
|
+
if (node.type !== "CallExpression") return;
|
|
6804
|
+
const name = calleeName(node);
|
|
6805
|
+
if (name === "multer" || name === "busboy" || name === "formidable" || name === "IncomingForm") {
|
|
6806
|
+
const opts = node.arguments[0];
|
|
6807
|
+
const obj = opts !== void 0 && opts.type !== "SpreadElement" ? opts : void 0;
|
|
6808
|
+
const hasSize = objectProp2(obj, "limits") !== void 0 || objectProp2(obj, "maxFileSize") !== void 0 || objectProp2(obj, "fileSize") !== void 0;
|
|
6809
|
+
const hasType = objectProp2(obj, "fileFilter") !== void 0 || objectProp2(obj, "filter") !== void 0;
|
|
6810
|
+
if (!hasSize || !hasType) {
|
|
6811
|
+
ctx.report({ node, message: `${name} is configured without a file size limit or type filter.` });
|
|
6812
|
+
}
|
|
6813
|
+
return;
|
|
6814
|
+
}
|
|
6815
|
+
if (name !== void 0 && PATH_CALLS.has(name)) {
|
|
6816
|
+
const usesName = node.arguments.some(
|
|
6817
|
+
(arg) => arg.type !== "SpreadElement" && containsOriginalName(arg)
|
|
6818
|
+
);
|
|
6819
|
+
if (usesName) {
|
|
6820
|
+
ctx.report({ node, message: "Uploaded originalname is used in a filesystem path." });
|
|
6821
|
+
}
|
|
6822
|
+
}
|
|
6823
|
+
},
|
|
6824
|
+
NewExpression(node) {
|
|
6825
|
+
if (node.type !== "NewExpression") return;
|
|
6826
|
+
const name = calleeName(node);
|
|
6827
|
+
if (name !== "IncomingForm" && name !== "Formidable") return;
|
|
6828
|
+
const opts = node.arguments[0];
|
|
6829
|
+
const obj = opts !== void 0 && opts.type !== "SpreadElement" ? opts : void 0;
|
|
6830
|
+
if (objectProp2(obj, "maxFileSize") === void 0 || objectProp2(obj, "filter") === void 0) {
|
|
6831
|
+
ctx.report({ node, message: `${name} is configured without a file size limit or type filter.` });
|
|
6832
|
+
}
|
|
4493
6833
|
}
|
|
4494
|
-
}
|
|
6834
|
+
};
|
|
4495
6835
|
}
|
|
4496
6836
|
};
|
|
4497
|
-
|
|
4498
|
-
|
|
4499
|
-
|
|
4500
|
-
|
|
6837
|
+
var CSRF_GUARD = /csrf-csrf|csrf-sync|csrfProtection|doubleCsrf|@fastify\/csrf-protection|\blusca\b|\bcsurf\b/;
|
|
6838
|
+
function sourceHasCsrfGuard(source) {
|
|
6839
|
+
return CSRF_GUARD.test(source);
|
|
6840
|
+
}
|
|
6841
|
+
var cookieRule = {
|
|
4501
6842
|
meta: {
|
|
4502
|
-
id: "
|
|
4503
|
-
title: "
|
|
4504
|
-
severity: "
|
|
4505
|
-
|
|
4506
|
-
|
|
6843
|
+
id: "SAST-COOKIE-001",
|
|
6844
|
+
title: "Cookie missing httpOnly, secure, or sameSite",
|
|
6845
|
+
severity: "medium",
|
|
6846
|
+
cwe: "CWE-614",
|
|
6847
|
+
docs: "https://cwe.mitre.org/data/definitions/614.html"
|
|
4507
6848
|
},
|
|
4508
|
-
|
|
4509
|
-
|
|
4510
|
-
|
|
4511
|
-
|
|
4512
|
-
|
|
4513
|
-
|
|
4514
|
-
|
|
4515
|
-
|
|
4516
|
-
|
|
6849
|
+
create(ctx) {
|
|
6850
|
+
return {
|
|
6851
|
+
CallExpression(node) {
|
|
6852
|
+
if (node.type !== "CallExpression") return;
|
|
6853
|
+
const name = calleeName(node);
|
|
6854
|
+
if (name === "cookie") {
|
|
6855
|
+
const opts = node.arguments[2];
|
|
6856
|
+
const optNode = opts !== void 0 && opts.type !== "SpreadElement" ? opts : void 0;
|
|
6857
|
+
if (!cookieFlagsComplete(optNode)) {
|
|
6858
|
+
ctx.report({ node, message: "Cookie is set without httpOnly, secure, and sameSite." });
|
|
6859
|
+
}
|
|
6860
|
+
return;
|
|
6861
|
+
}
|
|
6862
|
+
if (name === "session") {
|
|
6863
|
+
const opts = node.arguments[0];
|
|
6864
|
+
const optNode = opts !== void 0 && opts.type !== "SpreadElement" ? opts : void 0;
|
|
6865
|
+
const secret = objectProp2(optNode, "secret");
|
|
6866
|
+
const cookie = objectProp2(optNode, "cookie");
|
|
6867
|
+
if (literalString(secret) !== void 0) return;
|
|
6868
|
+
if (!cookieFlagsComplete(cookie)) {
|
|
6869
|
+
ctx.report({
|
|
6870
|
+
node,
|
|
6871
|
+
message: "express-session cookie is missing httpOnly, secure, and sameSite."
|
|
6872
|
+
});
|
|
6873
|
+
}
|
|
6874
|
+
}
|
|
6875
|
+
}
|
|
6876
|
+
};
|
|
4517
6877
|
}
|
|
4518
6878
|
};
|
|
4519
|
-
|
|
4520
|
-
// src/modules/secrets/rules/github.ts
|
|
4521
|
-
var PATTERN4 = /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36}\b/g;
|
|
4522
|
-
var githubRule = {
|
|
6879
|
+
var csrfRule = {
|
|
4523
6880
|
meta: {
|
|
4524
|
-
id: "
|
|
4525
|
-
title: "
|
|
6881
|
+
id: "SAST-CSRF-001",
|
|
6882
|
+
title: "Cookie-authenticated state-changing route without CSRF protection",
|
|
4526
6883
|
severity: "high",
|
|
4527
|
-
|
|
4528
|
-
|
|
6884
|
+
cwe: "CWE-352",
|
|
6885
|
+
docs: "https://cwe.mitre.org/data/definitions/352.html"
|
|
4529
6886
|
},
|
|
4530
|
-
|
|
4531
|
-
ctx.
|
|
4532
|
-
|
|
4533
|
-
|
|
6887
|
+
create(ctx) {
|
|
6888
|
+
const cookieAuth = /express-session|cookie-session|cookieParser|req\.cookies|\.cookie\s*\(/.test(ctx.source);
|
|
6889
|
+
const csrfProtect = ctx.projectCsrfProtected === true || sourceHasCsrfGuard(ctx.source);
|
|
6890
|
+
const sameSiteOk = /sameSite\s*:\s*['"](strict|lax)['"]/.test(ctx.source);
|
|
6891
|
+
return {
|
|
6892
|
+
CallExpression(node) {
|
|
6893
|
+
if (node.type !== "CallExpression" || !cookieAuth || csrfProtect || sameSiteOk) return;
|
|
6894
|
+
if (node.callee.type !== "MemberExpression") return;
|
|
6895
|
+
const name = calleeName(node);
|
|
6896
|
+
if (name === void 0 || !/^(post|put|patch|delete)$/.test(name)) return;
|
|
6897
|
+
const obj = node.callee.object;
|
|
6898
|
+
if (obj.type !== "Identifier" || !/^(app|router)$/i.test(obj.name)) return;
|
|
6899
|
+
ctx.report({
|
|
6900
|
+
node,
|
|
6901
|
+
message: "Cookie-authenticated POST/PUT/PATCH/DELETE has no CSRF protection and sameSite is not strict/lax."
|
|
6902
|
+
});
|
|
4534
6903
|
}
|
|
4535
|
-
}
|
|
6904
|
+
};
|
|
4536
6905
|
}
|
|
4537
6906
|
};
|
|
4538
|
-
|
|
4539
|
-
// src/modules/secrets/rules/google.ts
|
|
4540
|
-
var PATTERN5 = /\bAIza[0-9A-Za-z_-]{35}\b/g;
|
|
4541
|
-
var googleRule = {
|
|
6907
|
+
var massAssignRule = {
|
|
4542
6908
|
meta: {
|
|
4543
|
-
id: "
|
|
4544
|
-
title: "
|
|
6909
|
+
id: "SAST-MASSASSIGN-001",
|
|
6910
|
+
title: "Mass assignment of req.body without an allowlist or schema",
|
|
4545
6911
|
severity: "high",
|
|
4546
|
-
|
|
4547
|
-
|
|
6912
|
+
cwe: "CWE-915",
|
|
6913
|
+
docs: "https://cwe.mitre.org/data/definitions/915.html"
|
|
4548
6914
|
},
|
|
4549
|
-
|
|
4550
|
-
|
|
4551
|
-
|
|
4552
|
-
|
|
6915
|
+
create(ctx) {
|
|
6916
|
+
return {
|
|
6917
|
+
CallExpression(node) {
|
|
6918
|
+
if (node.type !== "CallExpression") return;
|
|
6919
|
+
const name = calleeName(node);
|
|
6920
|
+
if (name === "assign") {
|
|
6921
|
+
const src = node.arguments[1];
|
|
6922
|
+
if (src !== void 0 && src.type !== "SpreadElement" && ctx.isRequestObject(src)) {
|
|
6923
|
+
ctx.report({ node, message: "Object.assign copies req.body onto an object without an allowlist." });
|
|
6924
|
+
}
|
|
6925
|
+
return;
|
|
6926
|
+
}
|
|
6927
|
+
if (name !== "create" && name !== "update" && name !== "upsert") return;
|
|
6928
|
+
const body = node.arguments.find((arg) => arg.type !== "SpreadElement" && ctx.isRequestObject(arg));
|
|
6929
|
+
if (body === void 0) return;
|
|
6930
|
+
const opts = node.arguments[1];
|
|
6931
|
+
if (opts !== void 0 && opts.type !== "SpreadElement" && (objectProp2(opts, "fields") !== void 0 || objectProp2(opts, "whitelist") !== void 0)) {
|
|
6932
|
+
return;
|
|
6933
|
+
}
|
|
6934
|
+
ctx.report({ node, message: `Model.${name}(req.body) assigns untrusted fields without an allowlist or schema.` });
|
|
4553
6935
|
}
|
|
4554
|
-
}
|
|
6936
|
+
};
|
|
4555
6937
|
}
|
|
4556
6938
|
};
|
|
4557
|
-
|
|
4558
|
-
// src/modules/secrets/rules/jwtSecret.ts
|
|
4559
|
-
var PATTERN6 = /\b(?:jwt[_-]?secret|jwt[_-]?key|token[_-]?secret|signing[_-]?key)\b\s*[:=]\s*["'`]([^"'`]{8,})["'`]/gi;
|
|
4560
|
-
var jwtSecretRule = {
|
|
6939
|
+
var timingRule = {
|
|
4561
6940
|
meta: {
|
|
4562
|
-
id: "
|
|
4563
|
-
title: "
|
|
6941
|
+
id: "SAST-TIMING-001",
|
|
6942
|
+
title: "Secret compared with === instead of timingSafeEqual",
|
|
4564
6943
|
severity: "medium",
|
|
4565
|
-
|
|
4566
|
-
|
|
6944
|
+
cwe: "CWE-208",
|
|
6945
|
+
docs: "https://cwe.mitre.org/data/definitions/208.html"
|
|
4567
6946
|
},
|
|
4568
|
-
|
|
4569
|
-
|
|
4570
|
-
|
|
4571
|
-
|
|
4572
|
-
if (
|
|
4573
|
-
|
|
4574
|
-
|
|
6947
|
+
create(ctx) {
|
|
6948
|
+
return {
|
|
6949
|
+
BinaryExpression(node) {
|
|
6950
|
+
if (node.type !== "BinaryExpression") return;
|
|
6951
|
+
if (node.operator !== "===" && node.operator !== "==") return;
|
|
6952
|
+
if (isTimingSafeEqual(node.left) || isTimingSafeEqual(node.right)) return;
|
|
6953
|
+
if (!secretish(node.left) && !secretish(node.right)) return;
|
|
6954
|
+
ctx.report({
|
|
6955
|
+
node,
|
|
6956
|
+
message: "A secret/token/HMAC is compared with ===/== instead of crypto.timingSafeEqual."
|
|
6957
|
+
});
|
|
4575
6958
|
}
|
|
4576
|
-
}
|
|
6959
|
+
};
|
|
4577
6960
|
}
|
|
4578
6961
|
};
|
|
4579
|
-
|
|
4580
|
-
|
|
4581
|
-
|
|
4582
|
-
|
|
6962
|
+
function argon2BelowOwasp(opts) {
|
|
6963
|
+
if (opts === void 0 || opts.type !== "ObjectExpression") return false;
|
|
6964
|
+
const memory = numberLiteral(objectProp2(opts, "memoryCost"));
|
|
6965
|
+
const time = numberLiteral(objectProp2(opts, "timeCost"));
|
|
6966
|
+
if (memory !== void 0 && memory < 12288) return true;
|
|
6967
|
+
if (time !== void 0 && time < 2) return true;
|
|
6968
|
+
if (memory !== void 0 && time !== void 0 && memory < 19456 && time < 3) return true;
|
|
6969
|
+
return false;
|
|
6970
|
+
}
|
|
6971
|
+
function pbkdf2BelowOwasp(iterations, digest) {
|
|
6972
|
+
const algo = (digest ?? "sha256").toLowerCase();
|
|
6973
|
+
if (algo === "sha512") return iterations < 21e4;
|
|
6974
|
+
if (algo === "sha1") return iterations < 13e5;
|
|
6975
|
+
return iterations < 6e5;
|
|
6976
|
+
}
|
|
6977
|
+
var hashRule = {
|
|
4583
6978
|
meta: {
|
|
4584
|
-
id: "
|
|
4585
|
-
title: "
|
|
4586
|
-
severity: "
|
|
4587
|
-
|
|
4588
|
-
|
|
6979
|
+
id: "SAST-HASH-001",
|
|
6980
|
+
title: "Password hash below OWASP minimum cost",
|
|
6981
|
+
severity: "medium",
|
|
6982
|
+
cwe: "CWE-916",
|
|
6983
|
+
docs: "https://cwe.mitre.org/data/definitions/916.html"
|
|
4589
6984
|
},
|
|
4590
|
-
|
|
4591
|
-
|
|
4592
|
-
|
|
4593
|
-
|
|
4594
|
-
|
|
4595
|
-
|
|
4596
|
-
|
|
6985
|
+
create(ctx) {
|
|
6986
|
+
return {
|
|
6987
|
+
CallExpression(node) {
|
|
6988
|
+
if (node.type !== "CallExpression") return;
|
|
6989
|
+
const name = calleeName(node);
|
|
6990
|
+
if (name === "hash" || name === "hashSync" || name === "genSalt" || name === "genSaltSync") {
|
|
6991
|
+
const costArg = name === "genSalt" || name === "genSaltSync" ? node.arguments[0] : node.arguments[1];
|
|
6992
|
+
const cost = numberLiteral(costArg);
|
|
6993
|
+
if (cost !== void 0 && cost < 10) {
|
|
6994
|
+
ctx.report({ node, message: `bcrypt cost ${cost} is below the minimum of 10.` });
|
|
6995
|
+
return;
|
|
6996
|
+
}
|
|
6997
|
+
if (name === "hash" || name === "hashSync") {
|
|
6998
|
+
const opts = node.arguments[1];
|
|
6999
|
+
if (opts !== void 0 && opts.type !== "SpreadElement" && argon2BelowOwasp(opts)) {
|
|
7000
|
+
ctx.report({ node, message: "argon2 options are below OWASP minimum memory/time cost." });
|
|
7001
|
+
}
|
|
7002
|
+
}
|
|
7003
|
+
return;
|
|
7004
|
+
}
|
|
7005
|
+
if (name === "pbkdf2" || name === "pbkdf2Sync") {
|
|
7006
|
+
const iterations = numberLiteral(node.arguments[2]);
|
|
7007
|
+
const digest = literalString(node.arguments[4]);
|
|
7008
|
+
if (iterations !== void 0 && pbkdf2BelowOwasp(iterations, digest)) {
|
|
7009
|
+
ctx.report({ node, message: `pbkdf2 iterations ${iterations} are below the OWASP minimum for ${digest ?? "sha256"}.` });
|
|
7010
|
+
}
|
|
7011
|
+
}
|
|
4597
7012
|
}
|
|
4598
|
-
}
|
|
7013
|
+
};
|
|
4599
7014
|
}
|
|
4600
7015
|
};
|
|
4601
|
-
|
|
4602
|
-
|
|
4603
|
-
var PATTERN8 = /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g;
|
|
4604
|
-
var slackRule = {
|
|
7016
|
+
var GQL_SERVERS = /* @__PURE__ */ new Set(["ApolloServer", "GraphQLServer", "YogaServer", "graphqlHTTP"]);
|
|
7017
|
+
var gqlRule = {
|
|
4605
7018
|
meta: {
|
|
4606
|
-
id: "
|
|
4607
|
-
title: "
|
|
4608
|
-
severity: "
|
|
4609
|
-
|
|
4610
|
-
|
|
7019
|
+
id: "SAST-GQL-001",
|
|
7020
|
+
title: "GraphQL introspection, unbounded depth, or tainted resolver args",
|
|
7021
|
+
severity: "medium",
|
|
7022
|
+
cwe: "CWE-770",
|
|
7023
|
+
docs: "https://cwe.mitre.org/data/definitions/770.html"
|
|
4611
7024
|
},
|
|
4612
|
-
|
|
4613
|
-
ctx.
|
|
4614
|
-
|
|
4615
|
-
|
|
7025
|
+
create(ctx) {
|
|
7026
|
+
const mentionsProduction = /NODE_ENV/.test(ctx.source) && /production/.test(ctx.source);
|
|
7027
|
+
return {
|
|
7028
|
+
NewExpression(node) {
|
|
7029
|
+
if (node.type !== "NewExpression") return;
|
|
7030
|
+
const name = calleeName(node);
|
|
7031
|
+
if (name === void 0 || !GQL_SERVERS.has(name)) return;
|
|
7032
|
+
reportGqlServer(ctx, node, node.arguments[0], mentionsProduction);
|
|
7033
|
+
},
|
|
7034
|
+
CallExpression(node) {
|
|
7035
|
+
if (node.type !== "CallExpression") return;
|
|
7036
|
+
const name = calleeName(node);
|
|
7037
|
+
if (name !== void 0 && GQL_SERVERS.has(name)) {
|
|
7038
|
+
reportGqlServer(ctx, node, node.arguments[0], mentionsProduction);
|
|
7039
|
+
return;
|
|
7040
|
+
}
|
|
7041
|
+
if (name !== "query" && name !== "execute" && name !== "exec" && name !== "eval") return;
|
|
7042
|
+
const sql = node.arguments[0];
|
|
7043
|
+
if (sql === void 0 || sql.type === "SpreadElement") return;
|
|
7044
|
+
if (!containsIdentMember(sql, "args")) return;
|
|
7045
|
+
ctx.report({
|
|
7046
|
+
node,
|
|
7047
|
+
message: "GraphQL resolver args reach a sink without validation (tainted resolver argument)."
|
|
7048
|
+
});
|
|
4616
7049
|
}
|
|
4617
|
-
}
|
|
7050
|
+
};
|
|
4618
7051
|
}
|
|
4619
7052
|
};
|
|
7053
|
+
function reportGqlServer(ctx, node, optsArg, mentionsProduction) {
|
|
7054
|
+
const opts = optsArg !== void 0 && optsArg.type !== "SpreadElement" ? optsArg : void 0;
|
|
7055
|
+
const introspection = objectProp2(opts, "introspection");
|
|
7056
|
+
const rules = objectProp2(opts, "validationRules");
|
|
7057
|
+
const hasDepth = rules !== void 0 && containsCallNamed(rules, "depthLimit") || objectProp2(opts, "depthLimit") !== void 0 || /depthLimit|createComplexity|queryComplexity|costAnalysis/.test(ctx.source);
|
|
7058
|
+
const introOn = boolLiteral(introspection) === true;
|
|
7059
|
+
if (introOn && mentionsProduction) {
|
|
7060
|
+
ctx.report({ node, message: "GraphQL introspection is enabled in production." });
|
|
7061
|
+
}
|
|
7062
|
+
if (!hasDepth) {
|
|
7063
|
+
ctx.report({ node, message: "GraphQL server has no depth or complexity limit." });
|
|
7064
|
+
}
|
|
7065
|
+
}
|
|
7066
|
+
var phase29Rules = [
|
|
7067
|
+
gated(redosRule),
|
|
7068
|
+
gated(deserRule),
|
|
7069
|
+
gated(xxeRule),
|
|
7070
|
+
gated(uploadRule),
|
|
7071
|
+
gated(cookieRule),
|
|
7072
|
+
gated(csrfRule),
|
|
7073
|
+
gated(massAssignRule),
|
|
7074
|
+
gated(timingRule),
|
|
7075
|
+
gated(hashRule),
|
|
7076
|
+
gated(gqlRule)
|
|
7077
|
+
];
|
|
4620
7078
|
|
|
4621
|
-
// src/
|
|
4622
|
-
|
|
4623
|
-
var
|
|
4624
|
-
|
|
4625
|
-
|
|
4626
|
-
|
|
4627
|
-
|
|
4628
|
-
|
|
4629
|
-
|
|
4630
|
-
|
|
4631
|
-
|
|
4632
|
-
|
|
4633
|
-
|
|
4634
|
-
|
|
4635
|
-
}
|
|
7079
|
+
// src/ai/injection.ts
|
|
7080
|
+
init_finding();
|
|
7081
|
+
var PHRASES = [/ignore previous/i, /mark as safe/i, /ai reviewer/i];
|
|
7082
|
+
function isCommentLine(line) {
|
|
7083
|
+
const trimmed = line.trim();
|
|
7084
|
+
return trimmed.startsWith("//") || trimmed.startsWith("/*") || trimmed.startsWith("*");
|
|
7085
|
+
}
|
|
7086
|
+
function scanPromptInjection(file, source) {
|
|
7087
|
+
const findings = [];
|
|
7088
|
+
const lines = source.split(/\r?\n/);
|
|
7089
|
+
lines.forEach((line, index) => {
|
|
7090
|
+
if (!isCommentLine(line)) return;
|
|
7091
|
+
if (!PHRASES.some((p) => p.test(line))) return;
|
|
7092
|
+
findings.push({
|
|
7093
|
+
id: makeFindingId("AI-INJECT-001", file, `${index + 1}|${line.trim()}`),
|
|
7094
|
+
ruleId: "AI-INJECT-001",
|
|
7095
|
+
module: "sast",
|
|
7096
|
+
title: "Prompt-injection comment aimed at AI reviewers",
|
|
7097
|
+
severity: "medium",
|
|
7098
|
+
confidence: "high",
|
|
7099
|
+
file,
|
|
7100
|
+
line: index + 1,
|
|
7101
|
+
snippet: line.trim().slice(0, 120),
|
|
7102
|
+
message: "Code contains text attempting to manipulate AI reviewers.",
|
|
7103
|
+
remediation: "Remove comments that try to instruct an AI reviewer.",
|
|
7104
|
+
references: ["AI-INJECT-001"],
|
|
7105
|
+
source: "deterministic"
|
|
4636
7106
|
});
|
|
4637
|
-
}
|
|
4638
|
-
|
|
7107
|
+
});
|
|
7108
|
+
return findings;
|
|
7109
|
+
}
|
|
4639
7110
|
|
|
4640
|
-
// src/modules/
|
|
4641
|
-
var
|
|
4642
|
-
|
|
4643
|
-
|
|
4644
|
-
|
|
4645
|
-
|
|
4646
|
-
|
|
4647
|
-
|
|
4648
|
-
|
|
4649
|
-
|
|
4650
|
-
|
|
4651
|
-
|
|
4652
|
-
|
|
7111
|
+
// src/modules/sast/engine.ts
|
|
7112
|
+
var sastRules = [
|
|
7113
|
+
sqliRule,
|
|
7114
|
+
cmdiRule,
|
|
7115
|
+
pathRule,
|
|
7116
|
+
evalRule,
|
|
7117
|
+
jwtRule,
|
|
7118
|
+
cryptoRule,
|
|
7119
|
+
nosqliRule,
|
|
7120
|
+
ssrfRule,
|
|
7121
|
+
redirectRule,
|
|
7122
|
+
protoRule,
|
|
7123
|
+
xssRule,
|
|
7124
|
+
authRule,
|
|
7125
|
+
...phase29Rules
|
|
4653
7126
|
];
|
|
4654
|
-
var
|
|
4655
|
-
function
|
|
4656
|
-
const
|
|
4657
|
-
|
|
7127
|
+
var SOURCE_EXT = /\.[cm]?[jt]sx?$/;
|
|
7128
|
+
async function parseSource(code) {
|
|
7129
|
+
const { parse } = await import('@typescript-eslint/typescript-estree');
|
|
7130
|
+
return parse(code, {
|
|
7131
|
+
loc: true,
|
|
7132
|
+
range: true,
|
|
7133
|
+
comment: false,
|
|
7134
|
+
jsx: true,
|
|
7135
|
+
errorOnUnknownASTType: false,
|
|
7136
|
+
errorOnTypeScriptSyntacticAndSemanticIssues: false
|
|
7137
|
+
});
|
|
7138
|
+
}
|
|
7139
|
+
function snippetOf(source, node) {
|
|
7140
|
+
if (node.range === void 0) return "";
|
|
7141
|
+
return source.slice(node.range[0], node.range[1]).replace(/\s+/g, " ").trim().slice(0, 120);
|
|
7142
|
+
}
|
|
7143
|
+
function findingsFromAst(filename, source, ast, extraReturners = /* @__PURE__ */ new Set(), projectCsrfProtected = false) {
|
|
7144
|
+
const facts = analyzeFile(ast, extraReturners);
|
|
4658
7145
|
const findings = [];
|
|
4659
|
-
for (const rule of
|
|
4660
|
-
|
|
4661
|
-
|
|
4662
|
-
|
|
4663
|
-
|
|
4664
|
-
|
|
4665
|
-
const masked = secret !== void 0 ? maskSecret(secret) : void 0;
|
|
7146
|
+
for (const rule of sastRules) {
|
|
7147
|
+
const ctx = {
|
|
7148
|
+
filename,
|
|
7149
|
+
source,
|
|
7150
|
+
report: ({ node, message, confidence }) => {
|
|
7151
|
+
const snippet = snippetOf(source, node);
|
|
4666
7152
|
findings.push({
|
|
4667
|
-
id: makeFindingId(rule.meta.id,
|
|
7153
|
+
id: makeFindingId(rule.meta.id, filename, `${node.loc?.start.line ?? 0}|${snippet}`),
|
|
4668
7154
|
ruleId: rule.meta.id,
|
|
4669
|
-
module: "
|
|
7155
|
+
module: "sast",
|
|
4670
7156
|
title: rule.meta.title,
|
|
4671
7157
|
severity: rule.meta.severity,
|
|
4672
|
-
confidence:
|
|
7158
|
+
confidence: confidence ?? "high",
|
|
4673
7159
|
cwe: rule.meta.cwe,
|
|
4674
|
-
file:
|
|
4675
|
-
line,
|
|
4676
|
-
|
|
4677
|
-
|
|
4678
|
-
|
|
7160
|
+
file: filename,
|
|
7161
|
+
line: node.loc?.start.line,
|
|
7162
|
+
column: node.loc?.start.column,
|
|
7163
|
+
snippet,
|
|
7164
|
+
message,
|
|
7165
|
+
remediation: `${rule.meta.title}. See ${rule.meta.docs}`,
|
|
7166
|
+
references: [rule.meta.docs, rule.meta.cwe]
|
|
4679
7167
|
});
|
|
4680
|
-
}
|
|
7168
|
+
},
|
|
7169
|
+
isTainted: (node) => nodeIsTainted(node, facts),
|
|
7170
|
+
isPathSanitized: (node) => nodeIsPathSanitized(node, facts),
|
|
7171
|
+
isSanitizedFor: (node, kind) => nodeIsSanitizedFor(node, facts, kind),
|
|
7172
|
+
isNosqlSanitized: (node) => nodeIsNosqlSanitized(node, facts),
|
|
7173
|
+
isRequestObject: (node) => nodeIsRequestObject(node, facts),
|
|
7174
|
+
functionHasPasswordName: (node) => factsFor(node, facts).hasPasswordName,
|
|
7175
|
+
assignedName: (node) => assignedNameOf(node),
|
|
7176
|
+
projectCsrfProtected
|
|
7177
|
+
};
|
|
7178
|
+
const visitors = rule.create(ctx);
|
|
7179
|
+
walk(ast, (node) => {
|
|
7180
|
+
visitors[node.type]?.(node);
|
|
4681
7181
|
});
|
|
4682
7182
|
}
|
|
7183
|
+
findings.push(...scanPromptInjection(filename, source));
|
|
7184
|
+
return findings;
|
|
7185
|
+
}
|
|
7186
|
+
function findingsFromTemplate(filename, source) {
|
|
7187
|
+
const findings = [];
|
|
7188
|
+
const ctx = {
|
|
7189
|
+
filename,
|
|
7190
|
+
source,
|
|
7191
|
+
report: ({ node, message }) => {
|
|
7192
|
+
const snippet = source.split(/\r?\n/)[(node.loc?.start.line ?? 1) - 1]?.trim().slice(0, 120) ?? "";
|
|
7193
|
+
findings.push({
|
|
7194
|
+
id: makeFindingId(xssRule.meta.id, filename, `${node.loc?.start.line ?? 0}|${snippet}`),
|
|
7195
|
+
ruleId: xssRule.meta.id,
|
|
7196
|
+
module: "sast",
|
|
7197
|
+
title: xssRule.meta.title,
|
|
7198
|
+
severity: xssRule.meta.severity,
|
|
7199
|
+
confidence: "high",
|
|
7200
|
+
cwe: xssRule.meta.cwe,
|
|
7201
|
+
file: filename,
|
|
7202
|
+
line: node.loc?.start.line,
|
|
7203
|
+
column: node.loc?.start.column,
|
|
7204
|
+
snippet,
|
|
7205
|
+
message,
|
|
7206
|
+
remediation: `${xssRule.meta.title}. See ${xssRule.meta.docs}`,
|
|
7207
|
+
references: [xssRule.meta.docs, xssRule.meta.cwe]
|
|
7208
|
+
});
|
|
7209
|
+
},
|
|
7210
|
+
isTainted: () => false,
|
|
7211
|
+
isPathSanitized: () => false,
|
|
7212
|
+
isSanitizedFor: () => false,
|
|
7213
|
+
isNosqlSanitized: () => false,
|
|
7214
|
+
isRequestObject: () => false,
|
|
7215
|
+
functionHasPasswordName: () => false,
|
|
7216
|
+
assignedName: () => void 0,
|
|
7217
|
+
projectCsrfProtected: false
|
|
7218
|
+
};
|
|
7219
|
+
xssRule.create(ctx);
|
|
4683
7220
|
return findings;
|
|
4684
7221
|
}
|
|
4685
|
-
var
|
|
4686
|
-
|
|
4687
|
-
name: "secrets",
|
|
7222
|
+
var sastModule = {
|
|
7223
|
+
name: "sast",
|
|
4688
7224
|
async run(ctx) {
|
|
4689
7225
|
const findings = [];
|
|
7226
|
+
const parsed = [];
|
|
4690
7227
|
for (const rel of ctx.files) {
|
|
4691
|
-
|
|
7228
|
+
const isSource = SOURCE_EXT.test(rel);
|
|
7229
|
+
const isTemplate = TEMPLATE_EXT.test(rel);
|
|
7230
|
+
if (!isSource && !isTemplate) continue;
|
|
7231
|
+
let source;
|
|
4692
7232
|
try {
|
|
4693
|
-
|
|
7233
|
+
source = await readFile(join(ctx.cwd, rel), "utf8");
|
|
4694
7234
|
} catch {
|
|
4695
7235
|
continue;
|
|
4696
7236
|
}
|
|
4697
|
-
if (
|
|
4698
|
-
|
|
7237
|
+
if (source.length > 1024 * 1024 || source.includes("\0")) continue;
|
|
7238
|
+
if (isTemplate) {
|
|
7239
|
+
findings.push(...findingsFromTemplate(rel, source));
|
|
7240
|
+
continue;
|
|
7241
|
+
}
|
|
7242
|
+
try {
|
|
7243
|
+
const ast = await parseSource(source);
|
|
7244
|
+
parsed.push({ file: rel, source, ast });
|
|
7245
|
+
} catch {
|
|
7246
|
+
}
|
|
7247
|
+
}
|
|
7248
|
+
const files = new Set(parsed.map((p) => p.file));
|
|
7249
|
+
const projectCsrf = parsed.some((p) => sourceHasCsrfGuard(p.source));
|
|
7250
|
+
const returning = /* @__PURE__ */ new Map();
|
|
7251
|
+
for (const p of parsed) {
|
|
7252
|
+
returning.set(p.file, functionsThatReturnTaint(p.ast));
|
|
7253
|
+
}
|
|
7254
|
+
for (const p of parsed) {
|
|
7255
|
+
const ns = collectRequires(p.file, p.ast, files);
|
|
7256
|
+
const extra = new Set(returning.get(p.file));
|
|
7257
|
+
for (const [local, spec] of ns) {
|
|
7258
|
+
const hash = spec.indexOf("#");
|
|
7259
|
+
const file = hash >= 0 ? spec.slice(0, hash) : spec;
|
|
7260
|
+
const exp = hash >= 0 ? spec.slice(hash + 1) : local;
|
|
7261
|
+
if (returning.get(file)?.has(exp)) extra.add(local);
|
|
7262
|
+
}
|
|
7263
|
+
findings.push(...findingsFromAst(p.file, p.source, p.ast, extra, projectCsrf));
|
|
4699
7264
|
}
|
|
7265
|
+
findings.push(...runInterproc(parsed));
|
|
4700
7266
|
return findings;
|
|
4701
7267
|
}
|
|
4702
7268
|
};
|
|
4703
7269
|
|
|
7270
|
+
// src/core/engine.ts
|
|
7271
|
+
init_secrets();
|
|
7272
|
+
|
|
7273
|
+
// src/modules/secrets/experimental.ts
|
|
7274
|
+
function secretsAdvEnabled() {
|
|
7275
|
+
return process.env.FULLSHIELD_EXPERIMENTAL_SECRETS_ADV === "1";
|
|
7276
|
+
}
|
|
7277
|
+
|
|
7278
|
+
// src/ai/triage.ts
|
|
7279
|
+
init_finding();
|
|
7280
|
+
|
|
4704
7281
|
// src/ai/fixtures.ts
|
|
4705
7282
|
var RECORDED_FIXTURES = {
|
|
4706
7283
|
triage: {
|
|
@@ -4852,58 +7429,61 @@ function cap(findings, max) {
|
|
|
4852
7429
|
}
|
|
4853
7430
|
async function applyTriage(findings, client, config, snippets) {
|
|
4854
7431
|
const budget = cap(findings, config.maxFindings);
|
|
4855
|
-
for (const
|
|
4856
|
-
const snippet = snippets[
|
|
7432
|
+
for (const finding4 of budget) {
|
|
7433
|
+
const snippet = snippets[finding4.file ?? ""] ?? finding4.snippet ?? finding4.message;
|
|
4857
7434
|
const res = await client.complete({
|
|
4858
7435
|
mode: "triage",
|
|
4859
|
-
prompt: `${
|
|
7436
|
+
prompt: `${finding4.ruleId}
|
|
4860
7437
|
${snippet}`
|
|
4861
7438
|
});
|
|
4862
7439
|
if (res === null) continue;
|
|
4863
7440
|
if (client.useFixtures) {
|
|
4864
|
-
const label = fixtureTriage(
|
|
4865
|
-
|
|
4866
|
-
|
|
4867
|
-
|
|
7441
|
+
const label = fixtureTriage(finding4.ruleId);
|
|
7442
|
+
finding4.aiVerdict = label.verdict;
|
|
7443
|
+
finding4.aiReasoning = label.reasoning;
|
|
7444
|
+
finding4.exploitScenario = label.exploitScenario;
|
|
4868
7445
|
continue;
|
|
4869
7446
|
}
|
|
4870
7447
|
const live = asTriage(res.json);
|
|
4871
7448
|
if (live === void 0) continue;
|
|
4872
|
-
|
|
4873
|
-
|
|
4874
|
-
|
|
7449
|
+
finding4.aiVerdict = live.verdict;
|
|
7450
|
+
finding4.aiReasoning = live.reasoning;
|
|
7451
|
+
finding4.exploitScenario = live.exploitScenario;
|
|
4875
7452
|
}
|
|
4876
7453
|
return findings;
|
|
4877
7454
|
}
|
|
4878
7455
|
async function applyExplain(findings, client, config, snippets) {
|
|
4879
7456
|
const budget = cap(findings, config.maxFindings);
|
|
4880
|
-
for (const
|
|
4881
|
-
const snippet = snippets[
|
|
7457
|
+
for (const finding4 of budget) {
|
|
7458
|
+
const snippet = snippets[finding4.file ?? ""] ?? finding4.snippet ?? finding4.message;
|
|
4882
7459
|
const res = await client.complete({
|
|
4883
7460
|
mode: "explain",
|
|
4884
|
-
prompt: `${
|
|
7461
|
+
prompt: `${finding4.ruleId}
|
|
4885
7462
|
${snippet}`
|
|
4886
7463
|
});
|
|
4887
7464
|
if (res === null) continue;
|
|
4888
7465
|
if (client.useFixtures) {
|
|
4889
|
-
|
|
7466
|
+
finding4.explanation = fixtureExplain(finding4.ruleId);
|
|
4890
7467
|
continue;
|
|
4891
7468
|
}
|
|
4892
7469
|
const text = asExplanation(res.json);
|
|
4893
|
-
if (text !== void 0)
|
|
4894
|
-
delete
|
|
4895
|
-
delete
|
|
4896
|
-
delete
|
|
7470
|
+
if (text !== void 0) finding4.explanation = text;
|
|
7471
|
+
delete finding4.aiVerdict;
|
|
7472
|
+
delete finding4.exploitScenario;
|
|
7473
|
+
delete finding4.aiReasoning;
|
|
4897
7474
|
}
|
|
4898
7475
|
return findings;
|
|
4899
7476
|
}
|
|
7477
|
+
|
|
7478
|
+
// src/ai/logic.ts
|
|
7479
|
+
init_finding();
|
|
4900
7480
|
var ROUTE_FILE = /(^|\/)(routes?|handlers?|controllers?)\//;
|
|
4901
|
-
function clampAiFinding(
|
|
7481
|
+
function clampAiFinding(finding4) {
|
|
4902
7482
|
return {
|
|
4903
|
-
...
|
|
7483
|
+
...finding4,
|
|
4904
7484
|
source: "ai",
|
|
4905
7485
|
confidence: "medium",
|
|
4906
|
-
severity:
|
|
7486
|
+
severity: finding4.severity === "low" ? "low" : "medium"
|
|
4907
7487
|
};
|
|
4908
7488
|
}
|
|
4909
7489
|
async function runLogicHunt(cwd, files, client) {
|
|
@@ -4971,6 +7551,8 @@ ${text}`);
|
|
|
4971
7551
|
}
|
|
4972
7552
|
|
|
4973
7553
|
// src/ai/redact.ts
|
|
7554
|
+
init_mask();
|
|
7555
|
+
init_secrets();
|
|
4974
7556
|
var REDACTED = "<REDACTED>";
|
|
4975
7557
|
function redactSecrets(text) {
|
|
4976
7558
|
const rawLines = text.split(/\r?\n/);
|
|
@@ -5152,6 +7734,9 @@ function applyBaseline(findings, baseline) {
|
|
|
5152
7734
|
const known = new Set(baseline.findings.map((f) => f.id));
|
|
5153
7735
|
return findings.filter((f) => !known.has(f.id));
|
|
5154
7736
|
}
|
|
7737
|
+
|
|
7738
|
+
// src/core/config.ts
|
|
7739
|
+
init_finding();
|
|
5155
7740
|
var CONFIG_FILE_NAME = "fullshield.config.json";
|
|
5156
7741
|
var DEFAULT_CONFIG = {
|
|
5157
7742
|
modules: [...MODULE_NAMES],
|
|
@@ -5341,28 +7926,9 @@ async function listStagedFiles(cwd) {
|
|
|
5341
7926
|
}
|
|
5342
7927
|
}
|
|
5343
7928
|
|
|
5344
|
-
// src/core/
|
|
5345
|
-
|
|
5346
|
-
|
|
5347
|
-
const match = IGNORE.exec(line);
|
|
5348
|
-
if (match?.[1] === void 0 || match[2] === void 0) return void 0;
|
|
5349
|
-
return { ruleId: match[1], reason: match[2] };
|
|
5350
|
-
}
|
|
5351
|
-
function applyInlineSuppressions(findings, sources) {
|
|
5352
|
-
return findings.filter((finding2) => {
|
|
5353
|
-
if (finding2.file === void 0 || finding2.line === void 0) return true;
|
|
5354
|
-
const text = sources[finding2.file];
|
|
5355
|
-
if (text === void 0) return true;
|
|
5356
|
-
const lines = text.split(/\r?\n/);
|
|
5357
|
-
const current = lines[finding2.line - 1] ?? "";
|
|
5358
|
-
const previous = finding2.line >= 2 ? lines[finding2.line - 2] ?? "" : "";
|
|
5359
|
-
for (const line of [current, previous]) {
|
|
5360
|
-
const ignore = parseIgnore(line);
|
|
5361
|
-
if (ignore !== void 0 && ignore.ruleId === finding2.ruleId) return false;
|
|
5362
|
-
}
|
|
5363
|
-
return true;
|
|
5364
|
-
});
|
|
5365
|
-
}
|
|
7929
|
+
// src/core/engine.ts
|
|
7930
|
+
init_suppression();
|
|
7931
|
+
init_finding();
|
|
5366
7932
|
function packageVersion() {
|
|
5367
7933
|
let dir = dirname(fileURLToPath(import.meta.url));
|
|
5368
7934
|
for (let depth = 0; depth < 6; depth += 1) {
|
|
@@ -5381,16 +7947,201 @@ function packageVersion() {
|
|
|
5381
7947
|
throw new Error("fullshield package.json not found");
|
|
5382
7948
|
}
|
|
5383
7949
|
|
|
7950
|
+
// src/policy/index.ts
|
|
7951
|
+
init_finding();
|
|
7952
|
+
|
|
7953
|
+
// src/policy/prSummary.ts
|
|
7954
|
+
init_finding();
|
|
7955
|
+
|
|
7956
|
+
// src/policy/index.ts
|
|
7957
|
+
var POLICY_FILE_NAME = "fullshield.policy.json";
|
|
7958
|
+
var POLICY_FLAG = "FULLSHIELD_EXPERIMENTAL_POLICY";
|
|
7959
|
+
var POLICY_PEER_ERROR = "fullshield policy requires optional peer zod. Install with: npm i -D zod";
|
|
7960
|
+
var PolicyError = class extends Error {
|
|
7961
|
+
};
|
|
7962
|
+
var defaultLoadZod = async () => {
|
|
7963
|
+
const zodMod = await import('zod');
|
|
7964
|
+
const z = zodMod.z ?? zodMod;
|
|
7965
|
+
return { z };
|
|
7966
|
+
};
|
|
7967
|
+
function isPolicyEnabled(opts = {}) {
|
|
7968
|
+
return opts.experimental === true || process.env[POLICY_FLAG] === "1";
|
|
7969
|
+
}
|
|
7970
|
+
function formatZodIssues(error) {
|
|
7971
|
+
return error.issues.map((issue) => {
|
|
7972
|
+
const path = issue.path.map(String).join(".");
|
|
7973
|
+
return path.length > 0 ? `${path}: ${issue.message}` : issue.message;
|
|
7974
|
+
}).join("; ");
|
|
7975
|
+
}
|
|
7976
|
+
function isIsoTimestamp(value) {
|
|
7977
|
+
if (!/^\d{4}-\d{2}-\d{2}(T[\d:.+-Z]+)?$/.test(value)) return false;
|
|
7978
|
+
return Number.isFinite(Date.parse(value));
|
|
7979
|
+
}
|
|
7980
|
+
function policySchema(z) {
|
|
7981
|
+
const severity = z.enum(SEVERITIES);
|
|
7982
|
+
const moduleName = z.enum(MODULE_NAMES);
|
|
7983
|
+
const isoExpires = z.string().min(1).refine(isIsoTimestamp, { message: "expires must be an ISO date" });
|
|
7984
|
+
return z.object({
|
|
7985
|
+
requiredModules: z.array(moduleName).optional(),
|
|
7986
|
+
severityOverrides: z.record(z.string(), severity).optional(),
|
|
7987
|
+
failOn: z.record(z.string(), severity).optional(),
|
|
7988
|
+
exceptions: z.array(
|
|
7989
|
+
z.object({
|
|
7990
|
+
ruleId: z.string().min(1),
|
|
7991
|
+
file: z.string().min(1).optional(),
|
|
7992
|
+
id: z.string().min(1).optional(),
|
|
7993
|
+
owner: z.string().min(1, "exceptions require owner"),
|
|
7994
|
+
reason: z.string().min(1, "exceptions require reason"),
|
|
7995
|
+
expires: isoExpires
|
|
7996
|
+
})
|
|
7997
|
+
).optional()
|
|
7998
|
+
}).strict();
|
|
7999
|
+
}
|
|
8000
|
+
async function parsePolicy(raw, source = POLICY_FILE_NAME, loadZod = defaultLoadZod) {
|
|
8001
|
+
let z;
|
|
8002
|
+
try {
|
|
8003
|
+
({ z } = await loadZod());
|
|
8004
|
+
} catch {
|
|
8005
|
+
throw new PolicyError(POLICY_PEER_ERROR);
|
|
8006
|
+
}
|
|
8007
|
+
const parsed = policySchema(z).safeParse(raw);
|
|
8008
|
+
if (!parsed.success) {
|
|
8009
|
+
throw new PolicyError(`${source}: ${formatZodIssues(parsed.error)}`);
|
|
8010
|
+
}
|
|
8011
|
+
const data = parsed.data;
|
|
8012
|
+
const requiredModules = data.requiredModules?.filter(isModuleName);
|
|
8013
|
+
const severityOverrides = {};
|
|
8014
|
+
if (data.severityOverrides !== void 0) {
|
|
8015
|
+
for (const [ruleId, severity] of Object.entries(data.severityOverrides)) {
|
|
8016
|
+
if (isSeverity(severity)) severityOverrides[ruleId] = severity;
|
|
8017
|
+
}
|
|
8018
|
+
}
|
|
8019
|
+
const failOn = {};
|
|
8020
|
+
if (data.failOn !== void 0) {
|
|
8021
|
+
for (const [branch, severity] of Object.entries(data.failOn)) {
|
|
8022
|
+
if (isSeverity(severity)) failOn[branch] = severity;
|
|
8023
|
+
}
|
|
8024
|
+
}
|
|
8025
|
+
return {
|
|
8026
|
+
requiredModules,
|
|
8027
|
+
severityOverrides: Object.keys(severityOverrides).length > 0 ? severityOverrides : void 0,
|
|
8028
|
+
failOn: Object.keys(failOn).length > 0 ? failOn : void 0,
|
|
8029
|
+
exceptions: data.exceptions
|
|
8030
|
+
};
|
|
8031
|
+
}
|
|
8032
|
+
async function loadPolicy(cwd, opts = {}, loadZod = defaultLoadZod) {
|
|
8033
|
+
if (!isPolicyEnabled(opts)) return void 0;
|
|
8034
|
+
const file = join(cwd, POLICY_FILE_NAME);
|
|
8035
|
+
let contents;
|
|
8036
|
+
try {
|
|
8037
|
+
contents = await readFile(file, "utf8");
|
|
8038
|
+
} catch (error) {
|
|
8039
|
+
const code = error.code;
|
|
8040
|
+
if (code === "ENOENT") return void 0;
|
|
8041
|
+
throw new PolicyError(`cannot read policy file ${file}: ${code ?? String(error)}`);
|
|
8042
|
+
}
|
|
8043
|
+
let raw;
|
|
8044
|
+
try {
|
|
8045
|
+
raw = JSON.parse(contents);
|
|
8046
|
+
} catch (error) {
|
|
8047
|
+
throw new PolicyError(
|
|
8048
|
+
`${file}: invalid JSON (${error instanceof Error ? error.message : String(error)})`
|
|
8049
|
+
);
|
|
8050
|
+
}
|
|
8051
|
+
return parsePolicy(raw, file, loadZod);
|
|
8052
|
+
}
|
|
8053
|
+
function globMatch(pattern, value) {
|
|
8054
|
+
if (pattern === "*") return true;
|
|
8055
|
+
if (!pattern.includes("*")) return pattern === value;
|
|
8056
|
+
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
|
|
8057
|
+
return new RegExp(`^${escaped}$`).test(value);
|
|
8058
|
+
}
|
|
8059
|
+
function failOnForBranch(map, branch) {
|
|
8060
|
+
if (map === void 0) return void 0;
|
|
8061
|
+
if (Object.prototype.hasOwnProperty.call(map, branch)) return map[branch];
|
|
8062
|
+
for (const [key, severity] of Object.entries(map)) {
|
|
8063
|
+
if (key === "*") continue;
|
|
8064
|
+
if (globMatch(key, branch)) return severity;
|
|
8065
|
+
}
|
|
8066
|
+
return map["*"];
|
|
8067
|
+
}
|
|
8068
|
+
function resolvePolicyBranch(cwd, env = process.env) {
|
|
8069
|
+
const fromEnv = env.FULLSHIELD_POLICY_BRANCH ?? env.GITHUB_HEAD_REF ?? env.GITHUB_REF_NAME ?? env.CI_COMMIT_BRANCH;
|
|
8070
|
+
if (fromEnv !== void 0 && fromEnv.length > 0) return fromEnv;
|
|
8071
|
+
try {
|
|
8072
|
+
const stdout = execFileSync("git", ["-C", cwd, "rev-parse", "--abbrev-ref", "HEAD"], {
|
|
8073
|
+
encoding: "utf8",
|
|
8074
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
8075
|
+
});
|
|
8076
|
+
const branch = stdout.trim();
|
|
8077
|
+
return branch.length > 0 ? branch : "*";
|
|
8078
|
+
} catch {
|
|
8079
|
+
return "*";
|
|
8080
|
+
}
|
|
8081
|
+
}
|
|
8082
|
+
function expiresAtMs(expires) {
|
|
8083
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(expires)) {
|
|
8084
|
+
return Date.parse(`${expires}T23:59:59.999Z`);
|
|
8085
|
+
}
|
|
8086
|
+
return Date.parse(expires);
|
|
8087
|
+
}
|
|
8088
|
+
function exceptionMatches(exception, finding4) {
|
|
8089
|
+
if (exception.ruleId !== finding4.ruleId) return false;
|
|
8090
|
+
if (exception.id !== void 0 && exception.id !== finding4.id) return false;
|
|
8091
|
+
if (exception.file !== void 0 && exception.file !== finding4.file) return false;
|
|
8092
|
+
return true;
|
|
8093
|
+
}
|
|
8094
|
+
function applyPolicy(findings, policy, ctx = {}) {
|
|
8095
|
+
const now = ctx.now ?? /* @__PURE__ */ new Date();
|
|
8096
|
+
const errors = [];
|
|
8097
|
+
const overrides = policy.severityOverrides ?? {};
|
|
8098
|
+
let next = findings.map((finding4) => {
|
|
8099
|
+
const severity = overrides[finding4.ruleId];
|
|
8100
|
+
return severity !== void 0 && severity !== finding4.severity ? { ...finding4, severity } : finding4;
|
|
8101
|
+
});
|
|
8102
|
+
const suppressed = /* @__PURE__ */ new Set();
|
|
8103
|
+
for (const exception of policy.exceptions ?? []) {
|
|
8104
|
+
if (expiresAtMs(exception.expires) < now.getTime()) {
|
|
8105
|
+
errors.push(
|
|
8106
|
+
`policy exception for ${exception.ruleId} expired on ${exception.expires} (owner: ${exception.owner}); renew or remove it`
|
|
8107
|
+
);
|
|
8108
|
+
continue;
|
|
8109
|
+
}
|
|
8110
|
+
for (const finding4 of next) {
|
|
8111
|
+
if (exceptionMatches(exception, finding4)) suppressed.add(finding4.id);
|
|
8112
|
+
}
|
|
8113
|
+
}
|
|
8114
|
+
next = next.filter((finding4) => !suppressed.has(finding4.id));
|
|
8115
|
+
for (const required of policy.requiredModules ?? []) {
|
|
8116
|
+
if (!(ctx.modulesRun ?? []).includes(required)) {
|
|
8117
|
+
errors.push(`policy required module "${required}" did not run`);
|
|
8118
|
+
}
|
|
8119
|
+
}
|
|
8120
|
+
return {
|
|
8121
|
+
findings: sortFindings(next),
|
|
8122
|
+
failOn: failOnForBranch(policy.failOn, ctx.branch ?? "*"),
|
|
8123
|
+
errors
|
|
8124
|
+
};
|
|
8125
|
+
}
|
|
8126
|
+
function mergeRequiredModules(requested, required) {
|
|
8127
|
+
if (required === void 0 || required.length === 0) return [...requested];
|
|
8128
|
+
const out = [...requested];
|
|
8129
|
+
for (const name of required) {
|
|
8130
|
+
if (!out.includes(name)) out.push(name);
|
|
8131
|
+
}
|
|
8132
|
+
return out;
|
|
8133
|
+
}
|
|
8134
|
+
|
|
5384
8135
|
// src/core/engine.ts
|
|
5385
8136
|
var builtinModules = [depsModule, secretsModule, sastModule, configModule];
|
|
5386
8137
|
builtinModules.map((m) => m.name);
|
|
5387
8138
|
function dedupe2(findings) {
|
|
5388
8139
|
const seen = /* @__PURE__ */ new Set();
|
|
5389
8140
|
const out = [];
|
|
5390
|
-
for (const
|
|
5391
|
-
if (!seen.has(
|
|
5392
|
-
seen.add(
|
|
5393
|
-
out.push(
|
|
8141
|
+
for (const finding4 of findings) {
|
|
8142
|
+
if (!seen.has(finding4.id)) {
|
|
8143
|
+
seen.add(finding4.id);
|
|
8144
|
+
out.push(finding4);
|
|
5394
8145
|
}
|
|
5395
8146
|
}
|
|
5396
8147
|
return out;
|
|
@@ -5414,7 +8165,11 @@ async function runScan(options = {}, modules = builtinModules) {
|
|
|
5414
8165
|
config = { ...config, ai: { ...config.ai, maxFindings } };
|
|
5415
8166
|
}
|
|
5416
8167
|
}
|
|
5417
|
-
const
|
|
8168
|
+
const loadedPolicy = await loadPolicy(cwd, { experimental: options.experimentalPolicy });
|
|
8169
|
+
const modulesRequested = mergeRequiredModules(
|
|
8170
|
+
options.modules ?? config.modules,
|
|
8171
|
+
loadedPolicy?.requiredModules
|
|
8172
|
+
);
|
|
5418
8173
|
let files = await walkFiles({ cwd, ignore: config.ignore });
|
|
5419
8174
|
if (options.staged === true) {
|
|
5420
8175
|
const staged = new Set(await listStagedFiles(cwd));
|
|
@@ -5443,6 +8198,27 @@ async function runScan(options = {}, modules = builtinModules) {
|
|
|
5443
8198
|
collected.push(...await module.run(ctx));
|
|
5444
8199
|
}
|
|
5445
8200
|
}
|
|
8201
|
+
if (process.env.FULLSHIELD_EXPERIMENTAL_MCPCFG === "1") {
|
|
8202
|
+
collected.push(
|
|
8203
|
+
...scanMcpConfig(cwd, { experimental: true, includeHome: options.includeHome === true })
|
|
8204
|
+
);
|
|
8205
|
+
}
|
|
8206
|
+
const extraHits = [];
|
|
8207
|
+
const secretsWanted = modulesRequested.includes("secrets");
|
|
8208
|
+
if (secretsAdvEnabled() && secretsWanted) {
|
|
8209
|
+
if (options.history === true) {
|
|
8210
|
+
const { scanGitHistory: scanGitHistory2 } = await Promise.resolve().then(() => (init_history(), history_exports));
|
|
8211
|
+
const hist = await scanGitHistory2(cwd);
|
|
8212
|
+
extraHits.push(...hist.hits);
|
|
8213
|
+
collected.push(...hist.findings);
|
|
8214
|
+
}
|
|
8215
|
+
if (options.bundle !== void 0 && options.bundle.length > 0) {
|
|
8216
|
+
const { scanBundle: scanBundle2 } = await Promise.resolve().then(() => (init_bundle(), bundle_exports));
|
|
8217
|
+
const bun = await scanBundle2(options.bundle);
|
|
8218
|
+
extraHits.push(...bun.hits);
|
|
8219
|
+
collected.push(...bun.findings);
|
|
8220
|
+
}
|
|
8221
|
+
}
|
|
5446
8222
|
const sources = {};
|
|
5447
8223
|
for (const file of new Set(collected.map((f) => f.file).filter((f) => f !== void 0))) {
|
|
5448
8224
|
try {
|
|
@@ -5456,6 +8232,50 @@ async function runScan(options = {}, modules = builtinModules) {
|
|
|
5456
8232
|
await writeBaseline(baselinePath, unsuppressed);
|
|
5457
8233
|
}
|
|
5458
8234
|
let findings = sortFindings(applyBaseline(unsuppressed, await readBaseline(baselinePath)));
|
|
8235
|
+
if (secretsAdvEnabled() && secretsWanted && options.verifySecrets === true && options.iOwnTheseCredentials === true) {
|
|
8236
|
+
const { applyVerification: applyVerification2 } = await Promise.resolve().then(() => (init_verify(), verify_exports));
|
|
8237
|
+
const { scanTextForSecretHits: scanTextForSecretHits2 } = await Promise.resolve().then(() => (init_secrets(), secrets_exports));
|
|
8238
|
+
const secretById = /* @__PURE__ */ new Map();
|
|
8239
|
+
for (const hit of extraHits) {
|
|
8240
|
+
if (hit.secret !== void 0) secretById.set(hit.finding.id, hit.secret);
|
|
8241
|
+
}
|
|
8242
|
+
for (const finding4 of findings) {
|
|
8243
|
+
if (finding4.module !== "secrets" || finding4.file === void 0 || secretById.has(finding4.id)) {
|
|
8244
|
+
continue;
|
|
8245
|
+
}
|
|
8246
|
+
let text = sources[finding4.file];
|
|
8247
|
+
if (text === void 0) {
|
|
8248
|
+
try {
|
|
8249
|
+
text = await readFile(join(cwd, finding4.file), "utf8");
|
|
8250
|
+
} catch {
|
|
8251
|
+
continue;
|
|
8252
|
+
}
|
|
8253
|
+
}
|
|
8254
|
+
for (const hit of scanTextForSecretHits2(finding4.file, text)) {
|
|
8255
|
+
if (hit.secret !== void 0) secretById.set(hit.finding.id, hit.secret);
|
|
8256
|
+
}
|
|
8257
|
+
}
|
|
8258
|
+
findings = sortFindings(
|
|
8259
|
+
await applyVerification2(findings, secretById, {
|
|
8260
|
+
fetchImpl: options.verifyFetch
|
|
8261
|
+
})
|
|
8262
|
+
);
|
|
8263
|
+
}
|
|
8264
|
+
let policyResult;
|
|
8265
|
+
if (loadedPolicy !== void 0) {
|
|
8266
|
+
const applied = applyPolicy(findings, loadedPolicy, {
|
|
8267
|
+
branch: options.policyBranch ?? resolvePolicyBranch(cwd),
|
|
8268
|
+
modulesRun
|
|
8269
|
+
});
|
|
8270
|
+
findings = applied.findings;
|
|
8271
|
+
policyResult = {
|
|
8272
|
+
enabled: true,
|
|
8273
|
+
file: join(cwd, POLICY_FILE_NAME),
|
|
8274
|
+
failOn: applied.failOn,
|
|
8275
|
+
errors: applied.errors,
|
|
8276
|
+
policy: loadedPolicy
|
|
8277
|
+
};
|
|
8278
|
+
}
|
|
5459
8279
|
const aiMode = options.ai;
|
|
5460
8280
|
const aiEnabled = aiMode === true || aiMode === "triage" || aiMode === "explain" || aiMode === "logic" || config.ai.enabled;
|
|
5461
8281
|
if (aiEnabled) {
|
|
@@ -5485,41 +8305,31 @@ async function runScan(options = {}, modules = builtinModules) {
|
|
|
5485
8305
|
modulesRequested: [...modulesRequested],
|
|
5486
8306
|
modulesRun
|
|
5487
8307
|
},
|
|
5488
|
-
config
|
|
8308
|
+
config,
|
|
8309
|
+
policy: policyResult
|
|
5489
8310
|
};
|
|
5490
8311
|
}
|
|
5491
8312
|
function scan(options = {}) {
|
|
5492
8313
|
return runScan(options);
|
|
5493
8314
|
}
|
|
5494
8315
|
|
|
5495
|
-
// src/fixer/diff.ts
|
|
5496
|
-
function unifiedDiff(filename, before, after) {
|
|
5497
|
-
if (before === after) return "";
|
|
5498
|
-
const oldLines = before.split("\n");
|
|
5499
|
-
const newLines = after.split("\n");
|
|
5500
|
-
const hunks = [`--- a/${filename}`, `+++ b/${filename}`];
|
|
5501
|
-
hunks.push(`@@ -1,${oldLines.length} +1,${newLines.length} @@`);
|
|
5502
|
-
for (const line of oldLines) hunks.push(`-${line}`);
|
|
5503
|
-
for (const line of newLines) hunks.push(`+${line}`);
|
|
5504
|
-
return hunks.join("\n");
|
|
5505
|
-
}
|
|
5506
|
-
|
|
5507
8316
|
// src/fixer/patches.ts
|
|
5508
|
-
function proposeFix(
|
|
5509
|
-
const file =
|
|
5510
|
-
if (
|
|
5511
|
-
if (
|
|
5512
|
-
if (
|
|
5513
|
-
if (
|
|
5514
|
-
if (
|
|
5515
|
-
if (
|
|
5516
|
-
return secretEnvFix(file, source,
|
|
8317
|
+
function proposeFix(finding4, source) {
|
|
8318
|
+
const file = finding4.file ?? "";
|
|
8319
|
+
if (finding4.ruleId === "CONFIG-HELMET-001") return helmetFix(file, source);
|
|
8320
|
+
if (finding4.ruleId === "CONFIG-BODYLIMIT-001") return bodyLimitFix(file, source);
|
|
8321
|
+
if (finding4.ruleId === "SAST-CRYPTO-001") return cryptoFix(file, source);
|
|
8322
|
+
if (finding4.ruleId === "SECRET-ENVFILE-001") return gitignoreFix(file, source);
|
|
8323
|
+
if (finding4.fix !== void 0) return finding4.fix;
|
|
8324
|
+
if (finding4.ruleId.startsWith("SECRET-") && finding4.ruleId !== "SECRET-ENVFILE-001") {
|
|
8325
|
+
return secretEnvFix(file, source, finding4);
|
|
5517
8326
|
}
|
|
5518
8327
|
return void 0;
|
|
5519
8328
|
}
|
|
5520
8329
|
function helmetFix(file, source) {
|
|
5521
|
-
|
|
5522
|
-
|
|
8330
|
+
const normalized = normalizeNewlines(source);
|
|
8331
|
+
if (/helmet\s*\(/.test(normalized)) return void 0;
|
|
8332
|
+
let after = normalized;
|
|
5523
8333
|
if (!/require\(\s*['"]helmet['"]\s*\)|from\s+['"]helmet['"]/.test(after)) {
|
|
5524
8334
|
after = `const helmet = require('helmet');
|
|
5525
8335
|
${after}`;
|
|
@@ -5528,17 +8338,19 @@ ${after}`;
|
|
|
5528
8338
|
after = after.replace(/const app = express\(\s*\);/, (m) => `${m}
|
|
5529
8339
|
app.use(helmet());`);
|
|
5530
8340
|
}
|
|
5531
|
-
if (after ===
|
|
5532
|
-
return { kind: "config-add", safe: false, diff: unifiedDiff(file,
|
|
8341
|
+
if (after === normalized) return void 0;
|
|
8342
|
+
return { kind: "config-add", safe: false, diff: unifiedDiff(file, normalized, after) };
|
|
5533
8343
|
}
|
|
5534
8344
|
function bodyLimitFix(file, source) {
|
|
5535
|
-
|
|
5536
|
-
|
|
5537
|
-
|
|
8345
|
+
const normalized = normalizeNewlines(source);
|
|
8346
|
+
if (!/express\.json\(\s*\)/.test(normalized)) return void 0;
|
|
8347
|
+
const after = normalized.replace(/express\.json\(\s*\)/g, "express.json({ limit: '100kb' })");
|
|
8348
|
+
return { kind: "config-add", safe: true, diff: unifiedDiff(file, normalized, after) };
|
|
5538
8349
|
}
|
|
5539
8350
|
function cryptoFix(file, source) {
|
|
5540
|
-
|
|
5541
|
-
|
|
8351
|
+
const normalized = normalizeNewlines(source);
|
|
8352
|
+
if (!/Math\.random\(\)/.test(normalized)) return void 0;
|
|
8353
|
+
let after = normalized;
|
|
5542
8354
|
if (!/require\(\s*['"]crypto['"]\s*\)|from\s+['"]crypto['"]/.test(after)) {
|
|
5543
8355
|
after = `const crypto = require('crypto');
|
|
5544
8356
|
${after}`;
|
|
@@ -5547,22 +8359,24 @@ ${after}`;
|
|
|
5547
8359
|
/Math\.random\(\)\.toString\(\s*36\s*\)(?:\.slice\(\s*2\s*\))?/g,
|
|
5548
8360
|
"crypto.randomUUID()"
|
|
5549
8361
|
);
|
|
5550
|
-
if (after ===
|
|
5551
|
-
return { kind: "code-patch", safe: true, diff: unifiedDiff(file,
|
|
8362
|
+
if (after === normalized) return void 0;
|
|
8363
|
+
return { kind: "code-patch", safe: true, diff: unifiedDiff(file, normalized, after) };
|
|
5552
8364
|
}
|
|
5553
8365
|
function gitignoreFix(file, source) {
|
|
5554
8366
|
const target = file.endsWith(".gitignore") ? file : ".gitignore";
|
|
5555
|
-
|
|
5556
|
-
|
|
5557
|
-
|
|
8367
|
+
const normalized = normalizeNewlines(source);
|
|
8368
|
+
if (/(^|\n)\.env(\n|$)/.test(normalized)) return void 0;
|
|
8369
|
+
const after = normalized.length === 0 || normalized.endsWith("\n") ? `${normalized}.env
|
|
8370
|
+
` : `${normalized}
|
|
5558
8371
|
.env
|
|
5559
8372
|
`;
|
|
5560
|
-
return { kind: "config-add", safe: true, diff: unifiedDiff(target,
|
|
8373
|
+
return { kind: "config-add", safe: true, diff: unifiedDiff(target, normalized, after) };
|
|
5561
8374
|
}
|
|
5562
|
-
function secretEnvFix(file, source,
|
|
5563
|
-
const envName = envNameFor(
|
|
5564
|
-
const
|
|
5565
|
-
const
|
|
8375
|
+
function secretEnvFix(file, source, finding4) {
|
|
8376
|
+
const envName = envNameFor(finding4.ruleId);
|
|
8377
|
+
const normalized = normalizeNewlines(source);
|
|
8378
|
+
const lines = normalized.split("\n");
|
|
8379
|
+
const idx = (finding4.line ?? 1) - 1;
|
|
5566
8380
|
const line = lines[idx];
|
|
5567
8381
|
if (line === void 0) return void 0;
|
|
5568
8382
|
const replaced = line.replace(
|
|
@@ -5572,7 +8386,7 @@ function secretEnvFix(file, source, finding2) {
|
|
|
5572
8386
|
if (replaced === line) return void 0;
|
|
5573
8387
|
const afterLines = [...lines];
|
|
5574
8388
|
afterLines[idx] = replaced;
|
|
5575
|
-
return { kind: "code-patch", safe: false, diff: unifiedDiff(file,
|
|
8389
|
+
return { kind: "code-patch", safe: false, diff: unifiedDiff(file, normalized, afterLines.join("\n")) };
|
|
5576
8390
|
}
|
|
5577
8391
|
function envNameFor(ruleId) {
|
|
5578
8392
|
if (ruleId.includes("AWS")) return "AWS_ACCESS_KEY_ID";
|
|
@@ -5586,11 +8400,14 @@ function envNameFor(ruleId) {
|
|
|
5586
8400
|
return "SECRET";
|
|
5587
8401
|
}
|
|
5588
8402
|
function applyUnifiedDiff(source, diff) {
|
|
5589
|
-
const plus = diff.split("\n").filter((line) => line.startsWith("+") && !line.startsWith("+++")).map((line) => line.slice(1));
|
|
8403
|
+
const plus = normalizeNewlines(diff).split("\n").filter((line) => line.startsWith("+") && !line.startsWith("+++")).map((line) => line.slice(1));
|
|
5590
8404
|
if (plus.length === 0) return source;
|
|
5591
|
-
return plus.join("\n");
|
|
8405
|
+
return applyEol(plus.join("\n"), detectEol(source));
|
|
5592
8406
|
}
|
|
5593
|
-
|
|
8407
|
+
|
|
8408
|
+
// src/llm/index.ts
|
|
8409
|
+
init_finding();
|
|
8410
|
+
function finding3(ruleId, title, message, file, severity = "high") {
|
|
5594
8411
|
return {
|
|
5595
8412
|
id: makeFindingId(ruleId, file, message),
|
|
5596
8413
|
ruleId,
|
|
@@ -5626,7 +8443,7 @@ function scanLlmApp(dir, opts = {}) {
|
|
|
5626
8443
|
const text = readFileSync(file, "utf8");
|
|
5627
8444
|
if (/role\s*:\s*['"]system['"]/.test(text) && /content\s*:\s*['"][^'"]*['"]\s*\+|content\s*:\s*.*\+\s*req\.(body|query|params)/.test(text)) {
|
|
5628
8445
|
findings.push(
|
|
5629
|
-
|
|
8446
|
+
finding3(
|
|
5630
8447
|
"LLM-001",
|
|
5631
8448
|
"User input concatenated into an LLM system prompt",
|
|
5632
8449
|
"User-controlled data is concatenated into a system-role message (OWASP LLM01).",
|
|
@@ -5638,7 +8455,7 @@ function scanLlmApp(dir, opts = {}) {
|
|
|
5638
8455
|
text
|
|
5639
8456
|
) || /\.innerHTML\s*=\s*.*(completion|choices\[)/.test(text)) {
|
|
5640
8457
|
findings.push(
|
|
5641
|
-
|
|
8458
|
+
finding3(
|
|
5642
8459
|
"LLM-002",
|
|
5643
8460
|
"LLM output passed into a dangerous sink",
|
|
5644
8461
|
"Model output reaches exec/eval/SQL/HTML without validation (OWASP LLM02).",
|
|
@@ -5648,7 +8465,7 @@ function scanLlmApp(dir, opts = {}) {
|
|
|
5648
8465
|
}
|
|
5649
8466
|
if (/name\s*:\s*['"](run_shell|shell|exec|write_file|send_payment)['"]/.test(text) && !/require(Confirmation|Approval)|confirm(ation)?\s*:/.test(text)) {
|
|
5650
8467
|
findings.push(
|
|
5651
|
-
|
|
8468
|
+
finding3(
|
|
5652
8469
|
"LLM-003",
|
|
5653
8470
|
"Dangerous LLM tool without confirmation",
|
|
5654
8471
|
"Function-calling exposes shell/file/payment tools without a confirmation step (OWASP LLM06).",
|
|
@@ -5659,7 +8476,7 @@ function scanLlmApp(dir, opts = {}) {
|
|
|
5659
8476
|
const browserish = /(^|\/)(public|client|browser|static)\//.test(rel.replaceAll("\\", "/"));
|
|
5660
8477
|
if (browserish && /OPENAI_API_KEY|ANTHROPIC_API_KEY|sk-[a-zA-Z0-9]{8,}/.test(text)) {
|
|
5661
8478
|
findings.push(
|
|
5662
|
-
|
|
8479
|
+
finding3(
|
|
5663
8480
|
"LLM-004",
|
|
5664
8481
|
"LLM API key exposed to a browser bundle",
|
|
5665
8482
|
"An OpenAI/Anthropic-style key is present in a public/client file (OWASP LLM06/sensitive info).",
|
|
@@ -5670,7 +8487,7 @@ function scanLlmApp(dir, opts = {}) {
|
|
|
5670
8487
|
}
|
|
5671
8488
|
if (/chat\.completions\.create|messages\.create|anthropic\.messages/.test(text) && !/max_tokens|max_completion_tokens|maxTokens/.test(text)) {
|
|
5672
8489
|
findings.push(
|
|
5673
|
-
|
|
8490
|
+
finding3(
|
|
5674
8491
|
"LLM-005",
|
|
5675
8492
|
"LLM call has no output length or cost limit",
|
|
5676
8493
|
"chat/completions call does not set max_tokens (OWASP LLM10 unbounded consumption).",
|
|
@@ -5775,7 +8592,28 @@ async function handleMcpRequest(session, req) {
|
|
|
5775
8592
|
function textResult(value) {
|
|
5776
8593
|
return { content: [{ type: "text", text: JSON.stringify(value) }] };
|
|
5777
8594
|
}
|
|
5778
|
-
|
|
8595
|
+
var MCP_PEER_ERROR = "fullshield mcp requires optional peers @modelcontextprotocol/sdk and zod. Install with: npm i -D @modelcontextprotocol/sdk zod";
|
|
8596
|
+
var defaultMcpPeerImporter = async () => {
|
|
8597
|
+
const [serverMod, stdioMod, zodMod] = await Promise.all([
|
|
8598
|
+
import('@modelcontextprotocol/sdk/server/mcp.js'),
|
|
8599
|
+
import('@modelcontextprotocol/sdk/server/stdio.js'),
|
|
8600
|
+
import('zod')
|
|
8601
|
+
]);
|
|
8602
|
+
return {
|
|
8603
|
+
McpServer: serverMod.McpServer,
|
|
8604
|
+
StdioServerTransport: stdioMod.StdioServerTransport,
|
|
8605
|
+
z: zodMod.z
|
|
8606
|
+
};
|
|
8607
|
+
};
|
|
8608
|
+
async function loadMcpPeers(importer = defaultMcpPeerImporter) {
|
|
8609
|
+
try {
|
|
8610
|
+
return await importer();
|
|
8611
|
+
} catch {
|
|
8612
|
+
throw new Error(MCP_PEER_ERROR);
|
|
8613
|
+
}
|
|
8614
|
+
}
|
|
8615
|
+
async function createOfficialMcpServer(opts = {}) {
|
|
8616
|
+
const { McpServer, z } = await loadMcpPeers(opts.loadPeers);
|
|
5779
8617
|
const session = createMcpSession(opts);
|
|
5780
8618
|
const server = new McpServer({ name: "fullshield", version: "0.1.0" });
|
|
5781
8619
|
const modulesSchema = z.array(z.enum(["deps", "secrets", "sast", "config"])).optional();
|
|
@@ -5823,9 +8661,10 @@ function createOfficialMcpServer(opts = {}) {
|
|
|
5823
8661
|
return server;
|
|
5824
8662
|
}
|
|
5825
8663
|
async function startMcpStdio(opts = {}) {
|
|
5826
|
-
const
|
|
8664
|
+
const { StdioServerTransport } = await loadMcpPeers(opts.loadPeers);
|
|
8665
|
+
const server = await createOfficialMcpServer(opts);
|
|
5827
8666
|
const transport = new StdioServerTransport();
|
|
5828
8667
|
await server.connect(transport);
|
|
5829
8668
|
}
|
|
5830
8669
|
|
|
5831
|
-
export { callMcpTool, createMcpSession, createOfficialMcpServer, handleMcpRequest, listMcpTools, startMcpStdio };
|
|
8670
|
+
export { MCP_PEER_ERROR, callMcpTool, createMcpSession, createOfficialMcpServer, handleMcpRequest, listMcpTools, loadMcpPeers, startMcpStdio };
|