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.
Files changed (55) hide show
  1. package/CHANGELOG.md +109 -3
  2. package/README.md +48 -6
  3. package/dist/agent.js +3424 -601
  4. package/dist/bench/score.js +118 -0
  5. package/dist/cli/index.js +5857 -2033
  6. package/dist/compliance.js +31 -2
  7. package/dist/dashboard.js +50 -9
  8. package/dist/index.d.ts +152 -2
  9. package/dist/index.js +5733 -2611
  10. package/dist/mcp.js +3452 -613
  11. package/dist/runtime/auto.js +100 -3
  12. package/dist/runtime/dlp.js +313 -0
  13. package/dist/runtime/express.js +351 -1
  14. package/dist/runtime/fastify.js +339 -1
  15. package/dist/runtime/hono.js +570 -0
  16. package/dist/runtime/index.js +14 -1
  17. package/dist/runtime/koa.js +573 -0
  18. package/dist/runtime/rasp.js +100 -3
  19. package/dist/runtime/register.js +100 -3
  20. package/dist/supply.js +87 -1
  21. package/dist/vex.js +178 -0
  22. package/package.json +25 -6
  23. package/rules/CONFIG-DIRLIST-001.md +9 -0
  24. package/rules/CONFIG-TRUSTPROXY-001.md +11 -0
  25. package/rules/DLP-CARD-001.md +9 -0
  26. package/rules/DLP-INTERNAL-001.md +9 -0
  27. package/rules/DLP-PII-001.md +9 -0
  28. package/rules/DLP-SECRET-001.md +9 -0
  29. package/rules/DLP-STACK-001.md +9 -0
  30. package/rules/HOST-SSH-001.md +13 -0
  31. package/rules/HOST-SSH-002.md +11 -0
  32. package/rules/HOST-SSH-003.md +11 -0
  33. package/rules/MCP-001.md +7 -0
  34. package/rules/MCP-002.md +7 -0
  35. package/rules/MCP-003.md +7 -0
  36. package/rules/MCP-004.md +7 -0
  37. package/rules/MCP-005.md +7 -0
  38. package/rules/README.md +47 -1
  39. package/rules/SAST-COOKIE-001.md +11 -0
  40. package/rules/SAST-CSRF-001.md +11 -0
  41. package/rules/SAST-DESER-001.md +11 -0
  42. package/rules/SAST-GQL-001.md +11 -0
  43. package/rules/SAST-HASH-001.md +11 -0
  44. package/rules/SAST-MASSASSIGN-001.md +11 -0
  45. package/rules/SAST-NOSQLI-001.md +6 -2
  46. package/rules/SAST-REDOS-001.md +11 -0
  47. package/rules/SAST-TIMING-001.md +11 -0
  48. package/rules/SAST-UPLOAD-001.md +11 -0
  49. package/rules/SAST-XXE-001.md +11 -0
  50. package/rules/SECRET-BUNDLE-001.md +11 -0
  51. package/rules/SECRET-SESSION-001.md +9 -0
  52. package/rules/SUPPLY-WORKFLOW-001.md +9 -0
  53. package/rules/SUPPLY-WORM-001.md +9 -0
  54. package/templates/github-watch.yml +3 -3
  55. package/templates/github-workflow.yml +3 -3
package/dist/agent.js CHANGED
@@ -1,12 +1,13 @@
1
- import { execFile, spawnSync, execFileSync } from 'child_process';
2
- import { existsSync, writeFileSync, readFileSync, unlinkSync } from 'fs';
3
- import { resolve, join, basename, dirname, posix } from 'path';
1
+ import { createHash } from 'crypto';
4
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, writeFileSync, unlinkSync } from 'fs';
5
8
  import { performance } from 'perf_hooks';
6
- import { createHash } from 'crypto';
7
9
  import { homedir } from 'os';
8
10
  import ignoreModule from 'ignore';
9
- import { promisify } from 'util';
10
11
  import { fileURLToPath } from 'url';
11
12
 
12
13
  var __defProp = Object.defineProperty;
