preflight-scavenger 0.2.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js ADDED
@@ -0,0 +1,2320 @@
1
+ #!/usr/bin/env node
2
+
3
+ const fs = require("node:fs/promises");
4
+ const { execFile } = require("node:child_process");
5
+ const https = require("node:https");
6
+ const os = require("node:os");
7
+ const path = require("node:path");
8
+ const readline = require("node:readline");
9
+ const { promisify } = require("node:util");
10
+ require("dotenv").config({ quiet: true });
11
+ const { Command } = require("commander");
12
+ const fg = require("fast-glob");
13
+ const { parse: parseSql } = require("pgsql-ast-parser");
14
+ const ParserBinding = require("web-tree-sitter");
15
+ const { colorize, createLogger } = require("./logger");
16
+ const {
17
+ findSqlConcatenations,
18
+ generateParameterizedFix: generateSqlParameterizedFix
19
+ } = require("./remediationEngine");
20
+ const {
21
+ analyzeTaintGraph,
22
+ findTaintSources,
23
+ isClientComponent: isTreeClientComponent,
24
+ parseModuleBoundaries,
25
+ resolveImportPath
26
+ } = require("./taintTracker");
27
+ const {
28
+ applyScaffoldTransaction,
29
+ findServerSideLeaks
30
+ } = require("./scaffoldEngine");
31
+ const { activateLicenseKey: activateDefaultLicenseKey } = require("./src/licensing/licenseManager");
32
+ const { startMcpServer: startDefaultMcpServer } = require("./src/mcp/server");
33
+ const packageJson = require("./package.json");
34
+
35
+ const execFileAsync = promisify(execFile);
36
+ const TreeSitterParser = ParserBinding.Parser || ParserBinding.default?.Parser || ParserBinding.default || ParserBinding;
37
+ const TreeSitterLanguage = ParserBinding.Language || ParserBinding.default?.Language;
38
+ const PREFLIGHT_CONFIG_FILE = ".preflight-config.json";
39
+ const PREFLIGHT_POLICY_FILE = "preflight.config.json";
40
+ const LEMON_SQUEEZY_VALIDATE_URL = "https://api.lemonsqueezy.com/v1/licenses/validate";
41
+ const PREFLIGHT_MCP_SERVER_NAME = "preflight-pro";
42
+ const PREFLIGHT_MCP_SERVER_CONFIG = {
43
+ command: "npx",
44
+ args: ["preflight-pro", "mcp"]
45
+ };
46
+ const UNIVERSAL_MCP_OUTPUT = [
47
+ "=========================================",
48
+ "🚀 PreFlight Pro MCP Ready",
49
+ "=========================================",
50
+ "For IDEs with a UI (Cursor, Windsurf, Zed):",
51
+ "1. Go to Settings -> MCP Servers",
52
+ "2. Click \"Add New\"",
53
+ "3. Name: PreFlight Pro",
54
+ "4. Type: command",
55
+ "5. Command: npx",
56
+ "6. Args: preflight-pro mcp",
57
+ "",
58
+ "Don't have a paid AI IDE? ",
59
+ "You can run this MCP completely free using open-source ",
60
+ "alternatives like OpenCode, RooCode (VS Code), or Cline. ",
61
+ "Just plug in the same npx command above!",
62
+ "========================================="
63
+ ].join("\n");
64
+ const TREE_SITTER_WASM_PATHS = {
65
+ javascript: path.join(__dirname, "wasm", "tree-sitter-javascript.wasm"),
66
+ typescript: path.join(__dirname, "wasm", "tree-sitter-typescript.wasm"),
67
+ tsx: path.join(__dirname, "wasm", "tree-sitter-tsx.wasm")
68
+ };
69
+ const SOURCE_EXTENSIONS = ["js", "jsx", "ts", "tsx"];
70
+ const SCAN_EXTENSIONS = new Set([...SOURCE_EXTENSIONS, "sql"]);
71
+ const CREDENTIAL_PATTERNS = [
72
+ {
73
+ id: "aws-access-key-id",
74
+ label: "AWS Access Key ID",
75
+ regex: /\bAKIA[0-9A-Z]{16}\b/,
76
+ replacement: "process.env.AWS_ACCESS_KEY_ID"
77
+ },
78
+ {
79
+ id: "stripe-secret-key",
80
+ label: "Stripe Secret Key",
81
+ regex: /\bsk_(?:test|live)_[A-Za-z0-9_=-]{8,}\b/,
82
+ replacement: "process.env.STRIPE_SECRET_KEY"
83
+ },
84
+ {
85
+ id: "openai-api-key",
86
+ label: "OpenAI API Key",
87
+ regex: /\bsk-proj-[A-Za-z0-9_-]{20,}\b/,
88
+ replacement: "process.env.OPENAI_API_KEY"
89
+ },
90
+ {
91
+ id: "anthropic-api-key",
92
+ label: "Anthropic API Key",
93
+ regex: /\bsk-ant-(?:api03|oat01)-[A-Za-z0-9_-]{20,}\b/,
94
+ replacement: "process.env.ANTHROPIC_API_KEY"
95
+ },
96
+ {
97
+ id: "github-token",
98
+ label: "GitHub Personal Access Token",
99
+ regex: /\b(ghp|github_pat)_[a-zA-Z0-9]{36,}\b/,
100
+ replacement: "process.env.GITHUB_TOKEN"
101
+ },
102
+ {
103
+ id: "slack-token",
104
+ label: "Slack Bot/User Token",
105
+ regex: /\bxox[baprs]-[0-9]{10,13}-[a-zA-Z0-9]+\b/,
106
+ replacement: "process.env.SLACK_TOKEN"
107
+ },
108
+ {
109
+ id: "google-api-key",
110
+ label: "Google Cloud / Maps API Key",
111
+ regex: /\bAIza[0-9A-Za-z\-_]{35}\b/,
112
+ replacement: "process.env.GOOGLE_API_KEY"
113
+ },
114
+ {
115
+ id: "twilio-api-key",
116
+ label: "Twilio API Key",
117
+ regex: /\bSK[a-z0-9]{32}\b/,
118
+ replacement: "process.env.TWILIO_API_KEY"
119
+ },
120
+ {
121
+ id: "sendgrid-api-key",
122
+ label: "SendGrid API Key",
123
+ regex: /\bSG\.[a-zA-Z0-9_-]{22}\.[a-zA-Z0-9_-]{43}\b/,
124
+ replacement: "process.env.SENDGRID_API_KEY"
125
+ },
126
+ {
127
+ id: "postgres-uri",
128
+ label: "PostgreSQL Connection URI",
129
+ regex: /postgres:\/\/[a-zA-Z0-9_-]+:[a-zA-Z0-9_-]+@[a-zA-Z0-9_.-]+:[0-9]+\/[a-zA-Z0-9_-]+/,
130
+ replacement: "process.env.DATABASE_URL"
131
+ }
132
+ ];
133
+ const SERVICE_ROLE_SECRET_PATTERNS = [
134
+ /\bsupabase[_-]?service[_-]?role\b/i,
135
+ /\bservice[_-]?role[_-]?key\b/i
136
+ ];
137
+ const DATABASE_URL_PATTERN = /^(?:postgresql|postgres|mysql|mongodb\+srv):\/\/\S+/i;
138
+ const SERVICE_ROLE_NAME_PATTERN = /(?:^|[_-])(?:supabase[_-]?)?service[_-]?role(?:[_-]?key)?(?:$|[_-])/i;
139
+ const SARIF_REPORT_NAME = "preflight-report.sarif";
140
+ const SARIF_RULES = {
141
+ "frontend-secret": {
142
+ id: "frontend-secret",
143
+ name: "Exposed frontend secret",
144
+ shortDescription: {
145
+ text: "Frontend code contains a secret value or service role reference."
146
+ },
147
+ fullDescription: {
148
+ text: "Secrets in client-side JavaScript can be bundled and exposed to users."
149
+ },
150
+ helpUri: "https://preflight.local/rules/frontend-secret",
151
+ defaultConfiguration: {
152
+ level: "error"
153
+ },
154
+ properties: {
155
+ precision: "high",
156
+ securitySeverity: "9.0",
157
+ tags: ["security", "secret", "nextjs"]
158
+ }
159
+ },
160
+ "backend-secret": {
161
+ id: "backend-secret",
162
+ name: "Hardcoded backend secret",
163
+ shortDescription: {
164
+ text: "Backend code contains a database URL or hardcoded JWT secret."
165
+ },
166
+ fullDescription: {
167
+ text: "Database credentials and JWT secrets should be loaded from environment variables or a secret manager."
168
+ },
169
+ helpUri: "https://preflight.local/rules/backend-secret",
170
+ defaultConfiguration: {
171
+ level: "error"
172
+ },
173
+ properties: {
174
+ precision: "high",
175
+ securitySeverity: "9.3",
176
+ tags: ["security", "secret", "backend"]
177
+ }
178
+ },
179
+ "missing-rls": {
180
+ id: "missing-rls",
181
+ name: "Missing Supabase Row Level Security",
182
+ shortDescription: {
183
+ text: "A Supabase table is created without enabling Row Level Security."
184
+ },
185
+ fullDescription: {
186
+ text: "Supabase tables in exposed schemas should enable Row Level Security before deploy."
187
+ },
188
+ helpUri: "https://preflight.local/rules/missing-rls",
189
+ defaultConfiguration: {
190
+ level: "error"
191
+ },
192
+ properties: {
193
+ precision: "high",
194
+ securitySeverity: "8.7",
195
+ tags: ["security", "supabase", "rls"]
196
+ }
197
+ },
198
+ "sql-injection": {
199
+ id: "sql-injection",
200
+ name: "Unsafe SQL string concatenation",
201
+ shortDescription: {
202
+ text: "SQL query text is built with JavaScript string concatenation."
203
+ },
204
+ fullDescription: {
205
+ text: "Concatenating user-controlled values into SQL text can allow SQL injection. Use parameterized queries."
206
+ },
207
+ helpUri: "https://preflight.local/rules/sql-injection",
208
+ defaultConfiguration: {
209
+ level: "error"
210
+ },
211
+ properties: {
212
+ precision: "high",
213
+ securitySeverity: "9.5",
214
+ tags: ["security", "sql-injection"]
215
+ }
216
+ },
217
+ "architectural-leak": {
218
+ id: "architectural-leak",
219
+ name: "Server-only code in client component",
220
+ shortDescription: {
221
+ text: "A Next.js client component executes server-only dependencies."
222
+ },
223
+ fullDescription: {
224
+ text: "Server-only modules such as fs, pg, and child_process should be moved behind a server action or backend route."
225
+ },
226
+ helpUri: "https://preflight.local/rules/architectural-leak",
227
+ defaultConfiguration: {
228
+ level: "error"
229
+ },
230
+ properties: {
231
+ precision: "high",
232
+ securitySeverity: "8.8",
233
+ tags: ["security", "nextjs", "architecture"]
234
+ }
235
+ },
236
+ "taint-violation": {
237
+ id: "taint-violation",
238
+ name: "Tainted secret crosses client boundary",
239
+ shortDescription: {
240
+ text: "A client component imports a value marked as a secret."
241
+ },
242
+ fullDescription: {
243
+ text: "Secrets and credential-shaped values should not flow into files marked with the Next.js use client directive."
244
+ },
245
+ helpUri: "https://preflight.local/rules/taint-violation",
246
+ defaultConfiguration: {
247
+ level: "error"
248
+ },
249
+ properties: {
250
+ precision: "high",
251
+ securitySeverity: "9.1",
252
+ tags: ["security", "nextjs", "taint"]
253
+ }
254
+ }
255
+ };
256
+
257
+ function toPosix(relativePath) {
258
+ return relativePath.split(path.sep).join("/");
259
+ }
260
+
261
+ function normalizePolicy(policy = {}) {
262
+ return {
263
+ ignorePaths: Array.isArray(policy.ignorePaths) ? policy.ignorePaths.filter((item) => typeof item === "string") : [],
264
+ ignoreRules: new Set(
265
+ Array.isArray(policy.ignoreRules) ? policy.ignoreRules.filter((item) => typeof item === "string") : []
266
+ )
267
+ };
268
+ }
269
+
270
+ async function loadPreflightPolicy(rootDir = process.cwd(), options = {}) {
271
+ const warn = options.warn || ((message) => createLogger({ stderr: process.stderr }).warn(message));
272
+ const configPath = path.join(path.resolve(rootDir), PREFLIGHT_POLICY_FILE);
273
+ try {
274
+ const raw = await fs.readFile(configPath, "utf8");
275
+ return normalizePolicy(JSON.parse(raw));
276
+ } catch (error) {
277
+ if (error.code === "ENOENT") {
278
+ return normalizePolicy();
279
+ }
280
+
281
+ warn("Warning: preflight.config.json contains invalid JSON and was ignored.");
282
+ return normalizePolicy();
283
+ }
284
+ }
285
+
286
+ function matchesIgnorePath(relativePath, ignorePattern) {
287
+ const normalizedPath = toPosix(relativePath).replace(/^\/+/, "");
288
+ const normalizedPattern = toPosix(ignorePattern).replace(/^\/+/, "");
289
+
290
+ if (!normalizedPattern) {
291
+ return false;
292
+ }
293
+
294
+ if (normalizedPattern.endsWith("/")) {
295
+ return normalizedPath.startsWith(normalizedPattern);
296
+ }
297
+
298
+ if (normalizedPattern.includes("*")) {
299
+ const escaped = normalizedPattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
300
+ return new RegExp(`^${escaped}$`).test(normalizedPath);
301
+ }
302
+
303
+ return (
304
+ normalizedPath === normalizedPattern ||
305
+ normalizedPath.startsWith(`${normalizedPattern}/`) ||
306
+ normalizedPath.includes(`/${normalizedPattern}/`)
307
+ );
308
+ }
309
+
310
+ function isIgnoredPath(relativePath, policy = normalizePolicy()) {
311
+ return policy.ignorePaths.some((ignorePath) => matchesIgnorePath(relativePath, ignorePath));
312
+ }
313
+
314
+ function applyPolicy(findings, policy = normalizePolicy(), rootDir = process.cwd()) {
315
+ return findings.filter((item) => {
316
+ const relativePath = toPosix(path.relative(path.resolve(rootDir), item.filePath));
317
+ return !policy.ignoreRules.has(item.ruleId) && !isIgnoredPath(relativePath, policy);
318
+ });
319
+ }
320
+
321
+ function isSourceFile(filePath) {
322
+ return SOURCE_EXTENSIONS.includes(path.extname(filePath).slice(1));
323
+ }
324
+
325
+ function isScannableChangedFile(filePath) {
326
+ return SCAN_EXTENSIONS.has(path.extname(filePath).slice(1).toLowerCase());
327
+ }
328
+
329
+ function isInsideNextFrontend(relativePath) {
330
+ const normalized = toPosix(relativePath);
331
+ return normalized.startsWith("app/") || normalized.startsWith("pages/");
332
+ }
333
+
334
+ function isPagesApiRoute(relativePath) {
335
+ return toPosix(relativePath).startsWith("pages/api/");
336
+ }
337
+
338
+ function isAppApiRoute(relativePath) {
339
+ return toPosix(relativePath).startsWith("app/api/");
340
+ }
341
+
342
+ function isBackendApiRoute(relativePath) {
343
+ return isAppApiRoute(relativePath) || isPagesApiRoute(relativePath);
344
+ }
345
+
346
+ function isClientComponent(relativePath, source) {
347
+ const normalized = toPosix(relativePath);
348
+
349
+ if (normalized.startsWith("pages/")) {
350
+ return !isPagesApiRoute(relativePath);
351
+ }
352
+
353
+ if (!normalized.startsWith("app/")) {
354
+ return false;
355
+ }
356
+
357
+ return hasUseClientDirective(source);
358
+ }
359
+
360
+ function hasUseClientDirective(source) {
361
+ const withoutBom = source.replace(/^\uFEFF/, "");
362
+ const statementPattern = /^\s*(?:(?:\/\/[^\n]*|\/\*[\s\S]*?\*\/)\s*)*["']use client["']\s*;?/;
363
+ return statementPattern.test(withoutBom);
364
+ }
365
+
366
+ let treeSitterReady;
367
+ let treeSitterLanguages;
368
+
369
+ async function initializeTreeSitterLanguages() {
370
+ if (!treeSitterReady) {
371
+ treeSitterReady = (async () => {
372
+ if (typeof TreeSitterParser.init === "function") {
373
+ await TreeSitterParser.init();
374
+ }
375
+
376
+ treeSitterLanguages = {
377
+ javascript: await TreeSitterLanguage.load(TREE_SITTER_WASM_PATHS.javascript),
378
+ typescript: await TreeSitterLanguage.load(TREE_SITTER_WASM_PATHS.typescript),
379
+ tsx: await TreeSitterLanguage.load(TREE_SITTER_WASM_PATHS.tsx)
380
+ };
381
+ return treeSitterLanguages;
382
+ })();
383
+ }
384
+
385
+ return treeSitterReady;
386
+ }
387
+
388
+ function getTreeSitterLanguageKeyForFile(filePath) {
389
+ const extension = path.extname(filePath).toLowerCase();
390
+ if (extension === ".tsx") {
391
+ return "tsx";
392
+ }
393
+
394
+ if (extension === ".ts") {
395
+ return "typescript";
396
+ }
397
+
398
+ return "javascript";
399
+ }
400
+
401
+ async function parseWithRoutedTreeSitter(sourceCode, filePath) {
402
+ const languages = await initializeTreeSitterLanguages();
403
+ const parser = new TreeSitterParser();
404
+ parser.setLanguage(languages[getTreeSitterLanguageKeyForFile(filePath)]);
405
+ return parser.parse(sourceCode);
406
+ }
407
+
408
+ async function prepareSourceForScan(filePath, options = {}) {
409
+ const warn = options.warn || ((message) => createLogger({ stderr: process.stderr }).warn(message));
410
+ let sourceCode;
411
+
412
+ try {
413
+ sourceCode = await fs.readFile(filePath, "utf8");
414
+ } catch (error) {
415
+ warn(`Warning: could not scan ${filePath}: ${error.message}`);
416
+ return null;
417
+ }
418
+
419
+ try {
420
+ const tree = await parseWithRoutedTreeSitter(sourceCode, filePath);
421
+ tree.delete?.();
422
+ } catch (error) {
423
+ warn(`Warning: could not initialize parser for ${filePath}: ${error.message}`);
424
+ return null;
425
+ }
426
+
427
+ return sourceCode;
428
+ }
429
+
430
+ async function prepareParsedSourceForScan(filePath, options = {}) {
431
+ const sourceCode = await prepareSourceForScan(filePath, options);
432
+ if (sourceCode === null) {
433
+ return null;
434
+ }
435
+
436
+ try {
437
+ return {
438
+ sourceCode,
439
+ tree: await parseWithRoutedTreeSitter(sourceCode, filePath)
440
+ };
441
+ } catch (error) {
442
+ const warn = options.warn || ((message) => createLogger({ stderr: process.stderr }).warn(message));
443
+ warn(`Warning: could not initialize parser for ${filePath}: ${error.message}`);
444
+ return null;
445
+ }
446
+ }
447
+
448
+ function textFromByteRange(sourceCode, startIndex, endIndex) {
449
+ return Buffer.from(sourceCode, "utf8").subarray(startIndex, endIndex).toString("utf8");
450
+ }
451
+
452
+ function textFromNode(sourceCode, node) {
453
+ return sourceCode.slice(node.startIndex, node.endIndex);
454
+ }
455
+
456
+ function byteIndexFromStringIndex(sourceCode, stringIndex) {
457
+ return Buffer.byteLength(sourceCode.slice(0, stringIndex), "utf8");
458
+ }
459
+
460
+ function lineFromByteIndex(sourceCode, byteIndex) {
461
+ return textFromByteRange(sourceCode, 0, byteIndex).split(/\r?\n/).length;
462
+ }
463
+
464
+ function lineFromStringIndex(sourceCode, stringIndex) {
465
+ return sourceCode.slice(0, stringIndex).split(/\r?\n/).length;
466
+ }
467
+
468
+ function treeContainsUnsafeNode(node) {
469
+ if (!node) {
470
+ return false;
471
+ }
472
+
473
+ const isMissing = typeof node.isMissing === "function" ? node.isMissing() : node.isMissing === true;
474
+ if (node.type === "ERROR" || node.type === "MISSING" || isMissing) {
475
+ return true;
476
+ }
477
+
478
+ for (let index = 0; index < node.childCount; index += 1) {
479
+ if (treeContainsUnsafeNode(node.child(index))) {
480
+ return true;
481
+ }
482
+ }
483
+
484
+ return false;
485
+ }
486
+
487
+ async function assertSourceSyntaxSafe(filePath, sourceCode) {
488
+ const tree = await parseWithRoutedTreeSitter(sourceCode, filePath);
489
+ try {
490
+ if (treeContainsUnsafeNode(tree.rootNode)) {
491
+ throw new Error(`Remediation Context Violation: ${filePath}`);
492
+ }
493
+ } finally {
494
+ tree.delete?.();
495
+ }
496
+ }
497
+
498
+ function unquoteTreeString(rawString) {
499
+ return rawString.trim().replace(/^['"`]|['"`]$/g, "");
500
+ }
501
+
502
+ function walkTree(node, visitor) {
503
+ if (!node) {
504
+ return;
505
+ }
506
+
507
+ visitor(node);
508
+ for (let index = 0; index < node.childCount; index += 1) {
509
+ walkTree(node.child(index), visitor);
510
+ }
511
+ }
512
+
513
+ function childForField(node, fieldName) {
514
+ return typeof node.childForFieldName === "function" ? node.childForFieldName(fieldName) : null;
515
+ }
516
+
517
+ function detectSecret(value) {
518
+ if (typeof value !== "string") {
519
+ return null;
520
+ }
521
+
522
+ const credential = detectCredential(value);
523
+ if (credential) {
524
+ return credential.label;
525
+ }
526
+
527
+ const serviceRoleMatch = SERVICE_ROLE_SECRET_PATTERNS.find((pattern) => pattern.test(value));
528
+ if (serviceRoleMatch) {
529
+ return serviceRoleMatch.source;
530
+ }
531
+
532
+ const jwtRole = decodeSupabaseJwtRole(value);
533
+ if (jwtRole === "service_role") {
534
+ return "supabase service_role JWT";
535
+ }
536
+
537
+ return null;
538
+ }
539
+
540
+ function detectCredential(value) {
541
+ if (typeof value !== "string") {
542
+ return null;
543
+ }
544
+
545
+ return CREDENTIAL_PATTERNS.find((pattern) => pattern.regex.test(value)) || null;
546
+ }
547
+
548
+ function detectDatabaseUrl(value) {
549
+ return typeof value === "string" && DATABASE_URL_PATTERN.test(value) ? "database connection string" : null;
550
+ }
551
+
552
+ function decodeSupabaseJwtRole(value) {
553
+ if (!/^eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(value)) {
554
+ return null;
555
+ }
556
+
557
+ try {
558
+ const payload = JSON.parse(Buffer.from(value.split(".")[1], "base64url").toString("utf8"));
559
+ return payload.role || payload.iss || null;
560
+ } catch {
561
+ return null;
562
+ }
563
+ }
564
+
565
+ function finding({ ruleId, severity, filePath, line, message, evidence, tableName, fix, ...extra }) {
566
+ return {
567
+ ruleId,
568
+ severity,
569
+ filePath,
570
+ line,
571
+ message,
572
+ ...(evidence ? { evidence } : {}),
573
+ ...(tableName ? { tableName } : {}),
574
+ ...(fix ? { fix } : {}),
575
+ ...extra
576
+ };
577
+ }
578
+
579
+ class InvalidLicenseKeyError extends Error {
580
+ constructor(message = "Invalid License Key") {
581
+ super(message);
582
+ this.name = "InvalidLicenseKeyError";
583
+ }
584
+ }
585
+
586
+ function getPreflightConfigPath(homeDir = os.homedir()) {
587
+ return path.join(homeDir, PREFLIGHT_CONFIG_FILE);
588
+ }
589
+
590
+ async function readPreflightConfig(homeDir = os.homedir()) {
591
+ try {
592
+ const raw = await fs.readFile(getPreflightConfigPath(homeDir), "utf8");
593
+ return JSON.parse(raw);
594
+ } catch {
595
+ return null;
596
+ }
597
+ }
598
+
599
+ async function savePreflightConfig(homeDir, licenseKey) {
600
+ const configPath = getPreflightConfigPath(homeDir);
601
+ const payload = {
602
+ licenseKey,
603
+ validatedAt: new Date().toISOString()
604
+ };
605
+
606
+ await fs.writeFile(configPath, `${JSON.stringify(payload, null, 2)}\n`, {
607
+ encoding: "utf8",
608
+ mode: 0o600
609
+ });
610
+ }
611
+
612
+ function getCachedLicenseKey(config) {
613
+ if (!config || typeof config !== "object") {
614
+ return null;
615
+ }
616
+
617
+ const key = config.licenseKey || config.license_key;
618
+ return typeof key === "string" && key.trim() ? key.trim() : null;
619
+ }
620
+
621
+ async function promptForLicenseKey() {
622
+ const interfaceHandle = readline.createInterface({
623
+ input: process.stdin,
624
+ output: process.stdout
625
+ });
626
+
627
+ try {
628
+ return await new Promise((resolve) => {
629
+ interfaceHandle.question("Please buy PreFlight Repair Queue, then enter your PreFlight license key: ", (answer) => {
630
+ resolve(answer.trim());
631
+ });
632
+ });
633
+ } finally {
634
+ interfaceHandle.close();
635
+ }
636
+ }
637
+
638
+ function postFormUrlEncoded({ url, headers, body }) {
639
+ return new Promise((resolve, reject) => {
640
+ const request = https.request(
641
+ url,
642
+ {
643
+ method: "POST",
644
+ headers: {
645
+ ...headers,
646
+ "Content-Length": Buffer.byteLength(body)
647
+ }
648
+ },
649
+ (response) => {
650
+ let responseBody = "";
651
+ response.setEncoding("utf8");
652
+ response.on("data", (chunk) => {
653
+ responseBody += chunk;
654
+ });
655
+ response.on("end", () => {
656
+ try {
657
+ resolve(JSON.parse(responseBody));
658
+ } catch (error) {
659
+ reject(new Error(`Could not parse Lemon Squeezy response: ${error.message}`));
660
+ }
661
+ });
662
+ }
663
+ );
664
+
665
+ request.on("error", reject);
666
+ request.write(body);
667
+ request.end();
668
+ });
669
+ }
670
+
671
+ async function validateLicenseKey(licenseKey, postForm = postFormUrlEncoded) {
672
+ const body = new URLSearchParams({ license_key: licenseKey }).toString();
673
+ return postForm({
674
+ url: LEMON_SQUEEZY_VALIDATE_URL,
675
+ headers: {
676
+ Accept: "application/json",
677
+ "Content-Type": "application/x-www-form-urlencoded"
678
+ },
679
+ body
680
+ });
681
+ }
682
+
683
+ async function ensureLicenseVerified(options = {}) {
684
+ const homeDir = options.homeDir || os.homedir();
685
+ const promptForKey = options.promptForLicenseKey || promptForLicenseKey;
686
+ const validator = options.validateLicenseKey || validateLicenseKey;
687
+ const cachedKey = getCachedLicenseKey(await readPreflightConfig(homeDir));
688
+
689
+ if (cachedKey) {
690
+ const cachedValidation = await validator(cachedKey);
691
+ if (cachedValidation?.valid === true) {
692
+ return { valid: true, source: "config" };
693
+ }
694
+ }
695
+
696
+ const enteredKey = await promptForKey();
697
+ if (!enteredKey) {
698
+ throw new InvalidLicenseKeyError();
699
+ }
700
+
701
+ const validation = await validator(enteredKey);
702
+ if (validation?.valid === true) {
703
+ await savePreflightConfig(homeDir, enteredKey);
704
+ return { valid: true, source: "prompt" };
705
+ }
706
+
707
+ throw new InvalidLicenseKeyError();
708
+ }
709
+
710
+ function frontendSecretMessage(requireClientComponent) {
711
+ if (requireClientComponent) {
712
+ return "Potential secret exposed in a Next.js client-side component.";
713
+ }
714
+
715
+ return "Potential secret exposed in scanned JavaScript/TypeScript source.";
716
+ }
717
+
718
+ function getCredentialFix(sourceCode, node, credential) {
719
+ const rawString = textFromNode(sourceCode, node);
720
+ return {
721
+ kind: "credential",
722
+ credentialId: credential.id,
723
+ replacement: credential.replacement,
724
+ expectedText: rawString,
725
+ startByte: byteIndexFromStringIndex(sourceCode, node.startIndex),
726
+ endByte: byteIndexFromStringIndex(sourceCode, node.endIndex)
727
+ };
728
+ }
729
+
730
+ function scanCredentialStrings({ filePath, relativePath, sourceCode, tree, requireClientComponent }) {
731
+ if (!isSourceFile(filePath)) {
732
+ return [];
733
+ }
734
+
735
+ if (requireClientComponent && !isClientComponent(relativePath, sourceCode)) {
736
+ return [];
737
+ }
738
+
739
+ const findings = [];
740
+ walkTree(tree.rootNode, (node) => {
741
+ if (node.type === "string") {
742
+ const rawString = textFromNode(sourceCode, node);
743
+ const innerString = unquoteTreeString(rawString);
744
+ const credential = detectCredential(innerString);
745
+ const secretEvidence = credential?.label || detectSecret(innerString);
746
+ if (!secretEvidence) {
747
+ return;
748
+ }
749
+
750
+ findings.push(
751
+ finding({
752
+ ruleId: "frontend-secret",
753
+ severity: "critical",
754
+ filePath,
755
+ line: lineFromStringIndex(sourceCode, node.startIndex),
756
+ message: frontendSecretMessage(requireClientComponent),
757
+ evidence: secretEvidence,
758
+ fix: credential ? getCredentialFix(sourceCode, node, credential) : undefined
759
+ })
760
+ );
761
+ return;
762
+ }
763
+
764
+ if (node.type !== "identifier" && node.type !== "property_identifier") {
765
+ return;
766
+ }
767
+
768
+ const identifier = textFromNode(sourceCode, node);
769
+ if (!SERVICE_ROLE_NAME_PATTERN.test(identifier)) {
770
+ return;
771
+ }
772
+
773
+ findings.push(
774
+ finding({
775
+ ruleId: "frontend-secret",
776
+ severity: "critical",
777
+ filePath,
778
+ line: lineFromStringIndex(sourceCode, node.startIndex),
779
+ message: frontendSecretMessage(requireClientComponent),
780
+ evidence: "Supabase service role reference"
781
+ })
782
+ );
783
+ });
784
+
785
+ return dedupeFindings(findings);
786
+ }
787
+
788
+ function scanBackendStrings({ filePath, relativePath, sourceCode, tree, includeStandaloneBackend }) {
789
+ if (!isSourceFile(filePath)) {
790
+ return [];
791
+ }
792
+
793
+ if (!includeStandaloneBackend && !isBackendApiRoute(relativePath)) {
794
+ return [];
795
+ }
796
+
797
+ const findings = [];
798
+ walkTree(tree.rootNode, (node) => {
799
+ if (node.type === "call_expression") {
800
+ const callText = textFromNode(sourceCode, node);
801
+ const jwtMatch = callText.match(/\bjwt\.(sign|verify)\s*\([^,]+,\s*(["'`])[^"'`]+\2/);
802
+ if (jwtMatch) {
803
+ findings.push(
804
+ finding({
805
+ ruleId: "backend-secret",
806
+ severity: "critical",
807
+ filePath,
808
+ line: lineFromStringIndex(sourceCode, node.startIndex),
809
+ message: "JWT signing or verification uses a hardcoded secret.",
810
+ evidence: `jwt.${jwtMatch[1]} hardcoded secret`
811
+ })
812
+ );
813
+ }
814
+ return;
815
+ }
816
+
817
+ if (node.type !== "string") {
818
+ return;
819
+ }
820
+
821
+ const rawString = textFromNode(sourceCode, node);
822
+ const innerString = unquoteTreeString(rawString);
823
+ if (detectCredential(innerString)?.id === "postgres-uri") {
824
+ return;
825
+ }
826
+
827
+ const match = detectDatabaseUrl(innerString);
828
+ if (!match) {
829
+ return;
830
+ }
831
+
832
+ findings.push(
833
+ finding({
834
+ ruleId: "backend-secret",
835
+ severity: "critical",
836
+ filePath,
837
+ line: lineFromStringIndex(sourceCode, node.startIndex),
838
+ message: "Raw backend database connection string is hardcoded in source.",
839
+ evidence: match
840
+ })
841
+ );
842
+ });
843
+
844
+ return dedupeFindings(findings);
845
+ }
846
+
847
+ function scanBackendSource() {
848
+ return [];
849
+ }
850
+
851
+ function scanSecretSource() {
852
+ return [];
853
+ }
854
+
855
+ function scanFrontendSource() {
856
+ return [];
857
+ }
858
+
859
+ async function collectSourceFiles(rootDir, options = {}) {
860
+ const resolvedRoot = path.resolve(rootDir);
861
+ const policy = options.policy || normalizePolicy();
862
+ const relativePaths = await fg(["**/*.{js,jsx,ts,tsx}"], {
863
+ cwd: resolvedRoot,
864
+ absolute: false,
865
+ dot: false,
866
+ ignore: ["**/*.d.ts", "**/node_modules/**", "**/.next/**", "**/dist/**", "**/coverage/**"]
867
+ });
868
+
869
+ return relativePaths
870
+ .filter((relativePath) => !isIgnoredPath(relativePath, policy))
871
+ .map((relativePath) => ({
872
+ filePath: path.join(resolvedRoot, relativePath),
873
+ relativePath: toPosix(relativePath)
874
+ }))
875
+ .sort((left, right) => left.relativePath.localeCompare(right.relativePath));
876
+ }
877
+
878
+ function collectImportLineMap(tree, sourceCode) {
879
+ const importLines = new Map();
880
+
881
+ walkTree(tree.rootNode, (node) => {
882
+ if (node.type !== "import_statement") {
883
+ return;
884
+ }
885
+
886
+ const text = textFromNode(sourceCode, node);
887
+ for (const match of text.matchAll(/\b([A-Za-z_$][\w$]*)\b/g)) {
888
+ if (!importLines.has(match[1])) {
889
+ importLines.set(match[1], lineFromStringIndex(sourceCode, node.startIndex));
890
+ }
891
+ }
892
+ });
893
+
894
+ return importLines;
895
+ }
896
+
897
+ function credentialRegexesForTaint() {
898
+ return CREDENTIAL_PATTERNS.map((pattern) => new RegExp(pattern.regex.source, pattern.regex.flags));
899
+ }
900
+
901
+ function buildProjectGraphNode(filePath, sourceCode, tree) {
902
+ const boundaries = parseModuleBoundaries(tree.rootNode, sourceCode);
903
+ return {
904
+ isClient: isTreeClientComponent(tree.rootNode, sourceCode),
905
+ taintedSources: findTaintSources(tree.rootNode, sourceCode, credentialRegexesForTaint()),
906
+ imports: boundaries.imports.map((item) => ({
907
+ ...item,
908
+ source: resolveImportPath(filePath, item.source) || item.source
909
+ })),
910
+ reExports: (boundaries.reExports || []).map((item) => ({
911
+ ...item,
912
+ source: resolveImportPath(filePath, item.source) || item.source
913
+ })),
914
+ exports: boundaries.exports
915
+ };
916
+ }
917
+
918
+ function scanSqlConcatenationFindings({ filePath, sourceCode, tree }) {
919
+ return findSqlConcatenations(tree.rootNode, sourceCode).map((match) =>
920
+ finding({
921
+ ruleId: "sql-injection",
922
+ severity: "critical",
923
+ filePath,
924
+ line: lineFromByteIndex(sourceCode, match.startIndex),
925
+ message: "SQL query is built through string concatenation instead of parameter binding.",
926
+ evidence: match.rawSnippet,
927
+ fix: {
928
+ kind: "sql-remediation",
929
+ startByte: match.startIndex,
930
+ endByte: match.endIndex,
931
+ expectedText: match.rawSnippet,
932
+ rawSnippet: match.rawSnippet
933
+ }
934
+ })
935
+ );
936
+ }
937
+
938
+ function scanArchitecturalLeakFindings({ filePath, sourceCode, tree }) {
939
+ return findServerSideLeaks(tree.rootNode, sourceCode).map((leak) =>
940
+ finding({
941
+ ruleId: "architectural-leak",
942
+ severity: "high",
943
+ filePath,
944
+ line: lineFromByteIndex(sourceCode, leak.startIndex),
945
+ message: "Client component executes server-only code that should move behind a server action.",
946
+ evidence: leak.functionName,
947
+ fix: {
948
+ kind: "scaffold-server-action",
949
+ leak
950
+ }
951
+ })
952
+ );
953
+ }
954
+
955
+ function scanParsedSourceFile({ filePath, relativePath, sourceCode, tree }) {
956
+ const credentialRequiresClient =
957
+ isInsideNextFrontend(relativePath) && !isBackendApiRoute(relativePath);
958
+
959
+ return [
960
+ ...scanCredentialStrings({
961
+ filePath,
962
+ relativePath,
963
+ sourceCode,
964
+ tree,
965
+ requireClientComponent: credentialRequiresClient
966
+ }),
967
+ ...scanBackendStrings({
968
+ filePath,
969
+ relativePath,
970
+ sourceCode,
971
+ tree,
972
+ includeStandaloneBackend: true
973
+ }),
974
+ ...scanSqlConcatenationFindings({ filePath, sourceCode, tree }),
975
+ ...scanArchitecturalLeakFindings({ filePath, sourceCode, tree })
976
+ ];
977
+ }
978
+
979
+ function taintViolationsToFindings(violations, parsedFiles) {
980
+ return violations.map((violation) => {
981
+ const parsed = parsedFiles.get(violation.leakedFile);
982
+ const line = parsed?.importLines.get(violation.variable) || 1;
983
+ return finding({
984
+ ruleId: "taint-violation",
985
+ severity: "critical",
986
+ filePath: violation.leakedFile,
987
+ line,
988
+ message: `Client component imports tainted value ${violation.variable}.`,
989
+ evidence: `from ${violation.sourceFile}`,
990
+ variable: violation.variable,
991
+ sourceFile: violation.sourceFile,
992
+ leakedFile: violation.leakedFile
993
+ });
994
+ });
995
+ }
996
+
997
+ function dedupeFindings(findings) {
998
+ const seen = new Set();
999
+ return findings.filter((item) => {
1000
+ const key = `${item.ruleId}:${item.filePath}:${item.line}:${item.evidence || ""}`;
1001
+ if (seen.has(key)) {
1002
+ return false;
1003
+ }
1004
+
1005
+ seen.add(key);
1006
+ return true;
1007
+ });
1008
+ }
1009
+
1010
+ async function scanFrontendSecrets(rootDir, options = {}) {
1011
+ const policy = options.policy || normalizePolicy();
1012
+ const files = await fg(["{app,pages}/**/*.{js,jsx,ts,tsx}"], {
1013
+ cwd: rootDir,
1014
+ absolute: false,
1015
+ dot: false,
1016
+ ignore: ["app/api/**", "pages/api/**", "**/*.d.ts", "**/node_modules/**", "**/.next/**"]
1017
+ });
1018
+
1019
+ const results = [];
1020
+
1021
+ for (const relativePath of files) {
1022
+ if (isIgnoredPath(relativePath, policy)) {
1023
+ continue;
1024
+ }
1025
+
1026
+ const filePath = path.join(rootDir, relativePath);
1027
+ const parsed = await prepareParsedSourceForScan(filePath, { warn: options.warn });
1028
+ if (parsed === null) {
1029
+ continue;
1030
+ }
1031
+ try {
1032
+ results.push(...scanCredentialStrings({
1033
+ filePath,
1034
+ relativePath,
1035
+ sourceCode: parsed.sourceCode,
1036
+ tree: parsed.tree,
1037
+ requireClientComponent: true
1038
+ }));
1039
+ } finally {
1040
+ parsed.tree.delete?.();
1041
+ }
1042
+ }
1043
+
1044
+ return results;
1045
+ }
1046
+
1047
+ async function scanBackendSecrets(rootDir, options = {}) {
1048
+ const policy = options.policy || normalizePolicy();
1049
+ const files = await fg(["{app/api,pages/api}/**/*.{js,jsx,ts,tsx}"], {
1050
+ cwd: rootDir,
1051
+ absolute: false,
1052
+ dot: false,
1053
+ ignore: ["**/*.d.ts", "**/node_modules/**", "**/.next/**", "**/dist/**", "**/coverage/**"]
1054
+ });
1055
+
1056
+ const results = [];
1057
+
1058
+ for (const relativePath of files) {
1059
+ if (isIgnoredPath(relativePath, policy)) {
1060
+ continue;
1061
+ }
1062
+
1063
+ const filePath = path.join(rootDir, relativePath);
1064
+ const parsed = await prepareParsedSourceForScan(filePath, { warn: options.warn });
1065
+ if (parsed === null) {
1066
+ continue;
1067
+ }
1068
+ try {
1069
+ results.push(...scanBackendStrings({
1070
+ filePath,
1071
+ relativePath,
1072
+ sourceCode: parsed.sourceCode,
1073
+ tree: parsed.tree,
1074
+ includeStandaloneBackend: false
1075
+ }));
1076
+ } finally {
1077
+ parsed.tree.delete?.();
1078
+ }
1079
+ }
1080
+
1081
+ return results;
1082
+ }
1083
+
1084
+ async function scanStandaloneSecrets(rootDir, options = {}) {
1085
+ const policy = options.policy || normalizePolicy();
1086
+ const files = await fg(["**/*.{js,jsx,ts,tsx}"], {
1087
+ cwd: rootDir,
1088
+ absolute: false,
1089
+ dot: false,
1090
+ ignore: [
1091
+ "**/*.d.ts",
1092
+ "{app,pages}/**",
1093
+ "**/node_modules/**",
1094
+ "**/.next/**",
1095
+ "**/dist/**",
1096
+ "**/coverage/**"
1097
+ ]
1098
+ });
1099
+
1100
+ const results = [];
1101
+
1102
+ for (const relativePath of files) {
1103
+ if (isIgnoredPath(relativePath, policy)) {
1104
+ continue;
1105
+ }
1106
+
1107
+ const filePath = path.join(rootDir, relativePath);
1108
+ const parsed = await prepareParsedSourceForScan(filePath, { warn: options.warn });
1109
+ if (parsed === null) {
1110
+ continue;
1111
+ }
1112
+ try {
1113
+ results.push(...scanCredentialStrings({
1114
+ filePath,
1115
+ relativePath,
1116
+ sourceCode: parsed.sourceCode,
1117
+ tree: parsed.tree,
1118
+ requireClientComponent: false
1119
+ }));
1120
+ results.push(...scanBackendStrings({
1121
+ filePath,
1122
+ relativePath,
1123
+ sourceCode: parsed.sourceCode,
1124
+ tree: parsed.tree,
1125
+ includeStandaloneBackend: true
1126
+ }));
1127
+ } finally {
1128
+ parsed.tree.delete?.();
1129
+ }
1130
+ }
1131
+
1132
+ return results;
1133
+ }
1134
+
1135
+ async function directoryExists(rootDir, directoryName) {
1136
+ try {
1137
+ const stats = await fs.stat(path.join(rootDir, directoryName));
1138
+ return stats.isDirectory();
1139
+ } catch {
1140
+ return false;
1141
+ }
1142
+ }
1143
+
1144
+ async function shouldScanAsStandaloneSourceDirectory(rootDir) {
1145
+ const projectFolders = await Promise.all([
1146
+ directoryExists(rootDir, "app"),
1147
+ directoryExists(rootDir, "pages"),
1148
+ directoryExists(rootDir, "supabase")
1149
+ ]);
1150
+
1151
+ return !projectFolders.some(Boolean);
1152
+ }
1153
+
1154
+ function sqlStatementsWithOffsets(source) {
1155
+ const statements = [];
1156
+ let start = 0;
1157
+ let inSingleQuote = false;
1158
+ let inDoubleQuote = false;
1159
+ let inDollarQuote = null;
1160
+
1161
+ for (let index = 0; index < source.length; index += 1) {
1162
+ const current = source[index];
1163
+ const nextTwo = source.slice(index, index + 2);
1164
+
1165
+ if (!inSingleQuote && !inDoubleQuote && !inDollarQuote && nextTwo === "--") {
1166
+ const newline = source.indexOf("\n", index + 2);
1167
+ index = newline === -1 ? source.length : newline;
1168
+ continue;
1169
+ }
1170
+
1171
+ if (!inSingleQuote && !inDoubleQuote && !inDollarQuote && nextTwo === "/*") {
1172
+ const end = source.indexOf("*/", index + 2);
1173
+ index = end === -1 ? source.length : end + 1;
1174
+ continue;
1175
+ }
1176
+
1177
+ if (!inSingleQuote && !inDoubleQuote && current === "$") {
1178
+ const tag = source.slice(index).match(/^\$[A-Za-z_][A-Za-z0-9_]*\$|^\$\$/)?.[0];
1179
+ if (tag) {
1180
+ if (inDollarQuote === tag) {
1181
+ inDollarQuote = null;
1182
+ } else if (!inDollarQuote) {
1183
+ inDollarQuote = tag;
1184
+ }
1185
+ index += tag.length - 1;
1186
+ continue;
1187
+ }
1188
+ }
1189
+
1190
+ if (!inDoubleQuote && !inDollarQuote && current === "'" && source[index - 1] !== "\\") {
1191
+ inSingleQuote = !inSingleQuote;
1192
+ continue;
1193
+ }
1194
+
1195
+ if (!inSingleQuote && !inDollarQuote && current === '"') {
1196
+ inDoubleQuote = !inDoubleQuote;
1197
+ continue;
1198
+ }
1199
+
1200
+ if (!inSingleQuote && !inDoubleQuote && !inDollarQuote && current === ";") {
1201
+ statements.push({ sql: source.slice(start, index + 1), offset: start });
1202
+ start = index + 1;
1203
+ }
1204
+ }
1205
+
1206
+ const tail = source.slice(start).trim();
1207
+ if (tail) {
1208
+ statements.push({ sql: source.slice(start), offset: start });
1209
+ }
1210
+
1211
+ return statements;
1212
+ }
1213
+
1214
+ function lineAtOffset(source, offset) {
1215
+ return source.slice(0, offset).split(/\r?\n/).length;
1216
+ }
1217
+
1218
+ function normalizeSqlIdentifier(identifier) {
1219
+ if (!identifier) {
1220
+ return null;
1221
+ }
1222
+
1223
+ if (typeof identifier === "string") {
1224
+ return identifier.replace(/^"|"$/g, "").toLowerCase();
1225
+ }
1226
+
1227
+ const schema = identifier.schema ? normalizeSqlIdentifier(identifier.schema) : "public";
1228
+ const name = normalizeSqlIdentifier(identifier.name);
1229
+ return `${schema}.${name}`;
1230
+ }
1231
+
1232
+ function extractCreatedTables(source) {
1233
+ const tables = [];
1234
+
1235
+ for (const statement of sqlStatementsWithOffsets(source)) {
1236
+ let parsed;
1237
+ try {
1238
+ parsed = parseSql(statement.sql);
1239
+ } catch {
1240
+ continue;
1241
+ }
1242
+
1243
+ for (const astNode of parsed) {
1244
+ if (astNode.type === "create table") {
1245
+ const tableName = normalizeSqlIdentifier(astNode.name);
1246
+ if (tableName) {
1247
+ tables.push({
1248
+ tableName,
1249
+ line: lineAtOffset(source, statement.offset + statement.sql.search(/\S/))
1250
+ });
1251
+ }
1252
+ }
1253
+ }
1254
+ }
1255
+
1256
+ return tables;
1257
+ }
1258
+
1259
+ function extractRlsEnabledTables(source) {
1260
+ const enabledTables = new Set();
1261
+ const pattern =
1262
+ /alter\s+table\s+(?:only\s+)?(?:(?:"([^"]+)"|([A-Za-z_][\w$]*))\.)?(?:"([^"]+)"|([A-Za-z_][\w$]*))\s+enable\s+row\s+level\s+security\b/gi;
1263
+
1264
+ let match;
1265
+ while ((match = pattern.exec(source)) !== null) {
1266
+ const schema = (match[1] || match[2] || "public").toLowerCase();
1267
+ const table = (match[3] || match[4]).toLowerCase();
1268
+ enabledTables.add(`${schema}.${table}`);
1269
+ }
1270
+
1271
+ return enabledTables;
1272
+ }
1273
+
1274
+ function scanSqlSource({ filePath, source }) {
1275
+ const rlsEnabledTables = extractRlsEnabledTables(source);
1276
+
1277
+ return extractCreatedTables(source)
1278
+ .filter(({ tableName }) => !rlsEnabledTables.has(tableName))
1279
+ .map(({ line, tableName }) =>
1280
+ finding({
1281
+ ruleId: "missing-rls",
1282
+ severity: "high",
1283
+ filePath,
1284
+ line,
1285
+ tableName,
1286
+ message: `Table ${tableName} is created without enabling Row Level Security.`
1287
+ })
1288
+ );
1289
+ }
1290
+
1291
+ async function scanSupabaseMigrations(rootDir, options = {}) {
1292
+ const policy = options.policy || normalizePolicy();
1293
+ const files = await fg(["supabase/migrations/**/*.sql"], {
1294
+ cwd: rootDir,
1295
+ absolute: false,
1296
+ dot: false,
1297
+ ignore: ["**/node_modules/**"]
1298
+ });
1299
+
1300
+ const createdTables = [];
1301
+ const rlsEnabledTables = new Set();
1302
+
1303
+ for (const relativePath of files) {
1304
+ if (isIgnoredPath(relativePath, policy)) {
1305
+ continue;
1306
+ }
1307
+
1308
+ const filePath = path.join(rootDir, relativePath);
1309
+ const source = await fs.readFile(filePath, "utf8");
1310
+ const tables = extractCreatedTables(source).map((table) => ({ ...table, filePath }));
1311
+ createdTables.push(...tables);
1312
+
1313
+ for (const tableName of extractRlsEnabledTables(source)) {
1314
+ rlsEnabledTables.add(tableName);
1315
+ }
1316
+ }
1317
+
1318
+ return applyPolicy(createdTables
1319
+ .filter(({ tableName }) => !rlsEnabledTables.has(tableName))
1320
+ .map(({ filePath, line, tableName }) =>
1321
+ finding({
1322
+ ruleId: "missing-rls",
1323
+ severity: "high",
1324
+ filePath,
1325
+ line,
1326
+ tableName,
1327
+ message: `Table ${tableName} is created without enabling Row Level Security.`
1328
+ })
1329
+ ), policy, rootDir);
1330
+ }
1331
+
1332
+ async function fileExists(filePath) {
1333
+ try {
1334
+ const stats = await fs.stat(filePath);
1335
+ return stats.isFile();
1336
+ } catch {
1337
+ return false;
1338
+ }
1339
+ }
1340
+
1341
+ async function getChangedScanFiles(rootDir = process.cwd(), options = {}) {
1342
+ const resolvedRoot = path.resolve(rootDir);
1343
+ const policy = options.policy || normalizePolicy();
1344
+ const [diff, untracked] = await Promise.all([
1345
+ gitOutputOrEmpty(resolvedRoot, ["diff", "--name-only", "HEAD"], [/ambiguous argument ['"]?HEAD['"]?/i, /unknown revision/i]),
1346
+ gitOutputOrEmpty(resolvedRoot, ["ls-files", "--others", "--exclude-standard"])
1347
+ ]);
1348
+
1349
+ const candidates = new Set(
1350
+ `${diff}\n${untracked}`
1351
+ .split(/\r?\n/)
1352
+ .map((line) => line.trim())
1353
+ .filter(Boolean)
1354
+ );
1355
+
1356
+ const files = [];
1357
+ for (const relativePath of candidates) {
1358
+ if (!isScannableChangedFile(relativePath)) {
1359
+ continue;
1360
+ }
1361
+
1362
+ if (isIgnoredPath(relativePath, policy)) {
1363
+ continue;
1364
+ }
1365
+
1366
+ const filePath = path.join(resolvedRoot, relativePath);
1367
+ if (await fileExists(filePath)) {
1368
+ files.push({
1369
+ filePath,
1370
+ relativePath: toPosix(relativePath)
1371
+ });
1372
+ }
1373
+ }
1374
+
1375
+ return files.sort((a, b) => a.relativePath.localeCompare(b.relativePath));
1376
+ }
1377
+
1378
+ async function scanFiles(rootDir, files, options = {}) {
1379
+ const resolvedRoot = path.resolve(rootDir);
1380
+ const policy = options.policy || normalizePolicy();
1381
+ const findings = [];
1382
+ const projectGraph = {};
1383
+ const parsedFiles = new Map();
1384
+
1385
+ for (const file of files) {
1386
+ const filePath = file.filePath || path.join(resolvedRoot, file.relativePath);
1387
+ const relativePath = toPosix(file.relativePath || path.relative(resolvedRoot, filePath));
1388
+ if (isIgnoredPath(relativePath, policy)) {
1389
+ continue;
1390
+ }
1391
+
1392
+ if (path.extname(filePath).toLowerCase() === ".sql") {
1393
+ let source;
1394
+ try {
1395
+ source = await fs.readFile(filePath, "utf8");
1396
+ } catch (error) {
1397
+ const warn = options.warn || ((message) => createLogger({ stderr: process.stderr }).warn(message));
1398
+ warn(`Warning: could not scan ${filePath}: ${error.message}`);
1399
+ continue;
1400
+ }
1401
+ findings.push(...scanSqlSource({ filePath, source }));
1402
+ continue;
1403
+ }
1404
+
1405
+ const parsed = await prepareParsedSourceForScan(filePath, { warn: options.warn });
1406
+ if (parsed === null) {
1407
+ continue;
1408
+ }
1409
+
1410
+ try {
1411
+ parsedFiles.set(filePath, {
1412
+ sourceCode: parsed.sourceCode,
1413
+ importLines: collectImportLineMap(parsed.tree, parsed.sourceCode)
1414
+ });
1415
+ projectGraph[filePath] = buildProjectGraphNode(filePath, parsed.sourceCode, parsed.tree);
1416
+ findings.push(...scanParsedSourceFile({
1417
+ filePath,
1418
+ relativePath,
1419
+ sourceCode: parsed.sourceCode,
1420
+ tree: parsed.tree
1421
+ }));
1422
+ } finally {
1423
+ parsed.tree.delete?.();
1424
+ }
1425
+ }
1426
+
1427
+ findings.push(...taintViolationsToFindings(analyzeTaintGraph(projectGraph), parsedFiles));
1428
+
1429
+ return applyPolicy(dedupeFindings(findings), policy, resolvedRoot).sort((a, b) => {
1430
+ if (a.filePath === b.filePath) {
1431
+ return a.line - b.line;
1432
+ }
1433
+
1434
+ return a.filePath.localeCompare(b.filePath);
1435
+ });
1436
+ }
1437
+
1438
+ async function scanProjectDiff(rootDir = process.cwd(), options = {}) {
1439
+ const resolvedRoot = path.resolve(rootDir);
1440
+ const policy = options.policy || normalizePolicy();
1441
+ const files = await getChangedScanFiles(resolvedRoot, { policy });
1442
+ return scanFiles(resolvedRoot, files, { policy, warn: options.warn });
1443
+ }
1444
+
1445
+ async function scanProject(rootDir = process.cwd(), options = {}) {
1446
+ const resolvedRoot = path.resolve(rootDir);
1447
+ const policy = options.policy || normalizePolicy();
1448
+ const [sourceFiles, migrationFindings] = await Promise.all([
1449
+ collectSourceFiles(resolvedRoot, { policy }),
1450
+ scanSupabaseMigrations(resolvedRoot, { policy })
1451
+ ]);
1452
+ const sourceFindings = await scanFiles(resolvedRoot, sourceFiles, { policy, warn: options.warn });
1453
+
1454
+ return applyPolicy([...sourceFindings, ...migrationFindings], policy, resolvedRoot).sort((a, b) => {
1455
+ if (a.filePath === b.filePath) {
1456
+ return a.line - b.line;
1457
+ }
1458
+
1459
+ return a.filePath.localeCompare(b.filePath);
1460
+ });
1461
+ }
1462
+
1463
+ function askQuestion(question, options = {}) {
1464
+ const input = options.input || process.stdin;
1465
+ const output = options.output || process.stdout;
1466
+ const interfaceHandle = readline.createInterface({ input, output });
1467
+
1468
+ return new Promise((resolve) => {
1469
+ interfaceHandle.question(question, (answer) => {
1470
+ interfaceHandle.close();
1471
+ resolve(answer);
1472
+ });
1473
+ });
1474
+ }
1475
+
1476
+ function questionWithInterface(interfaceHandle, question) {
1477
+ return new Promise((resolve) => {
1478
+ interfaceHandle.question(question, (answer) => {
1479
+ resolve(answer || "");
1480
+ });
1481
+ });
1482
+ }
1483
+
1484
+ async function readAllInput(input) {
1485
+ let text = "";
1486
+ input.setEncoding?.("utf8");
1487
+
1488
+ for await (const chunk of input) {
1489
+ text += chunk;
1490
+ }
1491
+
1492
+ return text;
1493
+ }
1494
+
1495
+ async function createPromptOptions(options = {}) {
1496
+ if (options.ask) {
1497
+ return {
1498
+ promptOptions: options,
1499
+ close: () => {}
1500
+ };
1501
+ }
1502
+
1503
+ const input = options.input || process.stdin;
1504
+ const output = options.output || process.stdout;
1505
+
1506
+ if (input.isTTY !== true) {
1507
+ const answers = (await readAllInput(input)).split(/\r?\n/);
1508
+ return {
1509
+ promptOptions: {
1510
+ ...options,
1511
+ ask: async (question) => {
1512
+ output.write(question);
1513
+ return answers.shift() || "";
1514
+ }
1515
+ },
1516
+ close: () => {}
1517
+ };
1518
+ }
1519
+
1520
+ const promptInterface = readline.createInterface({ input, output });
1521
+ return {
1522
+ promptOptions: { ...options, promptInterface },
1523
+ close: () => promptInterface.close()
1524
+ };
1525
+ }
1526
+
1527
+ async function promptAndApplyFix(filePath, node, originalSourceBytes, options = {}) {
1528
+ const replacementText = node.replacement;
1529
+ const startByte = node.startByte;
1530
+ const endByte = node.endByte;
1531
+ const output = options.output || process.stdout;
1532
+ const ask =
1533
+ options.ask ||
1534
+ (options.promptInterface
1535
+ ? (question) => questionWithInterface(options.promptInterface, question)
1536
+ : (question) => askQuestion(question, options));
1537
+ const expectedText = node.expectedText;
1538
+
1539
+ if (typeof replacementText !== "string" || !replacementText) {
1540
+ output.write(`Fix skipped because no replacement is configured for ${filePath}.\n`);
1541
+ return originalSourceBytes;
1542
+ }
1543
+
1544
+ if (
1545
+ !Number.isInteger(startByte) ||
1546
+ !Number.isInteger(endByte) ||
1547
+ startByte < 0 ||
1548
+ endByte <= startByte ||
1549
+ endByte > originalSourceBytes.length
1550
+ ) {
1551
+ output.write(`Fix skipped because the stored byte range is invalid for ${filePath}.\n`);
1552
+ return originalSourceBytes;
1553
+ }
1554
+
1555
+ const leakedKey = originalSourceBytes.subarray(startByte, endByte).toString("utf8");
1556
+
1557
+ if (typeof expectedText !== "string" || leakedKey !== expectedText) {
1558
+ output.write(`Fix skipped because the file changed after scanning: ${filePath}\n`);
1559
+ return originalSourceBytes;
1560
+ }
1561
+
1562
+ output.write(`\n[PREFLIGHT PRO] Vulnerability found in ${filePath}\n`);
1563
+ output.write(`\u001b[91m(-) ${leakedKey}\u001b[0m\n`);
1564
+ output.write(`\u001b[92m(+) ${replacementText}\u001b[0m\n`);
1565
+
1566
+ const confirm = await ask("\nApply this fix? (y/N): ");
1567
+ if (confirm.toLowerCase() !== "y") {
1568
+ output.write("Skipped.\n");
1569
+ return originalSourceBytes;
1570
+ }
1571
+
1572
+ const replacement = Buffer.from(replacementText, "utf8");
1573
+ const newBytes = Buffer.concat([
1574
+ originalSourceBytes.subarray(0, startByte),
1575
+ replacement,
1576
+ originalSourceBytes.subarray(endByte)
1577
+ ]);
1578
+
1579
+ if (/^process\.env\.[A-Za-z_$][\w$]*$/.test(replacementText)) {
1580
+ const envName = replacementText.replace(/^process\.env\./, "");
1581
+ output.write(`Fix applied! Remember to add ${envName} to your .env file.\n`);
1582
+ } else {
1583
+ output.write("Fix applied!\n");
1584
+ }
1585
+ return newBytes;
1586
+ }
1587
+
1588
+ function assertNonOverlappingFixes(fixes) {
1589
+ const ordered = fixes.slice().sort((left, right) => left.startByte - right.startByte);
1590
+ for (let index = 1; index < ordered.length; index += 1) {
1591
+ const previous = ordered[index - 1];
1592
+ const current = ordered[index];
1593
+ if (current.startByte < previous.endByte) {
1594
+ throw new Error(`Overlapping PreFlight fixes: ${previous.kind} intersects ${current.kind}`);
1595
+ }
1596
+ }
1597
+ }
1598
+
1599
+ async function applyScanFixes(findings, options = {}) {
1600
+ const fixesByFile = new Map();
1601
+ const scaffoldFixes = [];
1602
+ const generateParameterizedFix = options.generateParameterizedFix || generateSqlParameterizedFix;
1603
+ let attempted = 0;
1604
+ let applied = 0;
1605
+ let skipped = 0;
1606
+ let unsupported = 0;
1607
+
1608
+ for (const item of findings) {
1609
+ if (!item.fix) {
1610
+ unsupported += 1;
1611
+ continue;
1612
+ }
1613
+
1614
+ if (item.fix.kind === "scaffold-server-action") {
1615
+ scaffoldFixes.push({ filePath: item.filePath, leak: item.fix.leak });
1616
+ continue;
1617
+ }
1618
+
1619
+ if (item.fix.kind !== "credential" && item.fix.kind !== "sql-remediation") {
1620
+ unsupported += 1;
1621
+ continue;
1622
+ }
1623
+
1624
+ if (!fixesByFile.has(item.filePath)) {
1625
+ fixesByFile.set(item.filePath, []);
1626
+ }
1627
+ fixesByFile.get(item.filePath).push({ finding: item, fix: item.fix });
1628
+ }
1629
+
1630
+ const { promptOptions, close } = await createPromptOptions(options);
1631
+
1632
+ try {
1633
+ for (const [filePath, fixes] of fixesByFile) {
1634
+ let currentSourceBytes = await fs.readFile(filePath);
1635
+ const resolvedFixes = [];
1636
+
1637
+ for (const { fix } of fixes) {
1638
+ if (fix.kind === "sql-remediation") {
1639
+ const replacement = await generateParameterizedFix(fix.rawSnippet);
1640
+ resolvedFixes.push({
1641
+ ...fix,
1642
+ replacement
1643
+ });
1644
+ continue;
1645
+ }
1646
+
1647
+ resolvedFixes.push(fix);
1648
+ }
1649
+
1650
+ const sortedFixes = resolvedFixes
1651
+ .slice()
1652
+ .sort((left, right) => right.startByte - left.startByte);
1653
+ assertNonOverlappingFixes(resolvedFixes);
1654
+
1655
+ for (const fix of sortedFixes) {
1656
+ const before = currentSourceBytes;
1657
+ currentSourceBytes = await promptAndApplyFix(filePath, fix, currentSourceBytes, promptOptions);
1658
+ if (!Buffer.compare(before, currentSourceBytes)) {
1659
+ skipped += 1;
1660
+ } else {
1661
+ applied += 1;
1662
+ }
1663
+ attempted += 1;
1664
+ }
1665
+
1666
+ await assertSourceSyntaxSafe(filePath, currentSourceBytes.toString("utf8"));
1667
+ await fs.writeFile(filePath, currentSourceBytes);
1668
+ }
1669
+
1670
+ for (const item of scaffoldFixes) {
1671
+ await applyScaffoldTransaction(item.filePath, item.leak);
1672
+ attempted += 1;
1673
+ applied += 1;
1674
+ }
1675
+ } finally {
1676
+ close();
1677
+ }
1678
+
1679
+ return { attempted, applied, skipped, unsupported };
1680
+ }
1681
+
1682
+ function renderReport(findings, options = {}) {
1683
+ const colorOptions = {
1684
+ color: options.color,
1685
+ noColor: options.noColor,
1686
+ stream: options.stream || process.stdout
1687
+ };
1688
+
1689
+ if (findings.length === 0) {
1690
+ return `${colorize("success", "The Scavenger found 0 issues.", colorOptions)}\n`;
1691
+ }
1692
+
1693
+ const plural = findings.length === 1 ? "issue" : "issues";
1694
+ const lines = [
1695
+ colorize("error", `The Scavenger found ${findings.length} ${plural}.`, colorOptions),
1696
+ ""
1697
+ ];
1698
+
1699
+ for (const item of findings) {
1700
+ lines.push(`${colorize(item.severity, item.severity.toUpperCase(), colorOptions)} ${item.ruleId}`);
1701
+ lines.push(` ${item.filePath}:${item.line}`);
1702
+ lines.push(` ${item.message}`);
1703
+ if (item.evidence) {
1704
+ lines.push(` Evidence: ${item.evidence}`);
1705
+ }
1706
+ lines.push("");
1707
+ }
1708
+
1709
+ return `${lines.join("\n").trimEnd()}\n`;
1710
+ }
1711
+
1712
+ function toSarifUri(filePath, rootDir) {
1713
+ const relativePath = path.relative(path.resolve(rootDir), filePath) || path.basename(filePath);
1714
+ return toPosix(relativePath);
1715
+ }
1716
+
1717
+ function renderSarif(findings, options = {}) {
1718
+ const rootDir = path.resolve(options.rootDir || process.cwd());
1719
+
1720
+ return {
1721
+ version: "2.1.0",
1722
+ $schema: "https://json.schemastore.org/sarif-2.1.0.json",
1723
+ runs: [
1724
+ {
1725
+ tool: {
1726
+ driver: {
1727
+ name: "PreFlight Scavenger",
1728
+ informationUri: "https://preflight.local",
1729
+ rules: Object.values(SARIF_RULES)
1730
+ }
1731
+ },
1732
+ results: findings.map((item) => ({
1733
+ ruleId: item.ruleId,
1734
+ level: "error",
1735
+ message: {
1736
+ text: item.message
1737
+ },
1738
+ locations: [
1739
+ {
1740
+ physicalLocation: {
1741
+ artifactLocation: {
1742
+ uri: toSarifUri(item.filePath, rootDir)
1743
+ },
1744
+ region: {
1745
+ startLine: item.line || 1
1746
+ }
1747
+ }
1748
+ }
1749
+ ],
1750
+ properties: {
1751
+ severity: item.severity,
1752
+ ...(item.evidence ? { evidence: item.evidence } : {}),
1753
+ ...(item.tableName ? { tableName: item.tableName } : {})
1754
+ }
1755
+ }))
1756
+ }
1757
+ ]
1758
+ };
1759
+ }
1760
+
1761
+ async function writeSarifReport(findings, options = {}) {
1762
+ const rootDir = path.resolve(options.rootDir || process.cwd());
1763
+ const outputPath = path.join(rootDir, SARIF_REPORT_NAME);
1764
+ await fs.writeFile(outputPath, `${JSON.stringify(renderSarif(findings, { rootDir }), null, 2)}\n`, "utf8");
1765
+ return outputPath;
1766
+ }
1767
+
1768
+ async function auditDependencies(rootDir = process.cwd(), options = {}) {
1769
+ const resolvedRoot = path.resolve(rootDir);
1770
+ const runner = options.runner || runCommand;
1771
+ try {
1772
+ const result = await runner("npm", ["audit", "--json"], resolvedRoot);
1773
+ return normalizeAuditResult(result.stdout, resolvedRoot);
1774
+ } catch (error) {
1775
+ const output = error.stdout || error.output || "";
1776
+ if (output.trim()) {
1777
+ return normalizeAuditResult(output, resolvedRoot);
1778
+ }
1779
+
1780
+ throw error;
1781
+ }
1782
+ }
1783
+
1784
+ function normalizeAuditResult(rawJson, rootDir = process.cwd()) {
1785
+ const parsed = JSON.parse(rawJson || "{}");
1786
+ const vulnerabilities = parsed.metadata?.vulnerabilities || {
1787
+ info: 0,
1788
+ low: 0,
1789
+ moderate: 0,
1790
+ high: 0,
1791
+ critical: 0,
1792
+ total: 0
1793
+ };
1794
+
1795
+ return {
1796
+ directory: path.resolve(rootDir),
1797
+ vulnerabilities,
1798
+ metadata: parsed.metadata || {},
1799
+ auditReportVersion: parsed.auditReportVersion,
1800
+ raw: parsed
1801
+ };
1802
+ }
1803
+
1804
+ function renderAuditReport(result, options = {}) {
1805
+ const colorOptions = {
1806
+ color: options.color,
1807
+ noColor: options.noColor,
1808
+ stream: options.stream || process.stdout
1809
+ };
1810
+ const vulnerabilities = result.vulnerabilities || {};
1811
+ const total = vulnerabilities.total || 0;
1812
+
1813
+ if (total === 0) {
1814
+ return `${colorize("success", "PreFlight dependency audit found 0 vulnerabilities.", colorOptions)}\n`;
1815
+ }
1816
+
1817
+ return [
1818
+ colorize("error", `PreFlight dependency audit found ${total} vulnerabilities.`, colorOptions),
1819
+ colorize("critical", ` Critical: ${vulnerabilities.critical || 0}`, colorOptions),
1820
+ colorize("high", ` High: ${vulnerabilities.high || 0}`, colorOptions),
1821
+ colorize("warning", ` Moderate: ${vulnerabilities.moderate || 0}`, colorOptions),
1822
+ colorize("warning", ` Low: ${vulnerabilities.low || 0}`, colorOptions),
1823
+ ` Info: ${vulnerabilities.info || 0}`,
1824
+ ""
1825
+ ].join("\n");
1826
+ }
1827
+
1828
+ async function runCommand(command, args, cwd) {
1829
+ const executable = process.platform === "win32" && command === "npm" ? "cmd.exe" : command;
1830
+ const finalArgs = process.platform === "win32" && command === "npm" ? ["/d", "/s", "/c", "npm", ...args] : args;
1831
+
1832
+ try {
1833
+ return await execFileAsync(executable, finalArgs, {
1834
+ cwd,
1835
+ encoding: "utf8",
1836
+ maxBuffer: 1024 * 1024 * 10
1837
+ });
1838
+ } catch (error) {
1839
+ error.output = `${error.stdout || ""}${error.stderr || ""}`;
1840
+ throw error;
1841
+ }
1842
+ }
1843
+
1844
+ async function git(rootDir, args) {
1845
+ return runCommand("git", args, rootDir);
1846
+ }
1847
+
1848
+ async function gitOutputOrEmpty(rootDir, args, allowedFailurePatterns = []) {
1849
+ try {
1850
+ return (await git(rootDir, args)).stdout;
1851
+ } catch (error) {
1852
+ const output = error.output || error.message || "";
1853
+ if (allowedFailurePatterns.some((pattern) => pattern.test(output))) {
1854
+ return "";
1855
+ }
1856
+
1857
+ throw error;
1858
+ }
1859
+ }
1860
+
1861
+ async function gitBranchExists(rootDir, branchName) {
1862
+ try {
1863
+ await git(rootDir, ["rev-parse", "--verify", branchName]);
1864
+ return true;
1865
+ } catch {
1866
+ return false;
1867
+ }
1868
+ }
1869
+
1870
+ async function deleteBranchIfExists(rootDir, branchName) {
1871
+ if (await gitBranchExists(rootDir, branchName)) {
1872
+ await git(rootDir, ["branch", "-D", branchName]);
1873
+ }
1874
+ }
1875
+
1876
+ async function rollbackTemporaryBranch(rootDir, originalBranch, originalRef, branchName) {
1877
+ await git(rootDir, ["reset", "--hard", originalRef]);
1878
+ await git(rootDir, ["checkout", originalBranch]);
1879
+ await deleteBranchIfExists(rootDir, branchName);
1880
+ }
1881
+
1882
+ async function assertCleanWorkingTree(rootDir) {
1883
+ const { stdout } = await git(rootDir, ["status", "--porcelain"]);
1884
+ if (stdout.trim()) {
1885
+ throw new Error("Refusing to apply a fix while the Git working tree has uncommitted changes.");
1886
+ }
1887
+ }
1888
+
1889
+ async function applyFixWithRollback(options = {}) {
1890
+ const rootDir = path.resolve(options.rootDir || process.cwd());
1891
+ const patchFile = options.patchFile ? path.resolve(options.patchFile) : null;
1892
+ const branchName = options.branchName || "preflight-temp-fix";
1893
+ const buildCommand = options.buildCommand || ["npm", "run", "build"];
1894
+
1895
+ if (!patchFile) {
1896
+ throw new Error("A patch file is required.");
1897
+ }
1898
+
1899
+ if (!Array.isArray(buildCommand) || buildCommand.length === 0) {
1900
+ throw new Error("buildCommand must be an array like ['npm', 'run', 'build'].");
1901
+ }
1902
+
1903
+ await fs.access(patchFile);
1904
+ await assertCleanWorkingTree(rootDir);
1905
+
1906
+ if (await gitBranchExists(rootDir, branchName)) {
1907
+ throw new Error(`Temporary branch already exists: ${branchName}`);
1908
+ }
1909
+
1910
+ const originalBranch = (await git(rootDir, ["rev-parse", "--abbrev-ref", "HEAD"])).stdout.trim();
1911
+ const originalRef = (await git(rootDir, ["rev-parse", "HEAD"])).stdout.trim();
1912
+
1913
+ await git(rootDir, ["checkout", "-b", branchName]);
1914
+
1915
+ try {
1916
+ await git(rootDir, ["apply", patchFile]);
1917
+
1918
+ let buildOutput = "";
1919
+ try {
1920
+ const build = await runCommand(buildCommand[0], buildCommand.slice(1), rootDir);
1921
+ buildOutput = `${build.stdout || ""}${build.stderr || ""}`;
1922
+ } catch (buildError) {
1923
+ buildOutput = buildError.output || buildError.message;
1924
+ await rollbackTemporaryBranch(rootDir, originalBranch, originalRef, branchName);
1925
+ return {
1926
+ success: false,
1927
+ branchName,
1928
+ originalBranch,
1929
+ buildOutput,
1930
+ rollbackCommand: `git reset --hard ${originalRef}`
1931
+ };
1932
+ }
1933
+
1934
+ await git(rootDir, ["add", "-A"]);
1935
+ const status = (await git(rootDir, ["status", "--porcelain"])).stdout.trim();
1936
+ if (status) {
1937
+ await git(rootDir, ["commit", "-m", "Apply PreFlight AI fix"]);
1938
+ }
1939
+
1940
+ await git(rootDir, ["checkout", originalBranch]);
1941
+ if (status) {
1942
+ await git(rootDir, ["merge", "--ff-only", branchName]);
1943
+ }
1944
+ await git(rootDir, ["branch", "-D", branchName]);
1945
+
1946
+ return {
1947
+ success: true,
1948
+ branchName,
1949
+ originalBranch,
1950
+ buildOutput
1951
+ };
1952
+ } catch (error) {
1953
+ try {
1954
+ await rollbackTemporaryBranch(rootDir, originalBranch, originalRef, branchName);
1955
+ } catch (rollbackError) {
1956
+ error.rollbackError = rollbackError;
1957
+ }
1958
+ throw error;
1959
+ }
1960
+ }
1961
+
1962
+ function getMcpConfigTargets(options = {}) {
1963
+ const platform = options.platform || process.platform;
1964
+ const homeDir = options.homeDir || os.homedir();
1965
+ const env = options.env || process.env;
1966
+
1967
+ if (platform === "win32") {
1968
+ const appData = env.APPDATA || path.join(homeDir, "AppData", "Roaming");
1969
+ return [
1970
+ {
1971
+ client: "Claude Desktop",
1972
+ filePath: path.join(appData, "Claude", "claude_desktop_config.json")
1973
+ },
1974
+ {
1975
+ client: "Cline for VS Code",
1976
+ filePath: path.join(appData, "Code", "User", "globalStorage", "saoudrizwan.claude-dev", "settings", "mcp_settings.json")
1977
+ },
1978
+ {
1979
+ client: "RooCode for VS Code",
1980
+ filePath: path.join(appData, "Code", "User", "globalStorage", "rooveterinaryinc.roo-cline", "settings", "mcp_settings.json")
1981
+ }
1982
+ ];
1983
+ }
1984
+
1985
+ if (platform === "darwin") {
1986
+ const appSupport = path.join(homeDir, "Library", "Application Support");
1987
+ return [
1988
+ {
1989
+ client: "Claude Desktop",
1990
+ filePath: path.join(appSupport, "Claude", "claude_desktop_config.json")
1991
+ },
1992
+ {
1993
+ client: "Cline for VS Code",
1994
+ filePath: path.join(appSupport, "Code", "User", "globalStorage", "saoudrizwan.claude-dev", "settings", "mcp_settings.json")
1995
+ },
1996
+ {
1997
+ client: "RooCode for VS Code",
1998
+ filePath: path.join(appSupport, "Code", "User", "globalStorage", "rooveterinaryinc.roo-cline", "settings", "mcp_settings.json")
1999
+ }
2000
+ ];
2001
+ }
2002
+
2003
+ const configHome = env.XDG_CONFIG_HOME || path.join(homeDir, ".config");
2004
+ return [
2005
+ {
2006
+ client: "Claude Desktop",
2007
+ filePath: path.join(configHome, "Claude", "claude_desktop_config.json")
2008
+ },
2009
+ {
2010
+ client: "Cline for VS Code",
2011
+ filePath: path.join(configHome, "Code", "User", "globalStorage", "saoudrizwan.claude-dev", "settings", "mcp_settings.json")
2012
+ },
2013
+ {
2014
+ client: "RooCode for VS Code",
2015
+ filePath: path.join(configHome, "Code", "User", "globalStorage", "rooveterinaryinc.roo-cline", "settings", "mcp_settings.json")
2016
+ }
2017
+ ];
2018
+ }
2019
+
2020
+ async function injectMcpServerConfig(target, options = {}) {
2021
+ const serverName = options.serverName || PREFLIGHT_MCP_SERVER_NAME;
2022
+ const serverConfig = options.serverConfig || PREFLIGHT_MCP_SERVER_CONFIG;
2023
+ const raw = await fs.readFile(target.filePath, "utf8");
2024
+ const config = raw.trim() ? JSON.parse(raw) : {};
2025
+
2026
+ if (!config.mcpServers || typeof config.mcpServers !== "object" || Array.isArray(config.mcpServers)) {
2027
+ config.mcpServers = {};
2028
+ }
2029
+
2030
+ config.mcpServers[serverName] = serverConfig;
2031
+ await writeJsonFileSafely(target.filePath, config);
2032
+ return target.client;
2033
+ }
2034
+
2035
+ async function writeJsonFileSafely(filePath, value) {
2036
+ const tempPath = `${filePath}.preflight-tmp`;
2037
+ await fs.writeFile(tempPath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
2038
+ await fs.rename(tempPath, filePath);
2039
+ }
2040
+
2041
+ async function installMcpForKnownClients(options = {}) {
2042
+ const targets = options.targets || getMcpConfigTargets(options);
2043
+ const output = options.output || process.stdout;
2044
+ const errorOutput = options.errorOutput || process.stderr;
2045
+ const color = options.color !== false;
2046
+ const configuredClients = [];
2047
+
2048
+ for (const target of targets) {
2049
+ if (!(await fileExists(target.filePath))) {
2050
+ continue;
2051
+ }
2052
+
2053
+ try {
2054
+ const client = await injectMcpServerConfig(target, options);
2055
+ configuredClients.push(client);
2056
+ output.write(`${colorize("success", "Configured", { color, stream: output })} ${client}: ${target.filePath}\n`);
2057
+ } catch (error) {
2058
+ errorOutput.write(`Warning: could not update ${target.client} MCP config at ${target.filePath}: ${error.message}\n`);
2059
+ }
2060
+ }
2061
+
2062
+ if (configuredClients.length > 0) {
2063
+ output.write(`${colorize("success", "PreFlight Pro MCP auto-configured", { color, stream: output })} for ${configuredClients.join(", ")}.\n`);
2064
+ }
2065
+
2066
+ output.write(`${UNIVERSAL_MCP_OUTPUT}\n`);
2067
+ return configuredClients;
2068
+ }
2069
+
2070
+ function normalizeCliArgs(argv) {
2071
+ const [nodePath, scriptPath, firstArg, ...rest] = argv;
2072
+ const knownCommands = new Set(["scan", "audit", "activate", "apply-fix", "install-mcp", "mcp", "help"]);
2073
+
2074
+ if (!firstArg || firstArg.startsWith("-") || !knownCommands.has(firstArg)) {
2075
+ return [nodePath, scriptPath, "scan", ...(firstArg ? [firstArg, ...rest] : rest)];
2076
+ }
2077
+
2078
+ return argv;
2079
+ }
2080
+
2081
+ function applyOpenAiKeyFlag(argv = process.argv) {
2082
+ const nextArgv = [];
2083
+
2084
+ for (let index = 0; index < argv.length; index += 1) {
2085
+ const arg = argv[index];
2086
+
2087
+ if (arg === "--openai-key") {
2088
+ const value = argv[index + 1];
2089
+ if (value && !value.startsWith("-")) {
2090
+ process.env.OPENAI_API_KEY = value;
2091
+ index += 1;
2092
+ }
2093
+ continue;
2094
+ }
2095
+
2096
+ if (arg.startsWith("--openai-key=")) {
2097
+ const value = arg.slice("--openai-key=".length);
2098
+ if (value) {
2099
+ process.env.OPENAI_API_KEY = value;
2100
+ }
2101
+ continue;
2102
+ }
2103
+
2104
+ nextArgv.push(arg);
2105
+ }
2106
+
2107
+ return nextArgv;
2108
+ }
2109
+
2110
+ async function runCli(argv = process.argv, options = {}) {
2111
+ const normalizedArgv = normalizeCliArgs(applyOpenAiKeyFlag(argv));
2112
+ const activateLicenseKey = options.activateLicenseKey || activateDefaultLicenseKey;
2113
+ const auditDependencyRunner = options.auditDependencies || auditDependencies;
2114
+ const startMcpServer = options.startMcpServer || startDefaultMcpServer;
2115
+ const program = new Command();
2116
+ program
2117
+ .name("scavenger")
2118
+ .description("Local zero-knowledge scanner for Next.js and Supabase security flaws.");
2119
+
2120
+ program
2121
+ .command("activate")
2122
+ .description("Activate a PreFlight Pro Lemon Squeezy license key.")
2123
+ .argument("<key>", "license key to activate")
2124
+ .argument("[email]", "purchase email address")
2125
+ .action(async (key, email) => {
2126
+ if (!email) {
2127
+ console.log(colorize("error", "\u274c Usage: preflight activate <key> <email>", { stream: process.stdout }));
2128
+ process.exitCode = 1;
2129
+ return;
2130
+ }
2131
+
2132
+ try {
2133
+ const result = await activateLicenseKey(key, email);
2134
+ if (result.success === false) {
2135
+ console.log(colorize("error", result.message, { stream: process.stdout }));
2136
+ process.exitCode = 1;
2137
+ return;
2138
+ }
2139
+
2140
+ console.log(colorize("success", result.message || "\u2705 PreFlight Pro activated successfully! Unlimited AI auto-fixes unlocked.", { stream: process.stdout }));
2141
+ process.exitCode = 0;
2142
+ } catch (error) {
2143
+ createLogger({ stderr: process.stderr }).error(`License activation failed: ${error.message}`);
2144
+ process.exitCode = 1;
2145
+ }
2146
+ });
2147
+
2148
+ program
2149
+ .command("apply-fix")
2150
+ .description("Apply a local patch on a temporary branch, run npm run build, then merge or rollback.")
2151
+ .argument("<patch-file>", "local patch file to apply with git apply")
2152
+ .argument("[directory]", "project directory", process.cwd())
2153
+ .option("--branch <name>", "temporary branch name", "preflight-temp-fix")
2154
+ .action(async (patchFile, directory, options) => {
2155
+ try {
2156
+ await ensureLicenseVerified();
2157
+ } catch (error) {
2158
+ if (error instanceof InvalidLicenseKeyError) {
2159
+ createLogger({ stderr: process.stderr }).error("Invalid License Key");
2160
+ process.exit(1);
2161
+ }
2162
+
2163
+ createLogger({ stderr: process.stderr }).error(`License verification failed: ${error.message}`);
2164
+ process.exit(1);
2165
+ }
2166
+
2167
+ const result = await applyFixWithRollback({
2168
+ rootDir: directory,
2169
+ patchFile,
2170
+ branchName: options.branch
2171
+ });
2172
+
2173
+ if (result.success) {
2174
+ process.stdout.write(`PreFlight fix merged from ${result.branchName} into ${result.originalBranch}.\n`);
2175
+ process.exitCode = 0;
2176
+ } else {
2177
+ process.stderr.write(`PreFlight fix failed build and was rolled back with ${result.rollbackCommand}.\n`);
2178
+ process.exitCode = 1;
2179
+ }
2180
+ });
2181
+
2182
+ program
2183
+ .command("install-mcp")
2184
+ .description("Auto-configure PreFlight Pro MCP for known local AI clients.")
2185
+ .action(async () => {
2186
+ await installMcpForKnownClients();
2187
+ process.exitCode = 0;
2188
+ });
2189
+
2190
+ program
2191
+ .command("audit")
2192
+ .description("Run an explicit dependency audit with npm audit.")
2193
+ .argument("[directory]", "project directory to audit", process.cwd())
2194
+ .option("--json", "print audit result as JSON")
2195
+ .option("--no-color", "disable color output")
2196
+ .action(async (directory, options) => {
2197
+ const result = await auditDependencyRunner(path.resolve(directory));
2198
+ if (options.json) {
2199
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
2200
+ } else {
2201
+ process.stdout.write(renderAuditReport(result, { color: options.color, stream: process.stdout }));
2202
+ }
2203
+
2204
+ process.exitCode = result.vulnerabilities?.total > 0 ? 1 : 0;
2205
+ });
2206
+
2207
+ program
2208
+ .command("mcp")
2209
+ .description("Start the PreFlight MCP server over stdio.")
2210
+ .action(async () => {
2211
+ await startMcpServer({
2212
+ applyScanFixes,
2213
+ auditDependencies,
2214
+ cwd: process.cwd(),
2215
+ loadPreflightPolicy,
2216
+ renderAuditReport,
2217
+ renderReport,
2218
+ scanProject,
2219
+ scanProjectDiff,
2220
+ version: packageJson.version
2221
+ });
2222
+ process.exitCode = 0;
2223
+ });
2224
+
2225
+ async function runScanAction(directory, options) {
2226
+ const rootDir = path.resolve(directory);
2227
+ const policy = await loadPreflightPolicy(process.cwd());
2228
+ const findings = options.diff ? await scanProjectDiff(rootDir, { policy }) : await scanProject(rootDir, { policy });
2229
+ let fixResult = null;
2230
+
2231
+ if (options.fix) {
2232
+ fixResult = await applyScanFixes(findings);
2233
+ }
2234
+
2235
+ if (options.fix) {
2236
+ process.stdout.write(
2237
+ `PreFlight remediation attempted ${fixResult?.attempted || 0} fix(es): ` +
2238
+ `${fixResult?.applied || 0} applied, ${fixResult?.skipped || 0} skipped, ${fixResult?.unsupported || 0} unsupported.\n`
2239
+ );
2240
+ } else if (options.format === "sarif") {
2241
+ await writeSarifReport(findings, { rootDir });
2242
+ } else if (options.json) {
2243
+ process.stdout.write(`${JSON.stringify(findings, null, 2)}\n`);
2244
+ } else {
2245
+ process.stdout.write(renderReport(findings, { color: options.color, stream: process.stdout }));
2246
+ }
2247
+
2248
+ if (options.fix) {
2249
+ const unresolved = (fixResult?.skipped || 0) + (fixResult?.unsupported || 0);
2250
+ process.exitCode = unresolved > 0 ? 1 : 0;
2251
+ } else {
2252
+ process.exitCode = findings.length > 0 ? 1 : 0;
2253
+ }
2254
+ }
2255
+
2256
+ program
2257
+ .command("scan")
2258
+ .description("Run the free local scanner.")
2259
+ .argument("[directory]", "project directory to scan", process.cwd())
2260
+ .option("--diff", "scan only changed Git files")
2261
+ .option("--fix", "interactively remediate supported findings")
2262
+ .option("--format <format>", "output format: text or sarif", "text")
2263
+ .option("--json", "print findings as JSON")
2264
+ .option("--no-color", "disable color output")
2265
+ .action(runScanAction);
2266
+
2267
+ await program.parseAsync(normalizedArgv);
2268
+ }
2269
+
2270
+ module.exports = {
2271
+ auditDependencies,
2272
+ applyOpenAiKeyFlag,
2273
+ applyScanFixes,
2274
+ applyFixWithRollback,
2275
+ detectSecret,
2276
+ ensureLicenseVerified,
2277
+ extractCreatedTables,
2278
+ extractRlsEnabledTables,
2279
+ getChangedScanFiles,
2280
+ getMcpConfigTargets,
2281
+ getPreflightConfigPath,
2282
+ hasUseClientDirective,
2283
+ injectMcpServerConfig,
2284
+ installMcpForKnownClients,
2285
+ InvalidLicenseKeyError,
2286
+ isIgnoredPath,
2287
+ loadPreflightPolicy,
2288
+ matchesIgnorePath,
2289
+ normalizeCliArgs,
2290
+ normalizePolicy,
2291
+ postFormUrlEncoded,
2292
+ promptAndApplyFix,
2293
+ promptForLicenseKey,
2294
+ readPreflightConfig,
2295
+ renderReport,
2296
+ renderAuditReport,
2297
+ renderSarif,
2298
+ runCli,
2299
+ savePreflightConfig,
2300
+ scanBackendSecrets,
2301
+ scanBackendSource,
2302
+ scanFiles,
2303
+ scanFrontendSecrets,
2304
+ scanFrontendSource,
2305
+ scanProject,
2306
+ scanProjectDiff,
2307
+ scanSecretSource,
2308
+ scanSqlSource,
2309
+ scanStandaloneSecrets,
2310
+ scanSupabaseMigrations,
2311
+ validateLicenseKey,
2312
+ writeSarifReport
2313
+ };
2314
+
2315
+ if (require.main === module) {
2316
+ runCli().catch((error) => {
2317
+ process.stderr.write(`The Scavenger failed: ${error.message}\n`);
2318
+ process.exitCode = 2;
2319
+ });
2320
+ }