ship-safe 3.0.0 → 3.2.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.
@@ -0,0 +1,646 @@
1
+ /**
2
+ * Remediate Command
3
+ * =================
4
+ *
5
+ * Automatically fixes hardcoded secrets by:
6
+ * 1. Replacing them with environment variable references in source code
7
+ * 2. Writing actual values to .env (atomic write, 0o600 permissions)
8
+ * 3. Adding .env to .gitignore BEFORE writing .env
9
+ * 4. Updating .env.example with safe placeholders
10
+ *
11
+ * USAGE:
12
+ * ship-safe remediate . Interactive — shows diff, confirms per file
13
+ * ship-safe remediate . --dry-run Preview only, writes nothing
14
+ * ship-safe remediate . --yes Apply all without prompting (CI use)
15
+ * ship-safe remediate . --stage Also run git add on modified files
16
+ *
17
+ * SAFETY GUARANTEES:
18
+ * - Dry-run by default shows full diff before any write
19
+ * - .gitignore updated BEFORE .env is written
20
+ * - Backs up originals to .ship-safe-backup/<timestamp>/ before touching
21
+ * - Atomic writes: temp file → rename, no partial writes
22
+ * - Verifies the fix worked by re-scanning before finalizing
23
+ * - Never prints actual secret values to stdout (masked in diff)
24
+ * - Sets .env to 0o600 (owner read/write only) on Unix
25
+ * - Warns if repository appears to be public
26
+ *
27
+ * RECOMMENDED ORDER:
28
+ * 1. ship-safe rotate — revoke the exposed key first
29
+ * 2. ship-safe remediate — fix source code
30
+ * 3. ship-safe purge-history — scrub git history (v4.0.0)
31
+ */
32
+
33
+ import fs from 'fs';
34
+ import path from 'path';
35
+ import os from 'os';
36
+ import { createInterface } from 'readline';
37
+ import { execSync } from 'child_process';
38
+ import chalk from 'chalk';
39
+ import ora from 'ora';
40
+ import pkg from 'write-file-atomic';
41
+ const { writeFile: writeFileAtomic } = pkg;
42
+ import fg from 'fast-glob';
43
+ import {
44
+ SECRET_PATTERNS,
45
+ SKIP_DIRS,
46
+ SKIP_EXTENSIONS,
47
+ TEST_FILE_PATTERNS,
48
+ MAX_FILE_SIZE
49
+ } from '../utils/patterns.js';
50
+ import { isHighEntropyMatch, getConfidence } from '../utils/entropy.js';
51
+ import * as output from '../utils/output.js';
52
+
53
+ // =============================================================================
54
+ // FRAMEWORK DETECTION
55
+ // =============================================================================
56
+
57
+ function detectFramework(rootPath) {
58
+ const pkgPath = path.join(rootPath, 'package.json');
59
+ if (fs.existsSync(pkgPath)) {
60
+ try {
61
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
62
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
63
+ if (deps['next']) return 'nextjs';
64
+ if (deps['nuxt'] || deps['nuxt3']) return 'nuxt';
65
+ } catch { /* ignore */ }
66
+ }
67
+ return 'node'; // default: Node.js / process.env
68
+ }
69
+
70
+ function envVarRef(varName, framework, filePath = '') {
71
+ if (filePath.endsWith('.py')) return `os.environ.get('${varName}')`;
72
+ if (filePath.endsWith('.rb')) return `ENV['${varName}']`;
73
+ // For Next.js keep standard process.env — user decides if NEXT_PUBLIC_ is needed
74
+ return `process.env.${varName}`;
75
+ }
76
+
77
+ // =============================================================================
78
+ // ENV VAR NAME GENERATION
79
+ // =============================================================================
80
+
81
+ /**
82
+ * Convert pattern name to SCREAMING_SNAKE_CASE env var name.
83
+ * e.g. "OpenAI API Key" → "OPENAI_API_KEY"
84
+ * "[custom] My Token" → "MY_TOKEN"
85
+ */
86
+ function patternToEnvVar(patternName) {
87
+ return patternName
88
+ .replace(/^\[custom\]\s*/i, '')
89
+ .toUpperCase()
90
+ .replace(/[^A-Z0-9\s]/g, '')
91
+ .trim()
92
+ .replace(/\s+/g, '_');
93
+ }
94
+
95
+ /**
96
+ * Ensure env var name is unique within the current session.
97
+ * If "OPENAI_API_KEY" is already taken, returns "OPENAI_API_KEY_2".
98
+ */
99
+ function uniqueVarName(baseName, seen) {
100
+ if (!seen.has(baseName)) return baseName;
101
+ let i = 2;
102
+ while (seen.has(`${baseName}_${i}`)) i++;
103
+ return `${baseName}_${i}`;
104
+ }
105
+
106
+ // =============================================================================
107
+ // REPLACEMENT LOGIC
108
+ // =============================================================================
109
+
110
+ /**
111
+ * Compute what to replace in a line and extract the raw secret value.
112
+ *
113
+ * Given: matched = 'apiKey = "sk-abc123xyz"', envRef = 'process.env.OPENAI_API_KEY'
114
+ * Returns:
115
+ * replacement = 'apiKey = process.env.OPENAI_API_KEY'
116
+ * secretValue = 'sk-abc123xyz'
117
+ */
118
+ function computeReplacement(matched, envRef) {
119
+ // Case 1: quoted assignment — key = "value" or key: 'value'
120
+ const quotedAssignment = matched.match(/^(.*?[:=]\s*)["']([^"']{4,})["'](.*)$/s);
121
+ if (quotedAssignment) {
122
+ const [, prefix, secretValue, suffix] = quotedAssignment;
123
+ return { replacement: prefix + envRef + suffix, secretValue };
124
+ }
125
+
126
+ // Case 2: unquoted assignment — key = value (no quotes around value)
127
+ const unquotedAssignment = matched.match(/^(.*?[:=]\s*)([^\s"'<>\[\]{},;]{8,})(\s*)$/s);
128
+ if (unquotedAssignment) {
129
+ const [, prefix, secretValue, suffix] = unquotedAssignment;
130
+ return { replacement: prefix + envRef + suffix, secretValue };
131
+ }
132
+
133
+ // Case 3: raw secret with no assignment context (e.g. AKIA..., ghp_...)
134
+ return { replacement: envRef, secretValue: matched };
135
+ }
136
+
137
+ /**
138
+ * Apply a single replacement to a line at the exact column position.
139
+ * Uses column index to avoid regex issues with special characters.
140
+ */
141
+ function replaceInLine(line, matched, colIndex, replacement) {
142
+ const before = line.substring(0, colIndex);
143
+ const after = line.substring(colIndex + matched.length);
144
+ return before + replacement + after;
145
+ }
146
+
147
+ // =============================================================================
148
+ // PLAN BUILDING
149
+ // =============================================================================
150
+
151
+ /**
152
+ * Build a complete remediation plan from scan results.
153
+ * Returns an array of file-level plans, each with:
154
+ * - file: absolute path
155
+ * - originalLines: string[]
156
+ * - modifiedLines: string[]
157
+ * - changes: [{lineNum, originalLine, newLine, varName, secretValue}]
158
+ */
159
+ function buildPlan(scanResults, framework, rootPath) {
160
+ const plan = [];
161
+ const seenVarNames = new Set();
162
+
163
+ for (const { file, findings } of scanResults) {
164
+ const content = fs.readFileSync(file, 'utf-8');
165
+ const originalLines = content.split('\n');
166
+ const modifiedLines = [...originalLines];
167
+ const changes = [];
168
+
169
+ // Group findings by line, sort within each line by column descending
170
+ // so right-to-left replacements don't shift column indices for earlier matches
171
+ const byLine = {};
172
+ for (const f of findings) {
173
+ if (!byLine[f.line]) byLine[f.line] = [];
174
+ byLine[f.line].push(f);
175
+ }
176
+
177
+ let fileHasChanges = false;
178
+
179
+ for (const lineNumStr of Object.keys(byLine).sort((a, b) => Number(a) - Number(b))) {
180
+ const lineNum = Number(lineNumStr);
181
+ const lineFinders = byLine[lineNumStr].sort((a, b) => b.column - a.column);
182
+ let lineContent = modifiedLines[lineNum - 1];
183
+ const originalLine = originalLines[lineNum - 1];
184
+
185
+ for (const f of lineFinders) {
186
+ const baseVarName = patternToEnvVar(f.patternName);
187
+ const varName = uniqueVarName(baseVarName, seenVarNames);
188
+ seenVarNames.add(varName);
189
+
190
+ const ref = envVarRef(varName, framework, file);
191
+ const colIndex = f.column - 1;
192
+
193
+ const { replacement, secretValue } = computeReplacement(f.matched, ref);
194
+
195
+ lineContent = replaceInLine(lineContent, f.matched, colIndex, replacement);
196
+ changes.push({ lineNum, originalLine, newLine: lineContent, varName, secretValue });
197
+ fileHasChanges = true;
198
+ }
199
+
200
+ if (fileHasChanges) {
201
+ modifiedLines[lineNum - 1] = lineContent;
202
+ }
203
+ }
204
+
205
+ if (changes.length > 0) {
206
+ plan.push({ file, originalLines, modifiedLines, changes });
207
+ }
208
+ }
209
+
210
+ return plan;
211
+ }
212
+
213
+ // =============================================================================
214
+ // DIFF DISPLAY
215
+ // =============================================================================
216
+
217
+ function showDiff(planItem, rootPath) {
218
+ const relPath = path.relative(rootPath, planItem.file);
219
+ console.log('\n' + chalk.white.bold(` ${relPath}`));
220
+
221
+ for (const change of planItem.changes) {
222
+ console.log(chalk.gray(` Line ${change.lineNum}:`));
223
+ // Mask secret value in the diff output — never print raw secrets
224
+ const maskedOriginal = maskLine(change.originalLine);
225
+ console.log(chalk.red(` - ${maskedOriginal.trim()}`));
226
+ console.log(chalk.green(` + ${change.newLine.trim()}`));
227
+ }
228
+ }
229
+
230
+ /**
231
+ * Mask what looks like a secret value in a line for safe display.
232
+ * Shows first 4 chars + asterisks so the user can identify which secret it is.
233
+ */
234
+ function maskLine(line) {
235
+ // Mask quoted strings that look like secrets (>8 chars of alphanum)
236
+ return line.replace(/["']([a-zA-Z0-9_\-+/=.]{8,})["']/g, (_, val) => {
237
+ const prefix = val.substring(0, 4);
238
+ return `"${prefix}${'*'.repeat(Math.min(val.length - 4, 12))}"`;
239
+ });
240
+ }
241
+
242
+ // =============================================================================
243
+ // CONFIRMATION PROMPT
244
+ // =============================================================================
245
+
246
+ async function confirm(question) {
247
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
248
+ return new Promise((resolve) => {
249
+ rl.question(chalk.yellow(`\n ${question} `), (answer) => {
250
+ rl.close();
251
+ const a = answer.trim().toLowerCase();
252
+ if (a === 's' || a === 'skip') resolve('skip');
253
+ else if (a === 'n' || a === 'no') resolve('no');
254
+ else resolve('yes');
255
+ });
256
+ });
257
+ }
258
+
259
+ // =============================================================================
260
+ // FILE BACKUP
261
+ // =============================================================================
262
+
263
+ function createBackupDir(rootPath) {
264
+ const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
265
+ const backupDir = path.join(rootPath, '.ship-safe-backup', ts);
266
+ fs.mkdirSync(backupDir, { recursive: true });
267
+ return backupDir;
268
+ }
269
+
270
+ function backupFile(filePath, backupDir, rootPath) {
271
+ const rel = path.relative(rootPath, filePath);
272
+ const dest = path.join(backupDir, rel);
273
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
274
+ fs.copyFileSync(filePath, dest);
275
+ }
276
+
277
+ // =============================================================================
278
+ // VERIFICATION
279
+ // =============================================================================
280
+
281
+ /**
282
+ * Re-scan the modified content string to verify secrets are gone.
283
+ * Returns true if clean, false if any of the original secrets still appear.
284
+ */
285
+ function verifyFixed(modifiedContent, changes) {
286
+ for (const change of changes) {
287
+ // Check that the original matched string is gone from the file
288
+ if (modifiedContent.includes(change.secretValue)) {
289
+ return false;
290
+ }
291
+ }
292
+ return true;
293
+ }
294
+
295
+ // =============================================================================
296
+ // ENV FILE MANAGEMENT
297
+ // =============================================================================
298
+
299
+ /**
300
+ * Append new env vars to .env file.
301
+ * - Creates .env if it doesn't exist
302
+ * - Skips vars that already exist in .env
303
+ * - Uses atomic write
304
+ * - Sets 0o600 permissions on Unix
305
+ */
306
+ async function writeEnvFile(rootPath, envVars) {
307
+ const envPath = path.join(rootPath, '.env');
308
+ let existing = '';
309
+
310
+ if (fs.existsSync(envPath)) {
311
+ existing = fs.readFileSync(envPath, 'utf-8');
312
+ }
313
+
314
+ const newLines = [];
315
+ const addedVars = [];
316
+
317
+ for (const [varName, secretValue] of Object.entries(envVars)) {
318
+ // Skip if already defined in .env
319
+ const alreadyDefined = new RegExp(`^${varName}=`, 'm').test(existing);
320
+ if (alreadyDefined) continue;
321
+
322
+ newLines.push(`${varName}=${secretValue}`);
323
+ addedVars.push(varName);
324
+ }
325
+
326
+ if (newLines.length === 0) return addedVars;
327
+
328
+ const separator = existing.endsWith('\n') || existing === '' ? '' : '\n';
329
+ const addition = separator + newLines.join('\n') + '\n';
330
+ const newContent = existing + addition;
331
+
332
+ await writeFileAtomic(envPath, newContent, { encoding: 'utf8' });
333
+
334
+ // Set restrictive permissions on Unix (no-op on Windows)
335
+ if (os.platform() !== 'win32') {
336
+ fs.chmodSync(envPath, 0o600);
337
+ }
338
+
339
+ return addedVars;
340
+ }
341
+
342
+ /**
343
+ * Ensure .env is in .gitignore.
344
+ * Adds it if missing. Called BEFORE writing .env.
345
+ */
346
+ function updateGitignore(rootPath) {
347
+ const gitignorePath = path.join(rootPath, '.gitignore');
348
+ let content = '';
349
+
350
+ if (fs.existsSync(gitignorePath)) {
351
+ content = fs.readFileSync(gitignorePath, 'utf-8');
352
+ }
353
+
354
+ const lines = content.split('\n').map(l => l.trim());
355
+ const hasEnv = lines.some(l => l === '.env' || l === '*.env');
356
+
357
+ if (!hasEnv) {
358
+ const addition = content.endsWith('\n') || content === ''
359
+ ? '.env\n'
360
+ : '\n.env\n';
361
+ fs.writeFileSync(gitignorePath, content + addition);
362
+ return true; // added
363
+ }
364
+ return false; // already present
365
+ }
366
+
367
+ /**
368
+ * Add placeholder entries to .env.example.
369
+ * Safe to call multiple times — skips vars already in the file.
370
+ */
371
+ function updateEnvExample(rootPath, envVars) {
372
+ const examplePath = path.join(rootPath, '.env.example');
373
+ let existing = '';
374
+
375
+ if (fs.existsSync(examplePath)) {
376
+ existing = fs.readFileSync(examplePath, 'utf-8');
377
+ }
378
+
379
+ const newLines = [];
380
+ for (const varName of Object.keys(envVars)) {
381
+ const alreadyDefined = new RegExp(`^${varName}=`, 'm').test(existing);
382
+ if (!alreadyDefined) {
383
+ newLines.push(`${varName}=your_${varName.toLowerCase()}_here`);
384
+ }
385
+ }
386
+
387
+ if (newLines.length === 0) return;
388
+
389
+ const separator = existing.endsWith('\n') || existing === '' ? '' : '\n';
390
+ fs.writeFileSync(examplePath, existing + separator + newLines.join('\n') + '\n');
391
+ }
392
+
393
+ // =============================================================================
394
+ // PUBLIC REPO WARNING
395
+ // =============================================================================
396
+
397
+ function checkPublicRepo(rootPath) {
398
+ try {
399
+ const remotes = execSync('git remote -v', { cwd: rootPath, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'] });
400
+ if (remotes.includes('github.com') || remotes.includes('gitlab.com')) {
401
+ // We can't easily check visibility without an API call, so warn if it looks like a hosted repo
402
+ console.log();
403
+ console.log(chalk.yellow.bold(' ⚠ Heads up: this repo is hosted remotely.'));
404
+ console.log(chalk.yellow(' If secrets were already pushed, rotating them is more urgent than this fix.'));
405
+ console.log(chalk.yellow(' Run ship-safe rotate first if you haven\'t already.'));
406
+ }
407
+ } catch { /* Not a git repo or no remote — skip */ }
408
+ }
409
+
410
+ // =============================================================================
411
+ // GIT STAGING
412
+ // =============================================================================
413
+
414
+ function stageFiles(files, rootPath) {
415
+ if (files.length === 0) return;
416
+ try {
417
+ const quoted = files.map(f => `"${f}"`).join(' ');
418
+ execSync(`git add ${quoted}`, { cwd: rootPath, stdio: 'inherit' }); // ship-safe-ignore — paths come from our own file scan
419
+ output.success(`Staged ${files.length} file(s) with git add`);
420
+ } catch {
421
+ output.warning('Could not stage files — run git add manually.');
422
+ }
423
+ }
424
+
425
+ // =============================================================================
426
+ // SCAN (local, includes lineContent for replacement)
427
+ // =============================================================================
428
+
429
+ async function findFiles(rootPath) {
430
+ const globIgnore = Array.from(SKIP_DIRS).map(dir => `**/${dir}/**`);
431
+ const files = await fg('**/*', {
432
+ cwd: rootPath, absolute: true, onlyFiles: true, ignore: globIgnore, dot: true
433
+ });
434
+
435
+ const filtered = [];
436
+ for (const file of files) {
437
+ const ext = path.extname(file).toLowerCase();
438
+ if (SKIP_EXTENSIONS.has(ext)) continue;
439
+ const basename = path.basename(file);
440
+ if (basename.endsWith('.min.js') || basename.endsWith('.min.css')) continue;
441
+ if (TEST_FILE_PATTERNS.some(p => p.test(file))) continue;
442
+ if (basename === '.env' || basename === '.env.example') continue;
443
+ try {
444
+ const stats = fs.statSync(file);
445
+ if (stats.size > MAX_FILE_SIZE) continue;
446
+ } catch { continue; }
447
+ filtered.push(file);
448
+ }
449
+ return filtered;
450
+ }
451
+
452
+ async function scanFile(filePath) {
453
+ const findings = [];
454
+ try {
455
+ const content = fs.readFileSync(filePath, 'utf-8');
456
+ const lines = content.split('\n');
457
+
458
+ for (let lineNum = 0; lineNum < lines.length; lineNum++) {
459
+ const line = lines[lineNum];
460
+ if (/ship-safe-ignore/i.test(line)) continue;
461
+
462
+ for (const pattern of SECRET_PATTERNS) {
463
+ pattern.pattern.lastIndex = 0;
464
+ let match;
465
+ while ((match = pattern.pattern.exec(line)) !== null) {
466
+ if (pattern.requiresEntropyCheck && !isHighEntropyMatch(match[0])) continue;
467
+ findings.push({
468
+ line: lineNum + 1,
469
+ column: match.index + 1,
470
+ matched: match[0],
471
+ patternName: pattern.name,
472
+ severity: pattern.severity,
473
+ confidence: getConfidence(pattern, match[0]),
474
+ });
475
+ }
476
+ }
477
+ }
478
+ } catch { /* skip unreadable files */ }
479
+
480
+ return findings;
481
+ }
482
+
483
+ // =============================================================================
484
+ // MAIN COMMAND
485
+ // =============================================================================
486
+
487
+ export async function remediateCommand(targetPath = '.', options = {}) {
488
+ const absolutePath = path.resolve(targetPath);
489
+
490
+ if (!fs.existsSync(absolutePath)) {
491
+ output.error(`Path does not exist: ${absolutePath}`);
492
+ process.exit(1);
493
+ }
494
+
495
+ // ── 1. Scan ──────────────────────────────────────────────────────────────
496
+ const spinner = ora({ text: 'Scanning for secrets to remediate...', color: 'cyan' }).start();
497
+
498
+ const files = await findFiles(absolutePath);
499
+ const scanResults = [];
500
+
501
+ for (const file of files) {
502
+ const findings = await scanFile(file);
503
+ if (findings.length > 0) scanResults.push({ file, findings });
504
+ }
505
+
506
+ spinner.stop();
507
+
508
+ if (scanResults.length === 0) {
509
+ output.success('No secrets found — nothing to remediate!');
510
+ console.log(chalk.gray('\n Run ship-safe scan . to double-check.'));
511
+ return;
512
+ }
513
+
514
+ // ── 2. Build plan ─────────────────────────────────────────────────────────
515
+ const framework = detectFramework(absolutePath);
516
+ const plan = buildPlan(scanResults, framework, absolutePath);
517
+
518
+ const totalFindings = plan.reduce((sum, p) => sum + p.changes.length, 0);
519
+
520
+ output.header('Remediation Plan');
521
+ console.log(chalk.gray(`\n Framework detected: ${framework}`));
522
+ console.log(chalk.gray(` Found ${totalFindings} secret(s) in ${plan.length} file(s) to fix\n`));
523
+
524
+ // Show full diff for all files
525
+ for (const item of plan) {
526
+ showDiff(item, absolutePath);
527
+ }
528
+
529
+ // ── 3. Dry run ────────────────────────────────────────────────────────────
530
+ if (options.dryRun) {
531
+ console.log();
532
+ console.log(chalk.cyan('\n Dry run — no files modified.'));
533
+ console.log(chalk.gray(' Remove --dry-run to apply these changes.'));
534
+ return;
535
+ }
536
+
537
+ // ── 4. Warn if hosted remotely ────────────────────────────────────────────
538
+ checkPublicRepo(absolutePath);
539
+
540
+ // ── 5. Confirm before starting ────────────────────────────────────────────
541
+ if (!options.yes) {
542
+ const answer = await confirm(`Apply all ${totalFindings} fix(es)? [y/n]:`);
543
+ if (answer !== 'yes') {
544
+ console.log(chalk.gray('\n Aborted. No files were modified.'));
545
+ return;
546
+ }
547
+ }
548
+
549
+ // ── 6. Ensure .env is in .gitignore BEFORE writing .env ──────────────────
550
+ const addedToGitignore = updateGitignore(absolutePath);
551
+ if (addedToGitignore) {
552
+ output.success('Added .env to .gitignore');
553
+ }
554
+
555
+ // ── 7. Create backup directory ────────────────────────────────────────────
556
+ const backupDir = createBackupDir(absolutePath);
557
+
558
+ // ── 8. Process each file ──────────────────────────────────────────────────
559
+ const modifiedFiles = [];
560
+ const allEnvVars = {}; // varName → secretValue (deduplicated)
561
+
562
+ for (const item of plan) {
563
+ const relPath = path.relative(absolutePath, item.file);
564
+
565
+ // Per-file confirmation in interactive mode
566
+ if (!options.yes && plan.length > 1) {
567
+ showDiff(item, absolutePath);
568
+ const answer = await confirm(`Fix ${relPath}? [y/s(kip)/n(abort)]:`);
569
+ if (answer === 'skip') {
570
+ console.log(chalk.gray(` Skipped ${relPath}`));
571
+ continue;
572
+ }
573
+ if (answer === 'no') {
574
+ console.log(chalk.gray('\n Aborted. Previously fixed files are kept.'));
575
+ break;
576
+ }
577
+ }
578
+
579
+ // Backup original
580
+ backupFile(item.file, backupDir, absolutePath);
581
+
582
+ // Build modified content
583
+ const newContent = item.modifiedLines.join('\n');
584
+
585
+ // Verify the fix actually removes the secrets before writing
586
+ if (!verifyFixed(newContent, item.changes)) {
587
+ output.warning(`Verification failed for ${relPath} — skipping (original untouched)`);
588
+ continue;
589
+ }
590
+
591
+ // Atomic write
592
+ try {
593
+ await writeFileAtomic(item.file, newContent, { encoding: 'utf8' });
594
+ } catch (err) {
595
+ output.error(`Failed to write ${relPath}: ${err.message}`);
596
+ continue;
597
+ }
598
+
599
+ modifiedFiles.push(item.file);
600
+
601
+ // Collect env vars (first value wins for duplicates)
602
+ for (const change of item.changes) {
603
+ if (!(change.varName in allEnvVars)) {
604
+ allEnvVars[change.varName] = change.secretValue;
605
+ }
606
+ }
607
+
608
+ console.log(chalk.green(` ✓ Fixed ${relPath}`));
609
+ }
610
+
611
+ if (modifiedFiles.length === 0) {
612
+ console.log(chalk.yellow('\n No files were modified.'));
613
+ return;
614
+ }
615
+
616
+ // ── 9. Write .env ─────────────────────────────────────────────────────────
617
+ const addedVars = await writeEnvFile(absolutePath, allEnvVars);
618
+ if (addedVars.length > 0) {
619
+ output.success(`.env updated with ${addedVars.length} variable(s)`);
620
+ }
621
+
622
+ // ── 10. Update .env.example ───────────────────────────────────────────────
623
+ updateEnvExample(absolutePath, allEnvVars);
624
+ output.success('.env.example updated with placeholders');
625
+
626
+ // ── 11. Stage files if --stage ────────────────────────────────────────────
627
+ if (options.stage) {
628
+ stageFiles(modifiedFiles, absolutePath);
629
+ }
630
+
631
+ // ── 12. Summary ───────────────────────────────────────────────────────────
632
+ console.log();
633
+ console.log(chalk.cyan.bold(' Remediation complete'));
634
+ console.log(chalk.gray(` Files fixed: ${modifiedFiles.length}`));
635
+ console.log(chalk.gray(` Env vars added: ${addedVars.length}`));
636
+ console.log(chalk.gray(` Backup saved to: .ship-safe-backup/`));
637
+
638
+ console.log();
639
+ console.log(chalk.yellow.bold(' Next steps — do these in order:'));
640
+ console.log(chalk.white(' 1.') + chalk.gray(' Rotate your exposed keys immediately (ship-safe rotate)'));
641
+ console.log(chalk.white(' 2.') + chalk.gray(' Commit the fixed files: git add . && git commit -m "fix: remove hardcoded secrets"'));
642
+ console.log(chalk.white(' 3.') + chalk.gray(' Copy .env.example → .env and fill in fresh values'));
643
+ console.log(chalk.white(' 4.') + chalk.gray(' Run ship-safe scan . to verify everything is clean'));
644
+ console.log(chalk.white(' 5.') + chalk.gray(' If secrets were already pushed, also purge git history'));
645
+ console.log();
646
+ }