@@ -18,6 +19,521 @@ var __export = (target, all) => {
18
19
  for (var name in all)
19
20
  __defProp(target, name, { get: all[name], enumerable: true });
20
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 finding3 of findings) {
43
+ bySeverity[finding3.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((finding3) => {
518
+ if (finding3.file === void 0 || finding3.line === void 0) return true;
519
+ const text = sources[finding3.file];
520
+ if (text === void 0) return true;
521
+ const lines = text.split(/\r?\n/);
522
+ const current = lines[finding3.line - 1] ?? "";
523
+ const previous = finding3.line >= 2 ? lines[finding3.line - 2] ?? "" : "";
524
+ for (const line of [current, previous]) {
525
+ const ignore = parseIgnore(line);
526
+ if (ignore !== void 0 && ignore.ruleId === finding3.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
+ });
21
537
 
22
538
  // src/incremental/index.ts
23
539
  var incremental_exports = {};
@@ -37,6 +553,435 @@ var init_incremental = __esm({
37
553
  }
38
554
  });
39
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
+ });
738
+
739
+ // src/modules/secrets/bundle.ts
740
+ var bundle_exports = {};
741
+ __export(bundle_exports, {
742
+ scanBundle: () => scanBundle
743
+ });
744
+ function posixRel(root, full) {
745
+ return relative(root, full).split("\\").join("/");
746
+ }
747
+ async function listFiles(root) {
748
+ const files = [];
749
+ async function walk2(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 walk2(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 walk2(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((finding3) => ({ finding: finding3, secret: secretById.get(finding3.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";
982
+ }
983
+ });
984
+
40
985
  // src/modules/sast/ast.ts
41
986
  var SKIP_KEYS = /* @__PURE__ */ new Set(["loc", "range", "type", "parent"]);
42
987
  function propName(node) {
@@ -51,6 +996,24 @@ function calleeName(node) {
51
996
  if (node.callee.type === "MemberExpression") return propName(node.callee);
52
997
  return void 0;
53
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
+ }
54
1017
  function literalString(node) {
55
1018
  if (node === void 0) return void 0;
56
1019
  if (node.type === "Literal" && typeof node.value === "string") return node.value;
@@ -121,17 +1084,19 @@ function attachParents(node, parent) {
121
1084
  }
122
1085
  }
123
1086
  }
124
- var SOURCE_PROPS = /* @__PURE__ */ new Set(["query", "body", "params", "headers"]);
1087
+ var REQUEST_SOURCE_PROPS = /* @__PURE__ */ new Set(["query", "body", "params", "headers", "files"]);
1088
+ var SOURCE_PROPS = REQUEST_SOURCE_PROPS;
125
1089
  function isTaintSource(node) {
126
- if (node.type !== "MemberExpression") return false;
127
- const name = propName(node);
128
- if (node.object.type === "Identifier" && node.object.name === "req" && name !== void 0 && SOURCE_PROPS.has(name)) {
129
- return true;
130
- }
131
- if (node.object.type === "Identifier" && node.object.name === "process" && name === "argv") {
132
- return true;
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;
133
1099
  }
134
- if (node.object.type === "MemberExpression" && isTaintSource(node.object)) return true;
135
1100
  return false;
136
1101
  }
137
1102
  function isRequestObjectNode(node) {
@@ -151,6 +1116,7 @@ function assignedNameOf(node) {
151
1116
  return parent.id.name;
152
1117
  }
153
1118
  if (parent.type === "AssignmentExpression" && parent.right === node) {
1119
+ if (parent.left.type === "MemberExpression") return propName(parent.left);
154
1120
  const names = boundNames(parent.left);
155
1121
  return names[0];
156
1122
  }
@@ -159,35 +1125,23 @@ function assignedNameOf(node) {
159
1125
  }
160
1126
  return void 0;
161
1127
  }
162
- var SEVERITIES = ["info", "low", "medium", "high", "critical"];
163
- var MODULE_NAMES = ["deps", "secrets", "sast", "config"];
164
- function isSeverity(value) {
165
- return SEVERITIES.includes(value);
166
- }
167
- function isModuleName(value) {
168
- return MODULE_NAMES.includes(value);
169
- }
170
- function severityRank(severity) {
171
- return SEVERITIES.indexOf(severity);
1128
+
1129
+ // src/modules/sast/experimental.ts
1130
+ function sastGapsEnabled() {
1131
+ return process.env.FULLSHIELD_EXPERIMENTAL_SAST_GAPS === "1";
172
1132
  }
173
- function makeFindingId(ruleId, file, snippet) {
174
- const normalized = (snippet ?? "").replace(/\s+/g, " ").trim();
175
- 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";
176
1135
  }
177
- function sortFindings(findings) {
178
- return [...findings].sort(
179
- (a, b) => severityRank(b.severity) - severityRank(a.severity) || (a.file ?? "").localeCompare(b.file ?? "") || (a.line ?? 0) - (b.line ?? 0) || a.ruleId.localeCompare(b.ruleId)
180
- );
1136
+ function classesEnabled() {
1137
+ return process.env.FULLSHIELD_EXPERIMENTAL_CLASSES === "1";
181
1138
  }
182
- function summarize(findings) {
183
- const bySeverity = Object.fromEntries(SEVERITIES.map((s) => [s, 0]));
184
- for (const finding of findings) {
185
- bySeverity[finding.severity] += 1;
186
- }
187
- return { total: findings.length, bySeverity };
1139
+ function mcpCfgEnabled() {
1140
+ return process.env.FULLSHIELD_EXPERIMENTAL_MCPCFG === "1";
188
1141
  }
189
1142
 
190
1143
  // src/modules/config/finding.ts
1144
+ init_finding();
191
1145
  function configFinding(ruleId, title, severity, file, message, remediation, line) {
192
1146
  return {
193
1147
  id: makeFindingId(ruleId, file, `${line ?? 0}|${title}`),
@@ -204,6 +1158,13 @@ function configFinding(ruleId, title, severity, file, message, remediation, line
204
1158
  }
205
1159
 
206
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
+ }
207
1168
  function objectProp(node, name) {
208
1169
  if (node.type !== "ObjectExpression") return void 0;
209
1170
  for (const prop of node.properties) {
@@ -225,12 +1186,16 @@ async function runConfigAstChecks(file, source) {
225
1186
  });
226
1187
  const findings = [];
227
1188
  let createsApp = false;
1189
+ let createsAppLine;
228
1190
  let usesHelmet = false;
229
1191
  walk(ast, (node) => {
230
1192
  if (node.type === "CallExpression") {
231
1193
  const name = calleeName(node);
232
1194
  if (name === "helmet") usesHelmet = true;
233
- if (name === "express" && node.callee.type === "Identifier") createsApp = true;
1195
+ if (name === "express" && node.callee.type === "Identifier") {
1196
+ createsApp = true;
1197
+ createsAppLine = node.loc?.start.line;
1198
+ }
234
1199
  if (node.callee.type === "MemberExpression" && node.callee.object.type === "Identifier" && node.callee.object.name === "express" && name === "json") {
235
1200
  const opts = node.arguments[0];
236
1201
  const hasLimit = opts !== void 0 && opts.type !== "SpreadElement" && objectProp(opts, "limit") !== void 0;
@@ -248,6 +1213,19 @@ async function runConfigAstChecks(file, source) {
248
1213
  );
249
1214
  }
250
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
+ }
251
1229
  if (name === "cors") {
252
1230
  const opts = node.arguments[0];
253
1231
  if (opts !== void 0 && opts.type === "ObjectExpression") {
@@ -306,6 +1284,26 @@ async function runConfigAstChecks(file, source) {
306
1284
  );
307
1285
  }
308
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
+ }
309
1307
  if (createsApp && !usesHelmet) {
310
1308
  findings.push(
311
1309
  configFinding(
@@ -314,10 +1312,52 @@ async function runConfigAstChecks(file, source) {
314
1312
  "medium",
315
1313
  file,
316
1314
  "An Express app is created but helmet() is never applied.",
317
- "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
318
1319
  )
319
1320
  );
320
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
+ }
321
1361
  return findings;
322
1362
  }
323
1363
 
@@ -471,6 +1511,9 @@ var configModule = {
471
1511
  }
472
1512
  };
473
1513
 
1514
+ // src/modules/deps/index.ts
1515
+ init_finding();
1516
+
474
1517
  // src/modules/deps/lockfile.ts
475
1518
  function basename2(filename) {
476
1519
  const parts = filename.replace(/\\/g, "/").split("/");
@@ -862,6 +1905,30 @@ function isExactNpmVersion(spec) {
862
1905
  if (/[~^><*xX|]/.test(bare)) return false;
863
1906
  return /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(bare);
864
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
+ }
865
1932
  function exactPinsFromManifest(pkg) {
866
1933
  const out = [];
867
1934
  for (const field of MANIFEST_FIELDS) {
@@ -877,6 +1944,7 @@ function exactPinsFromManifest(pkg) {
877
1944
  }
878
1945
 
879
1946
  // src/modules/deps/reachability.ts
1947
+ init_finding();
880
1948
  function packageNameFromSnippet(snippet) {
881
1949
  if (snippet.startsWith("@")) {
882
1950
  const second = snippet.indexOf("@", 1);
@@ -1016,26 +2084,26 @@ function isDevOnly(pkgName, ctx) {
1016
2084
  return directDev.has(pkgName) || reachableFrom(pkgName, directDev, edges);
1017
2085
  }
1018
2086
  function applyReachability(findings, ctx) {
1019
- return findings.map((finding) => {
1020
- if (finding.ruleId === "DEPS-MALICIOUS-001") {
1021
- if (finding.snippet === void 0) return finding;
1022
- const pkg2 = packageNameFromSnippet(finding.snippet);
1023
- if (pkg2 === void 0 || pkg2.length === 0) return finding;
2087
+ return findings.map((finding3) => {
2088
+ if (finding3.ruleId === "DEPS-MALICIOUS-001") {
2089
+ if (finding3.snippet === void 0) return finding3;
2090
+ const pkg2 = packageNameFromSnippet(finding3.snippet);
2091
+ if (pkg2 === void 0 || pkg2.length === 0) return finding3;
1024
2092
  const classified2 = classifyReachability(pkg2, ctx.productionUsage, ctx.graph);
1025
2093
  return {
1026
- ...finding,
2094
+ ...finding3,
1027
2095
  severity: "critical",
1028
2096
  reachability: classified2.reachability,
1029
2097
  ...classified2.path !== void 0 ? { dependencyPath: classified2.path } : {}
1030
2098
  };
1031
2099
  }
1032
- if (finding.ruleId !== "DEPS-VULN-001" || finding.snippet === void 0) return finding;
1033
- const pkg = packageNameFromSnippet(finding.snippet);
1034
- if (pkg === void 0 || pkg.length === 0) return finding;
2100
+ if (finding3.ruleId !== "DEPS-VULN-001" || finding3.snippet === void 0) return finding3;
2101
+ const pkg = packageNameFromSnippet(finding3.snippet);
2102
+ if (pkg === void 0 || pkg.length === 0) return finding3;
1035
2103
  const classified = classifyReachability(pkg, ctx.productionUsage, ctx.graph);
1036
2104
  const steps = reachabilitySteps(classified.reachability);
1037
- let severity = steps === 0 ? finding.severity : downgradeSeverity(finding.severity, steps);
1038
- let message = finding.message;
2105
+ let severity = steps === 0 ? finding3.severity : downgradeSeverity(finding3.severity, steps);
2106
+ let message = finding3.message;
1039
2107
  if (classified.path !== void 0) {
1040
2108
  const shown = classified.path.join(" > ");
1041
2109
  message = `${message} Reachable via ${shown}.`;
@@ -1046,7 +2114,7 @@ function applyReachability(findings, ctx) {
1046
2114
  message = `${message} (dev-only)`;
1047
2115
  }
1048
2116
  return {
1049
- ...finding,
2117
+ ...finding3,
1050
2118
  reachability: classified.reachability,
1051
2119
  ...classified.path !== void 0 ? { dependencyPath: classified.path } : {},
1052
2120
  severity,
@@ -2944,9 +4012,253 @@ function findTyposquats(packages, options = {}) {
2944
4012
  };
2945
4013
  }
2946
4014
  }
2947
- 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 finding3 of findings) {
4210
+ if (finding3.ruleId !== "DEPS-VULN-001" || finding3.snippet === void 0) continue;
4211
+ if (!/Upgrade \S+ to \S+ or later/.test(finding3.remediation)) continue;
4212
+ names.add(packageNameFromSnippet2(finding3.snippet));
4213
+ }
4214
+ return names;
4215
+ }
4216
+ function targetVersion(finding3) {
4217
+ const match = /Upgrade \S+ to (\S+) or later/.exec(finding3.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((finding3) => {
4228
+ if (finding3.ruleId !== "DEPS-VULN-001" || finding3.snippet === void 0) return finding3;
4229
+ const name = packageNameFromSnippet2(finding3.snippet);
4230
+ if (name.length === 0 || isDirect(name, graph)) return finding3;
4231
+ const version = targetVersion(finding3);
4232
+ if (version === void 0) return finding3;
4233
+ const parentFixed = parentsOf(name, graph.edges).some((parent) => withFix.has(parent));
4234
+ if (parentFixed) return finding3;
4235
+ const after = withManifestOverride(manifestRaw, kind, name, version);
4236
+ const diff = unifiedDiff("package.json", manifestRaw, after);
4237
+ if (diff.length === 0) return finding3;
4238
+ return {
4239
+ ...finding3,
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
+ }
2948
4260
  }
2949
- return hits;
4261
+ return out;
2950
4262
  }
2951
4263
 
2952
4264
  // src/modules/deps/index.ts
@@ -2971,27 +4283,29 @@ async function readDirectDependencyNames(cwd) {
2971
4283
  return void 0;
2972
4284
  }
2973
4285
  }
2974
- function vulnFinding(lockFile, pkg, vuln) {
4286
+ function vulnFinding(lockFile, pkg, vuln, loc) {
2975
4287
  const snippet = `${pkg.name}@${pkg.version}`;
2976
4288
  const aliases = (vuln.aliases ?? []).filter((a) => a.startsWith("CVE-"));
2977
4289
  const references = [vuln.id, ...aliases];
2978
4290
  const summary = vuln.summary ?? "known vulnerability";
2979
4291
  const fixed = minFixedVersion(vuln.affected ?? []);
2980
- const finding = {
2981
- id: makeFindingId("DEPS-VULN-001", lockFile, `${snippet}|${vuln.id}`),
4292
+ const file = loc?.file ?? lockFile;
4293
+ const finding3 = {
4294
+ id: makeFindingId("DEPS-VULN-001", file, `${snippet}|${vuln.id}`),
2982
4295
  ruleId: "DEPS-VULN-001",
2983
4296
  module: "deps",
2984
4297
  title: `Vulnerable dependency ${snippet}`,
2985
4298
  severity: osvSeverity(vuln),
2986
4299
  confidence: "high",
2987
- file: lockFile,
4300
+ file,
4301
+ line: loc?.line,
2988
4302
  snippet,
2989
4303
  message: `${snippet} is affected by ${vuln.id} (${summary}).`,
2990
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.`,
2991
4305
  references
2992
4306
  };
2993
4307
  if (fixed !== void 0) {
2994
- finding.fix = {
4308
+ finding3.fix = {
2995
4309
  kind: "dependency-bump",
2996
4310
  safe: sameMajor(pkg.version, fixed),
2997
4311
  diff: `--- a/package.json
@@ -3000,7 +4314,7 @@ function vulnFinding(lockFile, pkg, vuln) {
3000
4314
  `
3001
4315
  };
3002
4316
  }
3003
- return finding;
4317
+ return finding3;
3004
4318
  }
3005
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.";
3006
4320
  function maliciousFinding(lockFile, pkg, vuln) {
@@ -3068,12 +4382,23 @@ var depsModule = {
3068
4382
  const lockFile = LOCKFILE_NAMES.find((name) => ctx.files.includes(name));
3069
4383
  const cacheDir = ctx.config.deps.cacheDir !== void 0 ? resolve(ctx.cwd, ctx.config.deps.cacheDir) : defaultCacheDir();
3070
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
+ };
3071
4396
  if (lockFile === void 0) {
3072
4397
  if (!ctx.files.includes("package.json")) return [];
3073
4398
  const findings2 = [noLockFinding()];
3074
4399
  let manifest = {};
3075
4400
  try {
3076
- manifest = JSON.parse(await readFile(join(ctx.cwd, "package.json"), "utf8"));
4401
+ manifest = JSON.parse(manifestRaw ?? "{}");
3077
4402
  } catch {
3078
4403
  return findings2;
3079
4404
  }
@@ -3086,7 +4411,7 @@ var depsModule = {
3086
4411
  for (const hit of osvHits2) {
3087
4412
  for (const vuln of hit.vulns) {
3088
4413
  findings2.push(
3089
- 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))
3090
4415
  );
3091
4416
  }
3092
4417
  }
@@ -3114,11 +4439,16 @@ var depsModule = {
3114
4439
  for (const hit of osvHits) {
3115
4440
  for (const vuln of hit.vulns) {
3116
4441
  findings.push(
3117
- 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))
3118
4443
  );
3119
4444
  }
3120
4445
  }
3121
- return applyReachability(findings, await reachabilityCtx(ctx.cwd, ctx.files, graph));
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;
3122
4452
  }
3123
4453
  };
3124
4454
  function graphFromManifest(manifest) {
@@ -3150,6 +4480,489 @@ async function reachabilityCtx(cwd, files, graph) {
3150
4480
  return { productionUsage: indexPackageUsage(prodSources), graph };
3151
4481
  }
3152
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
+
3153
4966
  // src/modules/sast/taint.ts
3154
4967
  var SCHEMA_ROOT = /^(z|zod|joi|yup|schema)$/i;
3155
4968
  function objectChainMentions(node, pattern) {
@@ -3183,6 +4996,15 @@ function isUrlSanitizerCall(node) {
3183
4996
  const name = calleeName(node);
3184
4997
  return name === "encodeURIComponent" || name === "encodeURI";
3185
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
+ }
3186
5008
  function isXssSanitizerCall(node) {
3187
5009
  if (node === void 0 || node.type !== "CallExpression") return false;
3188
5010
  const name = calleeName(node);
@@ -3192,44 +5014,60 @@ function isXssSanitizerCall(node) {
3192
5014
  }
3193
5015
  return false;
3194
5016
  }
3195
- function exprIsTainted(node, tainted) {
5017
+ var NO_RETURNERS = /* @__PURE__ */ new Set();
5018
+ function exprIsTainted(node, tainted, returners = NO_RETURNERS) {
3196
5019
  if (node === void 0) return false;
3197
- if (isTaintSource(node)) return true;
5020
+ if (isTaintSource(node) || isFrameworkMemberTaint(node) || isFrameworkTaintCall(node) || isMcpArgsTaintSource(node)) {
5021
+ return true;
5022
+ }
3198
5023
  switch (node.type) {
5024
+ case "AwaitExpression":
5025
+ return exprIsTainted(node.argument, tainted, returners);
3199
5026
  case "Identifier":
3200
5027
  return tainted.has(node.name);
3201
5028
  case "MemberExpression":
3202
- return exprIsTainted(node.object, tainted);
5029
+ return exprIsTainted(node.object, tainted, returners);
3203
5030
  case "CallExpression":
3204
5031
  if (isSchemaValidatorCall(node)) return false;
3205
- return node.arguments.some((arg) => arg.type !== "SpreadElement" && exprIsTainted(arg, tainted));
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
+ );
3206
5039
  case "NewExpression":
3207
- return node.arguments.some((arg) => arg.type !== "SpreadElement" && exprIsTainted(arg, tainted));
5040
+ if (exprIsTainted(node.callee, tainted, returners)) return true;
5041
+ return node.arguments.some(
5042
+ (arg) => arg.type !== "SpreadElement" && exprIsTainted(arg, tainted, returners)
5043
+ );
3208
5044
  case "TemplateLiteral":
3209
- 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);
3210
5048
  case "BinaryExpression":
3211
- 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));
3212
5050
  case "AssignmentExpression":
3213
- return exprIsTainted(node.right, tainted);
5051
+ return exprIsTainted(node.right, tainted, returners);
3214
5052
  case "LogicalExpression":
3215
- return exprIsTainted(node.left, tainted) || exprIsTainted(node.right, tainted);
5053
+ return exprIsTainted(node.left, tainted, returners) || exprIsTainted(node.right, tainted, returners);
3216
5054
  case "ConditionalExpression":
3217
- return exprIsTainted(node.consequent, tainted) || exprIsTainted(node.alternate, tainted);
5055
+ return exprIsTainted(node.consequent, tainted, returners) || exprIsTainted(node.alternate, tainted, returners);
3218
5056
  case "ChainExpression":
3219
- return exprIsTainted(node.expression, tainted);
5057
+ return exprIsTainted(node.expression, tainted, returners);
3220
5058
  case "TSAsExpression":
3221
5059
  case "TSNonNullExpression":
3222
5060
  case "TSTypeAssertion":
3223
- return exprIsTainted(node.expression, tainted);
5061
+ return exprIsTainted(node.expression, tainted, returners);
3224
5062
  default:
3225
5063
  return false;
3226
5064
  }
3227
5065
  }
3228
- function exprIsSanitized(node, tainted, names, isCall) {
5066
+ function exprIsSanitized(node, tainted, names, isCall, returners = NO_RETURNERS) {
3229
5067
  if (node === void 0) return true;
3230
5068
  if (isCall(node)) return true;
3231
5069
  if (node.type === "Identifier") return names.has(node.name) || !tainted.has(node.name);
3232
- if (!exprIsTainted(node, tainted)) return true;
5070
+ if (!exprIsTainted(node, tainted, returners)) return true;
3233
5071
  if (node.type === "BinaryExpression" && node.operator === "+") {
3234
5072
  return exprIsSanitized(node.left, tainted, names, isCall) && exprIsSanitized(node.right, tainted, names, isCall);
3235
5073
  }
@@ -3261,16 +5099,50 @@ function walkScope(scope, visit) {
3261
5099
  }
3262
5100
  inner(scope);
3263
5101
  }
3264
- function analyzeScope(scope, seed = []) {
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) {
3265
5130
  const tainted = new Set(seed);
5131
+ seedDestructuredRequestParams(scope, tainted);
5132
+ seedFrameworkParams(scope, tainted);
5133
+ seedMcpToolParams(scope, tainted);
3266
5134
  const urlSanitized = /* @__PURE__ */ new Set();
3267
5135
  const xssSanitized = /* @__PURE__ */ new Set();
5136
+ const nosqlSanitized = /* @__PURE__ */ new Set();
3268
5137
  let changed = true;
3269
5138
  while (changed) {
3270
5139
  changed = false;
3271
5140
  walkScope(scope, (node) => {
3272
5141
  if (node.type === "VariableDeclarator") {
3273
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
+ }
3274
5146
  if (isSchemaValidatorCall(init)) return;
3275
5147
  if (isUrlSanitizerCall(init)) {
3276
5148
  for (const name of boundNames(node.id)) urlSanitized.add(name);
@@ -3278,7 +5150,7 @@ function analyzeScope(scope, seed = []) {
3278
5150
  if (isXssSanitizerCall(init)) {
3279
5151
  for (const name of boundNames(node.id)) xssSanitized.add(name);
3280
5152
  }
3281
- if (exprIsTainted(init, tainted)) {
5153
+ if (exprIsTainted(init, tainted, returners)) {
3282
5154
  for (const name of boundNames(node.id)) {
3283
5155
  if (!tainted.has(name)) {
3284
5156
  tainted.add(name);
@@ -3288,6 +5160,9 @@ function analyzeScope(scope, seed = []) {
3288
5160
  }
3289
5161
  }
3290
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
+ }
3291
5166
  if (isSchemaValidatorCall(node.right)) return;
3292
5167
  if (isUrlSanitizerCall(node.right)) {
3293
5168
  for (const name of boundNames(node.left)) urlSanitized.add(name);
@@ -3295,7 +5170,7 @@ function analyzeScope(scope, seed = []) {
3295
5170
  if (isXssSanitizerCall(node.right)) {
3296
5171
  for (const name of boundNames(node.left)) xssSanitized.add(name);
3297
5172
  }
3298
- if (exprIsTainted(node.right, tainted)) {
5173
+ if (exprIsTainted(node.right, tainted, returners)) {
3299
5174
  for (const name of boundNames(node.left)) {
3300
5175
  if (!tainted.has(name)) {
3301
5176
  tainted.add(name);
@@ -3333,6 +5208,16 @@ function analyzeScope(scope, seed = []) {
3333
5208
  hasPasswordName = true;
3334
5209
  }
3335
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
+ }
3336
5221
  walkScope(scope, (node) => {
3337
5222
  if (node.type === "VariableDeclarator" && node.init !== null && isRequestObjectNode(node.init)) {
3338
5223
  for (const name of boundNames(node.id)) requestObjects.add(name);
@@ -3340,18 +5225,110 @@ function analyzeScope(scope, seed = []) {
3340
5225
  if (node.type === "AssignmentExpression" && isRequestObjectNode(node.right)) {
3341
5226
  for (const name of boundNames(node.left)) requestObjects.add(name);
3342
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
+ }
5234
+ });
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
+ }
3343
5257
  });
3344
- return { tainted, pathSanitized, urlSanitized, xssSanitized, hasPasswordName, requestObjects };
5258
+ return yes;
3345
5259
  }
3346
- function analyzeFile(ast) {
5260
+ var MAX_TAINT_HOPS = 5;
5261
+ function analyzeFile(ast, extraReturners = NO_RETURNERS) {
3347
5262
  attachParents(ast);
3348
5263
  const facts = /* @__PURE__ */ new WeakMap();
3349
- facts.set(ast, analyzeScope(ast));
5264
+ const functions = [];
3350
5265
  walk(ast, (node) => {
3351
- if (isFunctionNode(node)) facts.set(node, analyzeScope(node));
5266
+ if (isFunctionNode(node)) functions.push(node);
3352
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
+ }
3353
5317
  return facts;
3354
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
+ }
3355
5332
  function analyzeFunctionWithSeed(fn, seed) {
3356
5333
  attachParents(fn);
3357
5334
  return analyzeScope(fn, seed);
@@ -3362,12 +5339,15 @@ function factsFor(node, map) {
3362
5339
  pathSanitized: /* @__PURE__ */ new Set(),
3363
5340
  urlSanitized: /* @__PURE__ */ new Set(),
3364
5341
  xssSanitized: /* @__PURE__ */ new Set(),
5342
+ nosqlSanitized: /* @__PURE__ */ new Set(),
3365
5343
  hasPasswordName: false,
3366
- requestObjects: /* @__PURE__ */ new Set()
5344
+ requestObjects: /* @__PURE__ */ new Set(),
5345
+ returners: /* @__PURE__ */ new Set()
3367
5346
  };
3368
5347
  }
3369
5348
  function nodeIsTainted(node, map) {
3370
- return exprIsTainted(node, factsFor(node, map).tainted);
5349
+ const facts = factsFor(node, map);
5350
+ return exprIsTainted(node, facts.tainted, facts.returners);
3371
5351
  }
3372
5352
  function nodeIsPathSanitized(node, map) {
3373
5353
  if (node.type !== "Identifier") return false;
@@ -3375,14 +5355,21 @@ function nodeIsPathSanitized(node, map) {
3375
5355
  }
3376
5356
  function nodeIsSanitizedFor(node, map, kind) {
3377
5357
  const facts = factsFor(node, map);
3378
- if (kind === "url") return exprIsSanitized(node, facts.tainted, facts.urlSanitized, isUrlSanitizerCall);
3379
- return exprIsSanitized(node, facts.tainted, facts.xssSanitized, isXssSanitizerCall);
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);
3380
5362
  }
3381
5363
  function nodeIsRequestObject(node, map) {
3382
- if (isRequestObjectNode(node)) return true;
5364
+ if (isRequestObjectNode(node) || isFrameworkRequestObject(node)) return true;
3383
5365
  if (node.type === "Identifier") return factsFor(node, map).requestObjects.has(node.name);
3384
5366
  return false;
3385
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
+ }
3386
5373
 
3387
5374
  // src/modules/sast/interproc.ts
3388
5375
  var MAX_HOPS = 5;
@@ -3722,6 +5709,22 @@ var authRule = {
3722
5709
  node,
3723
5710
  message: `Route ${path ?? "(dynamic)"} touches database/user data with no auth middleware in the chain.`
3724
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
+ });
3725
5728
  }
3726
5729
  };
3727
5730
  }
@@ -3730,6 +5733,8 @@ var authRule = {
3730
5733
  // src/modules/sast/rules/cmdi.ts
3731
5734
  var ALWAYS_SHELL = /* @__PURE__ */ new Set(["exec", "execSync"]);
3732
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"]);
3733
5738
  function optionsObject(node) {
3734
5739
  for (const arg of node.arguments) {
3735
5740
  if (arg.type === "ObjectExpression") return arg;
@@ -3760,6 +5765,45 @@ function argumentTainted(node, isTainted) {
3760
5765
  }
3761
5766
  return false;
3762
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
+ }
3763
5807
  var cmdiRule = {
3764
5808
  meta: {
3765
5809
  id: "SAST-CMDI-001",
@@ -3769,23 +5813,37 @@ var cmdiRule = {
3769
5813
  docs: "https://cwe.mitre.org/data/definitions/78.html"
3770
5814
  },
3771
5815
  create(ctx) {
5816
+ const tainted = (n) => ctx.isTainted(n);
3772
5817
  return {
3773
5818
  CallExpression(node) {
3774
5819
  if (node.type !== "CallExpression") return;
3775
5820
  const name = calleeName(node);
3776
5821
  if (name === void 0) return;
3777
- const shell = ALWAYS_SHELL.has(name) || OPTIONAL_SHELL.has(name) && hasShellTrue(node);
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;
3778
5825
  if (!shell) return;
3779
5826
  if (node.callee.type === "MemberExpression") {
3780
5827
  const obj = node.callee.object;
3781
5828
  const fromChild = obj.type === "Identifier" && (obj.name === "child_process" || obj.name === "cp") || propName(node.callee) !== void 0;
3782
- if (!fromChild && !ALWAYS_SHELL.has(name) && !OPTIONAL_SHELL.has(name)) return;
5829
+ if (!fromChild && !ALWAYS_SHELL.has(name) && !OPTIONAL_SHELL.has(name) && !dashC && !execaCall) {
5830
+ return;
5831
+ }
3783
5832
  }
3784
- if (!argumentTainted(node, (n) => ctx.isTainted(n))) return;
5833
+ if (!argumentTainted(node, tainted) && !dashC) return;
3785
5834
  ctx.report({
3786
5835
  node,
3787
5836
  message: "Tainted input reaches a shell command (exec/execSync/spawn with shell: true)."
3788
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;
5843
+ ctx.report({
5844
+ node,
5845
+ message: "Tainted input reaches a shell tagged template that does not auto-escape interpolations."
5846
+ });
3789
5847
  }
3790
5848
  };
3791
5849
  }
@@ -3816,7 +5874,18 @@ var cryptoRule = {
3816
5874
  }
3817
5875
  return;
3818
5876
  }
3819
- if (calleeName(node) !== "createHash") return;
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;
3820
5889
  const algo = literalString(node.arguments[0]);
3821
5890
  if (algo === void 0 || !WEAK_HASH.has(algo.toLowerCase())) return;
3822
5891
  if (!ctx.functionHasPasswordName(node)) return;
@@ -3941,6 +6010,90 @@ var MONGO_SINKS = /* @__PURE__ */ new Set([
3941
6010
  "remove",
3942
6011
  "aggregate"
3943
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
+ }
3944
6097
  var nosqliRule = {
3945
6098
  meta: {
3946
6099
  id: "SAST-NOSQLI-001",
@@ -3950,24 +6103,58 @@ var nosqliRule = {
3950
6103
  docs: "https://cwe.mitre.org/data/definitions/943.html"
3951
6104
  },
3952
6105
  create(ctx) {
6106
+ const middleware = fileHasMongoSanitizeMiddleware(ctx.source);
6107
+ const isSafe = (n) => isNosqlSafeValue(n, ctx.source, (x) => ctx.isNosqlSanitized(x));
3953
6108
  return {
3954
6109
  CallExpression(node) {
3955
6110
  if (node.type !== "CallExpression") return;
3956
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;
3957
6124
  if (name === void 0 || !MONGO_SINKS.has(name)) return;
6125
+ if (!isMongoReceiver(node, ctx.source)) return;
3958
6126
  const filter = node.arguments[0];
3959
6127
  if (filter === void 0 || filter.type === "SpreadElement") return;
3960
- if (!ctx.isRequestObject(filter)) return;
3961
- ctx.report({
3962
- node,
3963
- message: "req.body/req.query is passed directly into a Mongo-style filter."
3964
- });
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
+ }
3965
6151
  }
3966
6152
  };
3967
6153
  }
3968
6154
  };
3969
6155
 
3970
6156
  // src/modules/sast/rules/path.ts
6157
+ var EXTRACT_SINKS = /* @__PURE__ */ new Set(["extractAllTo", "extractAll", "extract"]);
3971
6158
  var PATH_SINKS = /* @__PURE__ */ new Set([
3972
6159
  "sendFile",
3973
6160
  "readFile",
@@ -4009,7 +6196,15 @@ var pathRule = {
4009
6196
  CallExpression(node) {
4010
6197
  if (node.type !== "CallExpression") return;
4011
6198
  const name = calleeName(node);
4012
- if (name === void 0 || !PATH_SINKS.has(name)) return;
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;
4013
6208
  const pathArg = node.arguments[0];
4014
6209
  if (pathArg === void 0 || pathArg.type === "SpreadElement") return;
4015
6210
  if (!ctx.isTainted(pathArg)) return;
@@ -4080,10 +6275,34 @@ var redirectRule = {
4080
6275
  };
4081
6276
 
4082
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
+ }
4083
6292
  function isSqlSink2(node) {
4084
- if (node.type !== "CallExpression") return false;
4085
6293
  const name = calleeName(node);
4086
- return name === "query" || name === "execute";
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;
4087
6306
  }
4088
6307
  var sqliRule = {
4089
6308
  meta: {
@@ -4192,6 +6411,19 @@ var xssRule = {
4192
6411
  return {
4193
6412
  CallExpression(node) {
4194
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
+ }
4195
6427
  const name = calleeName(node);
4196
6428
  if (name !== "send" && name !== "write") return;
4197
6429
  const arg = node.arguments[0];
@@ -4207,497 +6439,845 @@ var xssRule = {
4207
6439
  }
4208
6440
  };
4209
6441
 
4210
- // src/ai/injection.ts
4211
- var PHRASES = [/ignore previous/i, /mark as safe/i, /ai reviewer/i];
4212
- function isCommentLine(line) {
4213
- const trimmed = line.trim();
4214
- return trimmed.startsWith("//") || trimmed.startsWith("/*") || trimmed.startsWith("*");
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
+ };
4215
6451
  }
4216
- function scanPromptInjection(file, source) {
4217
- const findings = [];
4218
- const lines = source.split(/\r?\n/);
4219
- lines.forEach((line, index) => {
4220
- if (!isCommentLine(line)) return;
4221
- if (!PHRASES.some((p) => p.test(line))) return;
4222
- findings.push({
4223
- id: makeFindingId("AI-INJECT-001", file, `${index + 1}|${line.trim()}`),
4224
- ruleId: "AI-INJECT-001",
4225
- module: "sast",
4226
- title: "Prompt-injection comment aimed at AI reviewers",
4227
- severity: "medium",
4228
- confidence: "high",
4229
- file,
4230
- line: index + 1,
4231
- snippet: line.trim().slice(0, 120),
4232
- message: "Code contains text attempting to manipulate AI reviewers.",
4233
- remediation: "Remove comments that try to instruct an AI reviewer.",
4234
- references: ["AI-INJECT-001"],
4235
- source: "deterministic"
4236
- });
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
+ }
4237
6475
  });
4238
- return findings;
6476
+ return hit;
4239
6477
  }
4240
-
4241
- // src/modules/sast/engine.ts
4242
- var sastRules = [
4243
- sqliRule,
4244
- cmdiRule,
4245
- pathRule,
4246
- evalRule,
4247
- jwtRule,
4248
- cryptoRule,
4249
- nosqliRule,
4250
- ssrfRule,
4251
- redirectRule,
4252
- protoRule,
4253
- xssRule,
4254
- authRule
4255
- ];
4256
- var SOURCE_EXT = /\.[cm]?[jt]sx?$/;
4257
- async function parseSource(code) {
4258
- const { parse } = await import('@typescript-eslint/typescript-estree');
4259
- return parse(code, {
4260
- loc: true,
4261
- range: true,
4262
- comment: false,
4263
- jsx: true,
4264
- errorOnUnknownASTType: false,
4265
- 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;
4266
6482
  });
6483
+ return hit;
4267
6484
  }
4268
- function snippetOf(source, node) {
4269
- if (node.range === void 0) return "";
4270
- return source.slice(node.range[0], node.range[1]).replace(/\s+/g, " ").trim().slice(0, 120);
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;
4271
6491
  }
4272
- function findingsFromAst(filename, source, ast) {
4273
- const facts = analyzeFile(ast);
4274
- const findings = [];
4275
- for (const rule of sastRules) {
4276
- const ctx = {
4277
- filename,
4278
- source,
4279
- report: ({ node, message }) => {
4280
- const snippet = snippetOf(source, node);
4281
- findings.push({
4282
- id: makeFindingId(rule.meta.id, filename, `${node.loc?.start.line ?? 0}|${snippet}`),
4283
- ruleId: rule.meta.id,
4284
- module: "sast",
4285
- title: rule.meta.title,
4286
- severity: rule.meta.severity,
4287
- confidence: "high",
4288
- cwe: rule.meta.cwe,
4289
- file: filename,
4290
- line: node.loc?.start.line,
4291
- column: node.loc?.start.column,
4292
- snippet,
4293
- message,
4294
- remediation: `${rule.meta.title}. See ${rule.meta.docs}`,
4295
- references: [rule.meta.docs, rule.meta.cwe]
4296
- });
4297
- },
4298
- isTainted: (node) => nodeIsTainted(node, facts),
4299
- isPathSanitized: (node) => nodeIsPathSanitized(node, facts),
4300
- isSanitizedFor: (node, kind) => nodeIsSanitizedFor(node, facts, kind),
4301
- isRequestObject: (node) => nodeIsRequestObject(node, facts),
4302
- functionHasPasswordName: (node) => factsFor(node, facts).hasPasswordName,
4303
- assignedName: (node) => assignedNameOf(node)
4304
- };
4305
- const visitors = rule.create(ctx);
4306
- walk(ast, (node) => {
4307
- visitors[node.type]?.(node);
4308
- });
4309
- }
4310
- findings.push(...scanPromptInjection(filename, source));
4311
- 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);
4312
6496
  }
4313
- function findingsFromTemplate(filename, source) {
4314
- const findings = [];
4315
- const ctx = {
4316
- filename,
4317
- source,
4318
- report: ({ node, message }) => {
4319
- const snippet = source.split(/\r?\n/)[(node.loc?.start.line ?? 1) - 1]?.trim().slice(0, 120) ?? "";
4320
- findings.push({
4321
- id: makeFindingId(xssRule.meta.id, filename, `${node.loc?.start.line ?? 0}|${snippet}`),
4322
- ruleId: xssRule.meta.id,
4323
- module: "sast",
4324
- title: xssRule.meta.title,
4325
- severity: xssRule.meta.severity,
4326
- confidence: "high",
4327
- cwe: xssRule.meta.cwe,
4328
- file: filename,
4329
- line: node.loc?.start.line,
4330
- column: node.loc?.start.column,
4331
- snippet,
4332
- message,
4333
- remediation: `${xssRule.meta.title}. See ${xssRule.meta.docs}`,
4334
- references: [xssRule.meta.docs, xssRule.meta.cwe]
4335
- });
4336
- },
4337
- isTainted: () => false,
4338
- isPathSanitized: () => false,
4339
- isSanitizedFor: () => false,
4340
- isRequestObject: () => false,
4341
- functionHasPasswordName: () => false,
4342
- assignedName: () => void 0
4343
- };
4344
- xssRule.create(ctx);
4345
- 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;
4346
6501
  }
4347
- var sastModule = {
4348
- name: "sast",
4349
- async run(ctx) {
4350
- const findings = [];
4351
- const parsed = [];
4352
- for (const rel of ctx.files) {
4353
- const isSource = SOURCE_EXT.test(rel);
4354
- const isTemplate = TEMPLATE_EXT.test(rel);
4355
- if (!isSource && !isTemplate) continue;
4356
- let source;
4357
- try {
4358
- source = await readFile(join(ctx.cwd, rel), "utf8");
4359
- } catch {
4360
- continue;
4361
- }
4362
- if (source.length > 1024 * 1024 || source.includes("\0")) continue;
4363
- if (isTemplate) {
4364
- findings.push(...findingsFromTemplate(rel, source));
4365
- continue;
4366
- }
4367
- try {
4368
- const ast = await parseSource(source);
4369
- parsed.push({ file: rel, source, ast });
4370
- findings.push(...findingsFromAst(rel, source, ast));
4371
- } catch {
4372
- }
4373
- }
4374
- findings.push(...runInterproc(parsed));
4375
- return findings;
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;
4376
6510
  }
4377
- };
4378
-
4379
- // src/modules/secrets/mask.ts
4380
- function everyMatch(re, text) {
4381
- re.lastIndex = 0;
4382
- return [...text.matchAll(re)];
4383
6511
  }
4384
- function maskSecret(secret) {
4385
- return `${secret.slice(0, 4)}****`;
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;
6523
+ }
6524
+ }
6525
+ }
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
+ };
6533
+ }
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
+ };
4386
6549
  }
4387
- function foldStringConcat(line) {
4388
- return line.replace(/(["'`])\s*\+\s*(["'`])/g, "");
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
+ };
4389
6602
  }
4390
- function shannonEntropy(value) {
4391
- if (value.length === 0) return 0;
4392
- const counts = /* @__PURE__ */ new Map();
4393
- for (const char of value) {
4394
- counts.set(char, (counts.get(char) ?? 0) + 1);
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;
4395
6629
  }
4396
- let entropy = 0;
4397
- for (const count of counts.values()) {
4398
- const p = count / value.length;
4399
- 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]);
4400
6632
  }
4401
- return entropy;
6633
+ return void 0;
4402
6634
  }
4403
-
4404
- // src/modules/secrets/rules/aiKey.ts
4405
- var PATTERN = /\bsk-ant-[A-Za-z0-9_-]{16,}\b|\bsk-[A-Za-z0-9_-]{20,}\b/g;
4406
- var aiKeyRule = {
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 = {
4407
6670
  meta: {
4408
- id: "SECRET-AIKEY-001",
4409
- title: "Hardcoded AI provider API key (OpenAI/Anthropic-style)",
4410
- severity: "high",
4411
- confidence: "high",
4412
- cwe: "CWE-798"
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"
4413
6676
  },
4414
- check(ctx) {
4415
- ctx.lines.forEach((line, index) => {
4416
- for (const match of everyMatch(PATTERN, line)) {
4417
- ctx.report({ line: index + 1, secret: match[0] });
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
+ }
4418
6708
  }
4419
- });
6709
+ };
4420
6710
  }
4421
6711
  };
4422
-
4423
- // src/modules/secrets/rules/aws.ts
4424
- var PATTERN2 = /\bAKIA[0-9A-Z]{16}\b/g;
4425
- var awsRule = {
6712
+ var deserRule = {
4426
6713
  meta: {
4427
- id: "SECRET-AWS-001",
4428
- title: "Hardcoded AWS access key ID",
6714
+ id: "SAST-DESER-001",
6715
+ title: "Insecure deserialization of untrusted data",
4429
6716
  severity: "high",
4430
- confidence: "high",
4431
- cwe: "CWE-798"
6717
+ cwe: "CWE-502",
6718
+ docs: "https://cwe.mitre.org/data/definitions/502.html"
4432
6719
  },
4433
- check(ctx) {
4434
- ctx.lines.forEach((line, index) => {
4435
- for (const match of everyMatch(PATTERN2, line)) {
4436
- ctx.report({ line: index + 1, secret: match[0] });
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
+ }
4437
6753
  }
4438
- });
6754
+ };
4439
6755
  }
4440
6756
  };
4441
-
4442
- // src/modules/secrets/rules/dbUrl.ts
4443
- var PATTERN3 = /\b(?:postgres(?:ql)?|mysql|mariadb|mongodb(?:\+srv)?|redis|amqp):\/\/([^\s'"@/:]+):([^\s'"@]+)@/g;
4444
- function isPlaceholderPassword(password) {
4445
- if (password.includes("${")) return true;
4446
- if (/^<[^>]+>$/.test(password)) return true;
4447
- if (/^\{[^}]+\}$/.test(password)) return true;
4448
- return false;
4449
- }
4450
- var dbUrlRule = {
6757
+ var xxeRule = {
4451
6758
  meta: {
4452
- id: "SECRET-DBURL-001",
4453
- title: "Database connection string with inline password",
6759
+ id: "SAST-XXE-001",
6760
+ title: "XML parser with external entities enabled on tainted input",
4454
6761
  severity: "high",
4455
- confidence: "high",
4456
- cwe: "CWE-798"
6762
+ cwe: "CWE-611",
6763
+ docs: "https://cwe.mitre.org/data/definitions/611.html"
4457
6764
  },
4458
- check(ctx) {
4459
- ctx.lines.forEach((line, index) => {
4460
- for (const match of everyMatch(PATTERN3, line)) {
4461
- const password = match[2];
4462
- if (password === void 0 || isPlaceholderPassword(password)) continue;
4463
- ctx.report({ line: index + 1, secret: password });
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
+ }
4464
6788
  }
4465
- });
6789
+ };
4466
6790
  }
4467
6791
  };
4468
-
4469
- // src/modules/secrets/rules/entropy.ts
4470
- var ASSIGNMENT = /\b([A-Za-z_$][\w$]*)\s*[:=]\s*["'`]([^"'`]{20,})["'`]/g;
4471
- var SECRET_NAME = /(key|secret|token|passw(or)?d|pwd|credential)/i;
4472
- var ENTROPY_THRESHOLD = 4.5;
4473
- var entropyRule = {
6792
+ var uploadRule = {
4474
6793
  meta: {
4475
- id: "SECRET-ENTROPY-001",
4476
- title: "High-entropy string assigned to a secret-like variable",
4477
- severity: "medium",
4478
- confidence: "low",
4479
- cwe: "CWE-798"
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"
4480
6799
  },
4481
- check(ctx) {
4482
- ctx.lines.forEach((line, index) => {
4483
- for (const match of everyMatch(ASSIGNMENT, line)) {
4484
- const [, name, value] = match;
4485
- if (name === void 0 || value === void 0) continue;
4486
- if (!SECRET_NAME.test(name)) continue;
4487
- if (value.includes(" ") || value.includes("${")) continue;
4488
- if (shannonEntropy(value) <= ENTROPY_THRESHOLD) continue;
4489
- ctx.report({ line: index + 1, secret: value });
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
+ }
4490
6833
  }
4491
- });
6834
+ };
4492
6835
  }
4493
6836
  };
4494
-
4495
- // src/modules/secrets/rules/envFile.ts
4496
- var ENV_FILE = /^\.env(\..+)?$/;
4497
- var envFileRule = {
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 = {
4498
6842
  meta: {
4499
- id: "SECRET-ENVFILE-001",
4500
- title: "Committed .env file",
4501
- severity: "high",
4502
- confidence: "high",
4503
- cwe: "CWE-312"
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"
4504
6848
  },
4505
- check(ctx) {
4506
- const base = ctx.path.split("/").pop() ?? ctx.path;
4507
- ENV_FILE.lastIndex = 0;
4508
- if (ENV_FILE.test(base) && base !== ".env.example" && base !== ".env.backup") {
4509
- ctx.report({
4510
- line: 1,
4511
- message: `${ctx.path} is tracked in the repository. .env files hold live credentials and must never be committed.`
4512
- });
4513
- }
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
+ };
4514
6877
  }
4515
6878
  };
4516
-
4517
- // src/modules/secrets/rules/github.ts
4518
- var PATTERN4 = /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36}\b/g;
4519
- var githubRule = {
6879
+ var csrfRule = {
4520
6880
  meta: {
4521
- id: "SECRET-GITHUB-001",
4522
- title: "Hardcoded GitHub token",
6881
+ id: "SAST-CSRF-001",
6882
+ title: "Cookie-authenticated state-changing route without CSRF protection",
4523
6883
  severity: "high",
4524
- confidence: "high",
4525
- cwe: "CWE-798"
6884
+ cwe: "CWE-352",
6885
+ docs: "https://cwe.mitre.org/data/definitions/352.html"
4526
6886
  },
4527
- check(ctx) {
4528
- ctx.lines.forEach((line, index) => {
4529
- for (const match of everyMatch(PATTERN4, line)) {
4530
- ctx.report({ line: index + 1, secret: match[0] });
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
+ });
4531
6903
  }
4532
- });
6904
+ };
4533
6905
  }
4534
6906
  };
4535
-
4536
- // src/modules/secrets/rules/google.ts
4537
- var PATTERN5 = /\bAIza[0-9A-Za-z_-]{35}\b/g;
4538
- var googleRule = {
6907
+ var massAssignRule = {
4539
6908
  meta: {
4540
- id: "SECRET-GOOGLE-001",
4541
- title: "Hardcoded Google API key",
6909
+ id: "SAST-MASSASSIGN-001",
6910
+ title: "Mass assignment of req.body without an allowlist or schema",
4542
6911
  severity: "high",
4543
- confidence: "high",
4544
- cwe: "CWE-798"
6912
+ cwe: "CWE-915",
6913
+ docs: "https://cwe.mitre.org/data/definitions/915.html"
4545
6914
  },
4546
- check(ctx) {
4547
- ctx.lines.forEach((line, index) => {
4548
- for (const match of everyMatch(PATTERN5, line)) {
4549
- ctx.report({ line: index + 1, secret: match[0] });
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.` });
4550
6935
  }
4551
- });
6936
+ };
4552
6937
  }
4553
6938
  };
4554
-
4555
- // src/modules/secrets/rules/jwtSecret.ts
4556
- var PATTERN6 = /\b(?:jwt[_-]?secret|jwt[_-]?key|token[_-]?secret|signing[_-]?key)\b\s*[:=]\s*["'`]([^"'`]{8,})["'`]/gi;
4557
- var jwtSecretRule = {
6939
+ var timingRule = {
4558
6940
  meta: {
4559
- id: "SECRET-JWT-001",
4560
- title: "JWT secret assigned from a string literal",
6941
+ id: "SAST-TIMING-001",
6942
+ title: "Secret compared with === instead of timingSafeEqual",
4561
6943
  severity: "medium",
4562
- confidence: "medium",
4563
- cwe: "CWE-798"
6944
+ cwe: "CWE-208",
6945
+ docs: "https://cwe.mitre.org/data/definitions/208.html"
4564
6946
  },
4565
- check(ctx) {
4566
- ctx.lines.forEach((line, index) => {
4567
- for (const match of everyMatch(PATTERN6, line)) {
4568
- const secret = match[1];
4569
- if (secret !== void 0) {
4570
- ctx.report({ line: index + 1, secret });
4571
- }
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
+ });
4572
6958
  }
4573
- });
6959
+ };
4574
6960
  }
4575
6961
  };
4576
-
4577
- // src/modules/secrets/rules/privateKey.ts
4578
- var PATTERN7 = /-----BEGIN (?:[A-Z]+ )*PRIVATE KEY-----/;
4579
- var privateKeyRule = {
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 = {
4580
6978
  meta: {
4581
- id: "SECRET-PRIVKEY-001",
4582
- title: "Private key material committed to the repository",
4583
- severity: "critical",
4584
- confidence: "high",
4585
- cwe: "CWE-321"
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"
4586
6984
  },
4587
- check(ctx) {
4588
- ctx.lines.forEach((line, index) => {
4589
- if (line.match(PATTERN7) !== null) {
4590
- ctx.report({
4591
- line: index + 1,
4592
- message: "A PEM private key block is committed to the repository."
4593
- });
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
+ }
4594
7012
  }
4595
- });
7013
+ };
4596
7014
  }
4597
7015
  };
4598
-
4599
- // src/modules/secrets/rules/slack.ts
4600
- var PATTERN8 = /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g;
4601
- var slackRule = {
7016
+ var GQL_SERVERS = /* @__PURE__ */ new Set(["ApolloServer", "GraphQLServer", "YogaServer", "graphqlHTTP"]);
7017
+ var gqlRule = {
4602
7018
  meta: {
4603
- id: "SECRET-SLACK-001",
4604
- title: "Hardcoded Slack token",
4605
- severity: "high",
4606
- confidence: "high",
4607
- cwe: "CWE-798"
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"
4608
7024
  },
4609
- check(ctx) {
4610
- ctx.lines.forEach((line, index) => {
4611
- for (const match of everyMatch(PATTERN8, line)) {
4612
- ctx.report({ line: index + 1, secret: match[0] });
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
+ });
4613
7049
  }
4614
- });
7050
+ };
4615
7051
  }
4616
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
+ ];
4617
7078
 
4618
- // src/modules/secrets/rules/stripe.ts
4619
- var PATTERN9 = /\bsk_(?:live|test)_[A-Za-z0-9]{16,}\b/g;
4620
- var stripeRule = {
4621
- meta: {
4622
- id: "SECRET-STRIPE-001",
4623
- title: "Hardcoded Stripe secret key",
4624
- severity: "high",
4625
- confidence: "high",
4626
- cwe: "CWE-798"
4627
- },
4628
- check(ctx) {
4629
- ctx.lines.forEach((line, index) => {
4630
- for (const match of everyMatch(PATTERN9, line)) {
4631
- ctx.report({ line: index + 1, secret: match[0] });
4632
- }
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"
4633
7106
  });
4634
- }
4635
- };
7107
+ });
7108
+ return findings;
7109
+ }
4636
7110
 
4637
- // src/modules/secrets/index.ts
4638
- var secretsRules = [
4639
- awsRule,
4640
- stripeRule,
4641
- githubRule,
4642
- slackRule,
4643
- googleRule,
4644
- aiKeyRule,
4645
- privateKeyRule,
4646
- dbUrlRule,
4647
- jwtSecretRule,
4648
- entropyRule,
4649
- envFileRule
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
4650
7126
  ];
4651
- var 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.";
4652
- function scanTextForSecrets(path, text) {
4653
- const rawLines = text.split(/\r?\n/);
4654
- const lines = rawLines.map(foldStringConcat);
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);
4655
7145
  const findings = [];
4656
- for (const rule of secretsRules) {
4657
- rule.check({
4658
- path,
4659
- lines,
4660
- rawLines,
4661
- report: ({ line, secret, message }) => {
4662
- 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);
4663
7152
  findings.push({
4664
- id: makeFindingId(rule.meta.id, path, `${line}|${masked ?? "file"}`),
7153
+ id: makeFindingId(rule.meta.id, filename, `${node.loc?.start.line ?? 0}|${snippet}`),
4665
7154
  ruleId: rule.meta.id,
4666
- module: "secrets",
7155
+ module: "sast",
4667
7156
  title: rule.meta.title,
4668
7157
  severity: rule.meta.severity,
4669
- confidence: rule.meta.confidence,
7158
+ confidence: confidence ?? "high",
4670
7159
  cwe: rule.meta.cwe,
4671
- file: path,
4672
- line,
4673
- snippet: masked,
4674
- message: message ?? `${rule.meta.title}${masked !== void 0 ? ` (${masked})` : ""}.`,
4675
- remediation: REMEDIATION
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]
4676
7167
  });
4677
- }
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);
4678
7181
  });
4679
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);
4680
7220
  return findings;
4681
7221
  }
4682
- var MAX_FILE_SIZE = 1024 * 1024;
4683
- var secretsModule = {
4684
- name: "secrets",
7222
+ var sastModule = {
7223
+ name: "sast",
4685
7224
  async run(ctx) {
4686
7225
  const findings = [];
7226
+ const parsed = [];
4687
7227
  for (const rel of ctx.files) {
4688
- let text;
7228
+ const isSource = SOURCE_EXT.test(rel);
7229
+ const isTemplate = TEMPLATE_EXT.test(rel);
7230
+ if (!isSource && !isTemplate) continue;
7231
+ let source;
4689
7232
  try {
4690
- text = await readFile(join(ctx.cwd, rel), "utf8");
7233
+ source = await readFile(join(ctx.cwd, rel), "utf8");
4691
7234
  } catch {
4692
7235
  continue;
4693
7236
  }
4694
- if (text.length > MAX_FILE_SIZE || text.includes("\0")) continue;
4695
- findings.push(...scanTextForSecrets(rel, text));
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));
4696
7264
  }
7265
+ findings.push(...runInterproc(parsed));
4697
7266
  return findings;
4698
7267
  }
4699
7268
  };
4700
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
+
4701
7281
  // src/ai/fixtures.ts
4702
7282
  var RECORDED_FIXTURES = {
4703
7283
  triage: {
@@ -4849,58 +7429,61 @@ function cap(findings, max) {
4849
7429
  }
4850
7430
  async function applyTriage(findings, client, config, snippets) {
4851
7431
  const budget = cap(findings, config.maxFindings);
4852
- for (const finding of budget) {
4853
- const snippet = snippets[finding.file ?? ""] ?? finding.snippet ?? finding.message;
7432
+ for (const finding3 of budget) {
7433
+ const snippet = snippets[finding3.file ?? ""] ?? finding3.snippet ?? finding3.message;
4854
7434
  const res = await client.complete({
4855
7435
  mode: "triage",
4856
- prompt: `${finding.ruleId}
7436
+ prompt: `${finding3.ruleId}
4857
7437
  ${snippet}`
4858
7438
  });
4859
7439
  if (res === null) continue;
4860
7440
  if (client.useFixtures) {
4861
- const label = fixtureTriage(finding.ruleId);
4862
- finding.aiVerdict = label.verdict;
4863
- finding.aiReasoning = label.reasoning;
4864
- finding.exploitScenario = label.exploitScenario;
7441
+ const label = fixtureTriage(finding3.ruleId);
7442
+ finding3.aiVerdict = label.verdict;
7443
+ finding3.aiReasoning = label.reasoning;
7444
+ finding3.exploitScenario = label.exploitScenario;
4865
7445
  continue;
4866
7446
  }
4867
7447
  const live = asTriage(res.json);
4868
7448
  if (live === void 0) continue;
4869
- finding.aiVerdict = live.verdict;
4870
- finding.aiReasoning = live.reasoning;
4871
- finding.exploitScenario = live.exploitScenario;
7449
+ finding3.aiVerdict = live.verdict;
7450
+ finding3.aiReasoning = live.reasoning;
7451
+ finding3.exploitScenario = live.exploitScenario;
4872
7452
  }
4873
7453
  return findings;
4874
7454
  }
4875
7455
  async function applyExplain(findings, client, config, snippets) {
4876
7456
  const budget = cap(findings, config.maxFindings);
4877
- for (const finding of budget) {
4878
- const snippet = snippets[finding.file ?? ""] ?? finding.snippet ?? finding.message;
7457
+ for (const finding3 of budget) {
7458
+ const snippet = snippets[finding3.file ?? ""] ?? finding3.snippet ?? finding3.message;
4879
7459
  const res = await client.complete({
4880
7460
  mode: "explain",
4881
- prompt: `${finding.ruleId}
7461
+ prompt: `${finding3.ruleId}
4882
7462
  ${snippet}`
4883
7463
  });
4884
7464
  if (res === null) continue;
4885
7465
  if (client.useFixtures) {
4886
- finding.explanation = fixtureExplain(finding.ruleId);
7466
+ finding3.explanation = fixtureExplain(finding3.ruleId);
4887
7467
  continue;
4888
7468
  }
4889
7469
  const text = asExplanation(res.json);
4890
- if (text !== void 0) finding.explanation = text;
4891
- delete finding.aiVerdict;
4892
- delete finding.exploitScenario;
4893
- delete finding.aiReasoning;
7470
+ if (text !== void 0) finding3.explanation = text;
7471
+ delete finding3.aiVerdict;
7472
+ delete finding3.exploitScenario;
7473
+ delete finding3.aiReasoning;
4894
7474
  }
4895
7475
  return findings;
4896
7476
  }
7477
+
7478
+ // src/ai/logic.ts
7479
+ init_finding();
4897
7480
  var ROUTE_FILE = /(^|\/)(routes?|handlers?|controllers?)\//;
4898
- function clampAiFinding(finding) {
7481
+ function clampAiFinding(finding3) {
4899
7482
  return {
4900
- ...finding,
7483
+ ...finding3,
4901
7484
  source: "ai",
4902
7485
  confidence: "medium",
4903
- severity: finding.severity === "low" ? "low" : "medium"
7486
+ severity: finding3.severity === "low" ? "low" : "medium"
4904
7487
  };
4905
7488
  }
4906
7489
  async function runLogicHunt(cwd, files, client) {
@@ -4968,6 +7551,8 @@ ${text}`);
4968
7551
  }
4969
7552
 
4970
7553
  // src/ai/redact.ts
7554
+ init_mask();
7555
+ init_secrets();
4971
7556
  var REDACTED = "<REDACTED>";
4972
7557
  function redactSecrets(text) {
4973
7558
  const rawLines = text.split(/\r?\n/);
@@ -5149,6 +7734,9 @@ function applyBaseline(findings, baseline) {
5149
7734
  const known = new Set(baseline.findings.map((f) => f.id));
5150
7735
  return findings.filter((f) => !known.has(f.id));
5151
7736
  }
7737
+
7738
+ // src/core/config.ts
7739
+ init_finding();
5152
7740
  var CONFIG_FILE_NAME = "fullshield.config.json";
5153
7741
  var DEFAULT_CONFIG = {
5154
7742
  modules: [...MODULE_NAMES],
@@ -5338,28 +7926,9 @@ async function listStagedFiles(cwd) {
5338
7926
  }
5339
7927
  }
5340
7928
 
5341
- // src/core/suppression.ts
5342
- var IGNORE = /fullshield-ignore\s+([A-Z0-9-]+)\s*:\s*(\S+)/;
5343
- function parseIgnore(line) {
5344
- const match = IGNORE.exec(line);
5345
- if (match?.[1] === void 0 || match[2] === void 0) return void 0;
5346
- return { ruleId: match[1], reason: match[2] };
5347
- }
5348
- function applyInlineSuppressions(findings, sources) {
5349
- return findings.filter((finding) => {
5350
- if (finding.file === void 0 || finding.line === void 0) return true;
5351
- const text = sources[finding.file];
5352
- if (text === void 0) return true;
5353
- const lines = text.split(/\r?\n/);
5354
- const current = lines[finding.line - 1] ?? "";
5355
- const previous = finding.line >= 2 ? lines[finding.line - 2] ?? "" : "";
5356
- for (const line of [current, previous]) {
5357
- const ignore = parseIgnore(line);
5358
- if (ignore !== void 0 && ignore.ruleId === finding.ruleId) return false;
5359
- }
5360
- return true;
5361
- });
5362
- }
7929
+ // src/core/engine.ts
7930
+ init_suppression();
7931
+ init_finding();
5363
7932
  function packageVersion() {
5364
7933
  let dir = dirname(fileURLToPath(import.meta.url));
5365
7934
  for (let depth = 0; depth < 6; depth += 1) {
@@ -5378,16 +7947,201 @@ function packageVersion() {
5378
7947
  throw new Error("fullshield package.json not found");
5379
7948
  }
5380
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, finding3) {
8089
+ if (exception.ruleId !== finding3.ruleId) return false;
8090
+ if (exception.id !== void 0 && exception.id !== finding3.id) return false;
8091
+ if (exception.file !== void 0 && exception.file !== finding3.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((finding3) => {
8099
+ const severity = overrides[finding3.ruleId];
8100
+ return severity !== void 0 && severity !== finding3.severity ? { ...finding3, severity } : finding3;
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 finding3 of next) {
8111
+ if (exceptionMatches(exception, finding3)) suppressed.add(finding3.id);
8112
+ }
8113
+ }
8114
+ next = next.filter((finding3) => !suppressed.has(finding3.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
+
5381
8135
  // src/core/engine.ts
5382
8136
  var builtinModules = [depsModule, secretsModule, sastModule, configModule];
5383
8137
  builtinModules.map((m) => m.name);
5384
8138
  function dedupe2(findings) {
5385
8139
  const seen = /* @__PURE__ */ new Set();
5386
8140
  const out = [];
5387
- for (const finding of findings) {
5388
- if (!seen.has(finding.id)) {
5389
- seen.add(finding.id);
5390
- out.push(finding);
8141
+ for (const finding3 of findings) {
8142
+ if (!seen.has(finding3.id)) {
8143
+ seen.add(finding3.id);
8144
+ out.push(finding3);
5391
8145
  }
5392
8146
  }
5393
8147
  return out;
@@ -5411,7 +8165,11 @@ async function runScan(options = {}, modules = builtinModules) {
5411
8165
  config = { ...config, ai: { ...config.ai, maxFindings } };
5412
8166
  }
5413
8167
  }
5414
- const modulesRequested = options.modules ?? config.modules;
8168
+ const loadedPolicy = await loadPolicy(cwd, { experimental: options.experimentalPolicy });
8169
+ const modulesRequested = mergeRequiredModules(
8170
+ options.modules ?? config.modules,
8171
+ loadedPolicy?.requiredModules
8172
+ );
5415
8173
  let files = await walkFiles({ cwd, ignore: config.ignore });
5416
8174
  if (options.staged === true) {
5417
8175
  const staged = new Set(await listStagedFiles(cwd));
@@ -5440,6 +8198,27 @@ async function runScan(options = {}, modules = builtinModules) {
5440
8198
  collected.push(...await module.run(ctx));
5441
8199
  }
5442
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
+ }
5443
8222
  const sources = {};
5444
8223
  for (const file of new Set(collected.map((f) => f.file).filter((f) => f !== void 0))) {
5445
8224
  try {
@@ -5453,6 +8232,50 @@ async function runScan(options = {}, modules = builtinModules) {
5453
8232
  await writeBaseline(baselinePath, unsuppressed);
5454
8233
  }
5455
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 finding3 of findings) {
8243
+ if (finding3.module !== "secrets" || finding3.file === void 0 || secretById.has(finding3.id)) {
8244
+ continue;
8245
+ }
8246
+ let text = sources[finding3.file];
8247
+ if (text === void 0) {
8248
+ try {
8249
+ text = await readFile(join(cwd, finding3.file), "utf8");
8250
+ } catch {
8251
+ continue;
8252
+ }
8253
+ }
8254
+ for (const hit of scanTextForSecretHits2(finding3.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
+ }
5456
8279
  const aiMode = options.ai;
5457
8280
  const aiEnabled = aiMode === true || aiMode === "triage" || aiMode === "explain" || aiMode === "logic" || config.ai.enabled;
5458
8281
  if (aiEnabled) {
@@ -5482,41 +8305,34 @@ async function runScan(options = {}, modules = builtinModules) {
5482
8305
  modulesRequested: [...modulesRequested],
5483
8306
  modulesRun
5484
8307
  },
5485
- config
8308
+ config,
8309
+ policy: policyResult
5486
8310
  };
5487
8311
  }
5488
8312
  function scan(options = {}) {
5489
8313
  return runScan(options);
5490
8314
  }
5491
8315
 
5492
- // src/fixer/diff.ts
5493
- function unifiedDiff(filename, before, after) {
5494
- if (before === after) return "";
5495
- const oldLines = before.split("\n");
5496
- const newLines = after.split("\n");
5497
- const hunks = [`--- a/${filename}`, `+++ b/${filename}`];
5498
- hunks.push(`@@ -1,${oldLines.length} +1,${newLines.length} @@`);
5499
- for (const line of oldLines) hunks.push(`-${line}`);
5500
- for (const line of newLines) hunks.push(`+${line}`);
5501
- return hunks.join("\n");
5502
- }
8316
+ // src/agent/index.ts
8317
+ init_finding();
5503
8318
 
5504
8319
  // src/fixer/patches.ts
5505
- function proposeFix(finding, source) {
5506
- const file = finding.file ?? "";
5507
- if (finding.ruleId === "CONFIG-HELMET-001") return helmetFix(file, source);
5508
- if (finding.ruleId === "CONFIG-BODYLIMIT-001") return bodyLimitFix(file, source);
5509
- if (finding.ruleId === "SAST-CRYPTO-001") return cryptoFix(file, source);
5510
- if (finding.ruleId === "SECRET-ENVFILE-001") return gitignoreFix(file, source);
5511
- if (finding.fix !== void 0) return finding.fix;
5512
- if (finding.ruleId.startsWith("SECRET-") && finding.ruleId !== "SECRET-ENVFILE-001") {
5513
- return secretEnvFix(file, source, finding);
8320
+ function proposeFix(finding3, source) {
8321
+ const file = finding3.file ?? "";
8322
+ if (finding3.ruleId === "CONFIG-HELMET-001") return helmetFix(file, source);
8323
+ if (finding3.ruleId === "CONFIG-BODYLIMIT-001") return bodyLimitFix(file, source);
8324
+ if (finding3.ruleId === "SAST-CRYPTO-001") return cryptoFix(file, source);
8325
+ if (finding3.ruleId === "SECRET-ENVFILE-001") return gitignoreFix(file, source);
8326
+ if (finding3.fix !== void 0) return finding3.fix;
8327
+ if (finding3.ruleId.startsWith("SECRET-") && finding3.ruleId !== "SECRET-ENVFILE-001") {
8328
+ return secretEnvFix(file, source, finding3);
5514
8329
  }
5515
8330
  return void 0;
5516
8331
  }
5517
8332
  function helmetFix(file, source) {
5518
- if (/helmet\s*\(/.test(source)) return void 0;
5519
- let after = source;
8333
+ const normalized = normalizeNewlines(source);
8334
+ if (/helmet\s*\(/.test(normalized)) return void 0;
8335
+ let after = normalized;
5520
8336
  if (!/require\(\s*['"]helmet['"]\s*\)|from\s+['"]helmet['"]/.test(after)) {
5521
8337
  after = `const helmet = require('helmet');
5522
8338
  ${after}`;
@@ -5525,17 +8341,19 @@ ${after}`;
5525
8341
  after = after.replace(/const app = express\(\s*\);/, (m) => `${m}
5526
8342
  app.use(helmet());`);
5527
8343
  }
5528
- if (after === source) return void 0;
5529
- return { kind: "config-add", safe: false, diff: unifiedDiff(file, source, after) };
8344
+ if (after === normalized) return void 0;
8345
+ return { kind: "config-add", safe: false, diff: unifiedDiff(file, normalized, after) };
5530
8346
  }
5531
8347
  function bodyLimitFix(file, source) {
5532
- if (!/express\.json\(\s*\)/.test(source)) return void 0;
5533
- const after = source.replace(/express\.json\(\s*\)/g, "express.json({ limit: '100kb' })");
5534
- return { kind: "config-add", safe: true, diff: unifiedDiff(file, source, after) };
8348
+ const normalized = normalizeNewlines(source);
8349
+ if (!/express\.json\(\s*\)/.test(normalized)) return void 0;
8350
+ const after = normalized.replace(/express\.json\(\s*\)/g, "express.json({ limit: '100kb' })");
8351
+ return { kind: "config-add", safe: true, diff: unifiedDiff(file, normalized, after) };
5535
8352
  }
5536
8353
  function cryptoFix(file, source) {
5537
- if (!/Math\.random\(\)/.test(source)) return void 0;
5538
- let after = source;
8354
+ const normalized = normalizeNewlines(source);
8355
+ if (!/Math\.random\(\)/.test(normalized)) return void 0;
8356
+ let after = normalized;
5539
8357
  if (!/require\(\s*['"]crypto['"]\s*\)|from\s+['"]crypto['"]/.test(after)) {
5540
8358
  after = `const crypto = require('crypto');
5541
8359
  ${after}`;
@@ -5544,22 +8362,24 @@ ${after}`;
5544
8362
  /Math\.random\(\)\.toString\(\s*36\s*\)(?:\.slice\(\s*2\s*\))?/g,
5545
8363
  "crypto.randomUUID()"
5546
8364
  );
5547
- if (after === source) return void 0;
5548
- return { kind: "code-patch", safe: true, diff: unifiedDiff(file, source, after) };
8365
+ if (after === normalized) return void 0;
8366
+ return { kind: "code-patch", safe: true, diff: unifiedDiff(file, normalized, after) };
5549
8367
  }
5550
8368
  function gitignoreFix(file, source) {
5551
8369
  const target = file.endsWith(".gitignore") ? file : ".gitignore";
5552
- if (/(^|[\r\n])\.env(\r?\n|$)/.test(source)) return void 0;
5553
- const after = source.length === 0 || source.endsWith("\n") || source.endsWith("\r") ? `${source}.env
5554
- ` : `${source}
8370
+ const normalized = normalizeNewlines(source);
8371
+ if (/(^|\n)\.env(\n|$)/.test(normalized)) return void 0;
8372
+ const after = normalized.length === 0 || normalized.endsWith("\n") ? `${normalized}.env
8373
+ ` : `${normalized}
5555
8374
  .env
5556
8375
  `;
5557
- return { kind: "config-add", safe: true, diff: unifiedDiff(target, source, after) };
8376
+ return { kind: "config-add", safe: true, diff: unifiedDiff(target, normalized, after) };
5558
8377
  }
5559
- function secretEnvFix(file, source, finding) {
5560
- const envName = envNameFor(finding.ruleId);
5561
- const lines = source.split("\n");
5562
- const idx = (finding.line ?? 1) - 1;
8378
+ function secretEnvFix(file, source, finding3) {
8379
+ const envName = envNameFor(finding3.ruleId);
8380
+ const normalized = normalizeNewlines(source);
8381
+ const lines = normalized.split("\n");
8382
+ const idx = (finding3.line ?? 1) - 1;
5563
8383
  const line = lines[idx];
5564
8384
  if (line === void 0) return void 0;
5565
8385
  const replaced = line.replace(
@@ -5569,7 +8389,7 @@ function secretEnvFix(file, source, finding) {
5569
8389
  if (replaced === line) return void 0;
5570
8390
  const afterLines = [...lines];
5571
8391
  afterLines[idx] = replaced;
5572
- return { kind: "code-patch", safe: false, diff: unifiedDiff(file, source, afterLines.join("\n")) };
8392
+ return { kind: "code-patch", safe: false, diff: unifiedDiff(file, normalized, afterLines.join("\n")) };
5573
8393
  }
5574
8394
  function envNameFor(ruleId) {
5575
8395
  if (ruleId.includes("AWS")) return "AWS_ACCESS_KEY_ID";
@@ -5583,9 +8403,9 @@ function envNameFor(ruleId) {
5583
8403
  return "SECRET";
5584
8404
  }
5585
8405
  function applyUnifiedDiff(source, diff) {
5586
- const plus = diff.split("\n").filter((line) => line.startsWith("+") && !line.startsWith("+++")).map((line) => line.slice(1));
8406
+ const plus = normalizeNewlines(diff).split("\n").filter((line) => line.startsWith("+") && !line.startsWith("+++")).map((line) => line.slice(1));
5587
8407
  if (plus.length === 0) return source;
5588
- return plus.join("\n");
8408
+ return applyEol(plus.join("\n"), detectEol(source));
5589
8409
  }
5590
8410
 
5591
8411
  // src/agent/index.ts
@@ -5606,6 +8426,9 @@ function assertLocalAgentApi(url) {
5606
8426
  function npmBin() {
5607
8427
  return process.platform === "win32" ? "npm.cmd" : "npm";
5608
8428
  }
8429
+ function gitPath(file) {
8430
+ return file.replace(/\\/g, "/");
8431
+ }
5609
8432
  function git(cwd, args) {
5610
8433
  return execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
5611
8434
  }
@@ -5639,10 +8462,10 @@ function defaultTests(cwd) {
5639
8462
  return { ok: false, output: [err.stderr, err.stdout, err.message].filter(Boolean).join("\n") };
5640
8463
  }
5641
8464
  }
5642
- function sourceFor(finding, cwd) {
5643
- const file = finding.file;
8465
+ function sourceFor(finding3, cwd) {
8466
+ const file = finding3.file;
5644
8467
  if (file === void 0) return void 0;
5645
- const target = finding.ruleId === "SECRET-ENVFILE-001" ? ".gitignore" : file;
8468
+ const target = finding3.ruleId === "SECRET-ENVFILE-001" ? ".gitignore" : file;
5646
8469
  const path = join(cwd, target);
5647
8470
  const source = existsSync(path) ? readFileSync(path, "utf8") : "";
5648
8471
  return { file: target, source };
@@ -5683,18 +8506,18 @@ async function runAgent(options = {}) {
5683
8506
  const ranked = sortFindings(scanned.findings).filter((f) => severityRank(f.severity) >= severityRank("medium"));
5684
8507
  const prs = [];
5685
8508
  let stoppedOnTestFailure = false;
5686
- for (const finding of ranked) {
8509
+ for (const finding3 of ranked) {
5687
8510
  if (prs.length >= maxPrs) break;
5688
- const src = sourceFor(finding, cwd);
8511
+ const src = sourceFor(finding3, cwd);
5689
8512
  if (src === void 0) continue;
5690
8513
  const proposed = proposeFix(
5691
- finding.ruleId === "SECRET-ENVFILE-001" ? { ...finding, file: src.file } : finding,
8514
+ finding3.ruleId === "SECRET-ENVFILE-001" ? { ...finding3, file: src.file } : finding3,
5692
8515
  src.source
5693
8516
  );
5694
8517
  if (proposed === void 0 || proposed.safe !== true) continue;
5695
8518
  const files = [src.file].slice(0, maxFiles);
5696
8519
  if (files.length === 0) continue;
5697
- const branch = `fullshield/agent-${finding.ruleId.toLowerCase()}-${prs.length + 1}`;
8520
+ const branch = `fullshield/agent-${finding3.ruleId.toLowerCase()}-${prs.length + 1}`;
5698
8521
  if (PROTECTED.has(branch)) {
5699
8522
  throw new Error("refusing to use a protected branch name");
5700
8523
  }
@@ -5710,25 +8533,25 @@ async function runAgent(options = {}) {
5710
8533
  stoppedOnTestFailure = true;
5711
8534
  break;
5712
8535
  }
5713
- git(cwd, ["add", "--", ...files]);
5714
- git(cwd, ["-c", "user.email=agent@fullshield.local", "-c", "user.name=FullShield Agent", "commit", "-m", `fix: ${finding.ruleId}`]);
8536
+ git(cwd, ["add", "--", ...files.map(gitPath)]);
8537
+ git(cwd, ["-c", "user.email=agent@fullshield.local", "-c", "user.name=FullShield Agent", "commit", "-m", `fix: ${finding3.ruleId}`]);
5715
8538
  git(cwd, ["push", remote, `HEAD:${branch}`]);
5716
8539
  if (PROTECTED.has(branch)) {
5717
8540
  throw new Error("refusing to push a protected branch");
5718
8541
  }
5719
8542
  const opened = await openPull(options, {
5720
- title: `fix: ${finding.ruleId}`,
8543
+ title: `fix: ${finding3.ruleId}`,
5721
8544
  head: branch,
5722
8545
  base: PROTECTED.has(baseBranch) ? baseBranch : "main",
5723
8546
  body: [
5724
- finding.message,
5725
- finding.remediation,
5726
- `Proof: safe rule-based patch for ${finding.ruleId} on ${files.join(", ")}.`,
8547
+ finding3.message,
8548
+ finding3.remediation,
8549
+ `Proof: safe rule-based patch for ${finding3.ruleId} on ${files.join(", ")}.`,
5727
8550
  `Tests: ${tests.output.slice(0, 500) || "passed"}`,
5728
8551
  "This PR is not merged. FullShield never merges and never pushes to protected branches."
5729
8552
  ].join("\n\n")
5730
8553
  });
5731
- prs.push({ ...opened, branch, files, ruleId: finding.ruleId });
8554
+ prs.push({ ...opened, branch, files, ruleId: finding3.ruleId });
5732
8555
  git(cwd, ["checkout", baseBranch]);
5733
8556
  }
5734
8557
  return { prs, stoppedOnTestFailure, pushedToProtected: false, merged: false };