ship-safe 6.1.1 → 6.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.
Files changed (47) hide show
  1. package/README.md +735 -641
  2. package/cli/agents/api-fuzzer.js +345 -345
  3. package/cli/agents/auth-bypass-agent.js +348 -348
  4. package/cli/agents/base-agent.js +272 -272
  5. package/cli/agents/cicd-scanner.js +236 -201
  6. package/cli/agents/config-auditor.js +521 -521
  7. package/cli/agents/deep-analyzer.js +6 -2
  8. package/cli/agents/git-history-scanner.js +170 -170
  9. package/cli/agents/html-reporter.js +568 -568
  10. package/cli/agents/index.js +84 -84
  11. package/cli/agents/injection-tester.js +500 -500
  12. package/cli/agents/llm-redteam.js +251 -251
  13. package/cli/agents/mobile-scanner.js +231 -231
  14. package/cli/agents/orchestrator.js +322 -322
  15. package/cli/agents/pii-compliance-agent.js +301 -301
  16. package/cli/agents/scoring-engine.js +248 -248
  17. package/cli/agents/supabase-rls-agent.js +154 -154
  18. package/cli/agents/supply-chain-agent.js +650 -507
  19. package/cli/bin/ship-safe.js +452 -426
  20. package/cli/commands/agent.js +608 -608
  21. package/cli/commands/audit.js +986 -980
  22. package/cli/commands/baseline.js +193 -193
  23. package/cli/commands/ci.js +342 -342
  24. package/cli/commands/deps.js +516 -516
  25. package/cli/commands/doctor.js +159 -159
  26. package/cli/commands/fix.js +218 -218
  27. package/cli/commands/hooks.js +268 -0
  28. package/cli/commands/init.js +407 -407
  29. package/cli/commands/mcp.js +304 -304
  30. package/cli/commands/red-team.js +7 -1
  31. package/cli/commands/remediate.js +798 -798
  32. package/cli/commands/rotate.js +571 -571
  33. package/cli/commands/scan.js +569 -569
  34. package/cli/commands/score.js +449 -449
  35. package/cli/commands/watch.js +281 -281
  36. package/cli/hooks/patterns.js +313 -0
  37. package/cli/hooks/post-tool-use.js +140 -0
  38. package/cli/hooks/pre-tool-use.js +186 -0
  39. package/cli/index.js +73 -69
  40. package/cli/providers/llm-provider.js +397 -287
  41. package/cli/utils/autofix-rules.js +74 -74
  42. package/cli/utils/cache-manager.js +311 -311
  43. package/cli/utils/output.js +230 -230
  44. package/cli/utils/patterns.js +1121 -1121
  45. package/cli/utils/pdf-generator.js +94 -94
  46. package/package.json +69 -69
  47. package/configs/supabase/rls-templates.sql +0 -242
@@ -1,798 +1,798 @@
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, execFileSync } 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
- SKIP_FILENAMES,
48
- TEST_FILE_PATTERNS,
49
- MAX_FILE_SIZE
50
- } from '../utils/patterns.js';
51
- import { isHighEntropyMatch, getConfidence } from '../utils/entropy.js';
52
- import * as output from '../utils/output.js';
53
-
54
- // =============================================================================
55
- // FRAMEWORK DETECTION
56
- // =============================================================================
57
-
58
- function detectFramework(rootPath) {
59
- const pkgPath = path.join(rootPath, 'package.json');
60
- if (fs.existsSync(pkgPath)) {
61
- try {
62
- const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
63
- const deps = { ...pkg.dependencies, ...pkg.devDependencies };
64
- if (deps['next']) return 'nextjs';
65
- if (deps['nuxt'] || deps['nuxt3']) return 'nuxt';
66
- } catch { /* ignore */ }
67
- }
68
- return 'node'; // default: Node.js / process.env
69
- }
70
-
71
- function envVarRef(varName, framework, filePath = '') {
72
- if (filePath.endsWith('.py')) return `os.environ.get('${varName}')`;
73
- if (filePath.endsWith('.rb')) return `ENV['${varName}']`;
74
- // For Next.js keep standard process.env — user decides if NEXT_PUBLIC_ is needed
75
- return `process.env.${varName}`;
76
- }
77
-
78
- // =============================================================================
79
- // ENV VAR NAME GENERATION
80
- // =============================================================================
81
-
82
- /**
83
- * Convert pattern name to SCREAMING_SNAKE_CASE env var name.
84
- * e.g. "OpenAI API Key" → "OPENAI_API_KEY" // ship-safe-ignore — example name in doc comment, not a secret value
85
- * "[custom] My Token" → "MY_TOKEN"
86
- */
87
- function patternToEnvVar(patternName) {
88
- return patternName
89
- .replace(/^\[custom\]\s*/i, '')
90
- .toUpperCase()
91
- .replace(/[^A-Z0-9\s]/g, '')
92
- .trim()
93
- .replace(/\s+/g, '_');
94
- }
95
-
96
- /**
97
- * Ensure env var name is unique within the current session.
98
- * If "OPENAI_API_KEY" is already taken, returns "OPENAI_API_KEY_2". // ship-safe-ignore — example in doc comment
99
- */
100
- function uniqueVarName(baseName, seen) {
101
- if (!seen.has(baseName)) return baseName;
102
- let i = 2;
103
- while (seen.has(`${baseName}_${i}`)) i++;
104
- return `${baseName}_${i}`;
105
- }
106
-
107
- // =============================================================================
108
- // REPLACEMENT LOGIC
109
- // =============================================================================
110
-
111
- /**
112
- * Compute what to replace in a line and extract the raw secret value.
113
- *
114
- * Given: matched = 'apiKey = "sk-abc123xyz"', envRef = 'process.env.OPENAI_API_KEY' // ship-safe-ignore — example in doc comment, no real secret
115
- * Returns:
116
- * replacement = 'apiKey = process.env.OPENAI_API_KEY' // ship-safe-ignore — example replacement in doc comment
117
- * secretValue = 'sk-abc123xyz'
118
- */
119
- function computeReplacement(matched, envRef) {
120
- // Case 1: quoted assignment — key = "value" or key: 'value'
121
- const quotedAssignment = matched.match(/^(.*?[:=]\s*)["']([^"']{4,})["'](.*)$/s);
122
- if (quotedAssignment) {
123
- const [, prefix, secretValue, suffix] = quotedAssignment;
124
- return { replacement: prefix + envRef + suffix, secretValue };
125
- }
126
-
127
- // Case 2: unquoted assignment — key = value (no quotes around value)
128
- const unquotedAssignment = matched.match(/^(.*?[:=]\s*)([^\s"'<>\[\]{},;]{8,})(\s*)$/s);
129
- if (unquotedAssignment) {
130
- const [, prefix, secretValue, suffix] = unquotedAssignment;
131
- return { replacement: prefix + envRef + suffix, secretValue };
132
- }
133
-
134
- // Case 3: raw secret with no assignment context (e.g. AKIA..., ghp_...)
135
- return { replacement: envRef, secretValue: matched };
136
- }
137
-
138
- /**
139
- * Apply a single replacement to a line at the exact column position.
140
- * Uses column index to avoid regex issues with special characters.
141
- */
142
- function replaceInLine(line, matched, colIndex, replacement) {
143
- const before = line.substring(0, colIndex);
144
- const after = line.substring(colIndex + matched.length);
145
- return before + replacement + after;
146
- }
147
-
148
- // =============================================================================
149
- // PLAN BUILDING
150
- // =============================================================================
151
-
152
- /**
153
- * Build a complete remediation plan from scan results.
154
- * Returns an array of file-level plans, each with:
155
- * - file: absolute path
156
- * - originalLines: string[]
157
- * - modifiedLines: string[]
158
- * - changes: [{lineNum, originalLine, newLine, varName, secretValue}]
159
- */
160
- function buildPlan(scanResults, framework, rootPath) {
161
- const plan = [];
162
- const seenVarNames = new Set();
163
-
164
- for (const { file, findings } of scanResults) {
165
- const content = fs.readFileSync(file, 'utf-8');
166
- const originalLines = content.split('\n');
167
- const modifiedLines = [...originalLines];
168
- const changes = [];
169
-
170
- // Group findings by line, sort within each line by column descending
171
- // so right-to-left replacements don't shift column indices for earlier matches
172
- const byLine = {};
173
- for (const f of findings) {
174
- if (!byLine[f.line]) byLine[f.line] = [];
175
- byLine[f.line].push(f);
176
- }
177
-
178
- let fileHasChanges = false;
179
-
180
- for (const lineNumStr of Object.keys(byLine).sort((a, b) => Number(a) - Number(b))) {
181
- const lineNum = Number(lineNumStr);
182
- const lineFinders = byLine[lineNumStr].sort((a, b) => b.column - a.column);
183
- let lineContent = modifiedLines[lineNum - 1];
184
- const originalLine = originalLines[lineNum - 1];
185
-
186
- for (const f of lineFinders) {
187
- const baseVarName = patternToEnvVar(f.patternName);
188
- const varName = uniqueVarName(baseVarName, seenVarNames);
189
- seenVarNames.add(varName);
190
-
191
- const ref = envVarRef(varName, framework, file);
192
- const colIndex = f.column - 1;
193
-
194
- const { replacement, secretValue } = computeReplacement(f.matched, ref);
195
-
196
- lineContent = replaceInLine(lineContent, f.matched, colIndex, replacement);
197
- changes.push({ lineNum, originalLine, newLine: lineContent, varName, secretValue });
198
- fileHasChanges = true;
199
- }
200
-
201
- if (fileHasChanges) {
202
- modifiedLines[lineNum - 1] = lineContent;
203
- }
204
- }
205
-
206
- if (changes.length > 0) {
207
- plan.push({ file, originalLines, modifiedLines, changes });
208
- }
209
- }
210
-
211
- return plan;
212
- }
213
-
214
- // =============================================================================
215
- // DIFF DISPLAY
216
- // =============================================================================
217
-
218
- function showDiff(planItem, rootPath) {
219
- const relPath = path.relative(rootPath, planItem.file);
220
- console.log('\n' + chalk.white.bold(` ${relPath}`));
221
-
222
- for (const change of planItem.changes) {
223
- console.log(chalk.gray(` Line ${change.lineNum}:`));
224
- // Mask secret value in the diff output — never print raw secrets
225
- const maskedOriginal = maskLine(change.originalLine);
226
- console.log(chalk.red(` - ${maskedOriginal.trim()}`));
227
- console.log(chalk.green(` + ${change.newLine.trim()}`));
228
- }
229
- }
230
-
231
- /**
232
- * Mask what looks like a secret value in a line for safe display.
233
- * Shows first 4 chars + asterisks so the user can identify which secret it is.
234
- */
235
- function maskLine(line) {
236
- // Mask quoted strings that look like secrets (>8 chars of alphanum)
237
- return line.replace(/["']([a-zA-Z0-9_\-+/=.]{8,})["']/g, (_, val) => {
238
- const prefix = val.substring(0, 4);
239
- return `"${prefix}${'*'.repeat(Math.min(val.length - 4, 12))}"`;
240
- });
241
- }
242
-
243
- // =============================================================================
244
- // CONFIRMATION PROMPT
245
- // =============================================================================
246
-
247
- async function confirm(question) {
248
- const rl = createInterface({ input: process.stdin, output: process.stdout });
249
- return new Promise((resolve) => {
250
- rl.question(chalk.yellow(`\n ${question} `), (answer) => {
251
- rl.close();
252
- const a = answer.trim().toLowerCase();
253
- if (a === 's' || a === 'skip') resolve('skip');
254
- else if (a === 'n' || a === 'no') resolve('no');
255
- else resolve('yes');
256
- });
257
- });
258
- }
259
-
260
- // =============================================================================
261
- // FILE BACKUP
262
- // =============================================================================
263
-
264
- function createBackupDir(rootPath) {
265
- const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
266
- const backupDir = path.join(rootPath, '.ship-safe-backup', ts);
267
- fs.mkdirSync(backupDir, { recursive: true });
268
- return backupDir;
269
- }
270
-
271
- function backupFile(filePath, backupDir, rootPath) {
272
- const rel = path.relative(rootPath, filePath);
273
- const dest = path.join(backupDir, rel);
274
- const resolvedDest = path.resolve(dest);
275
- const resolvedBackupDir = path.resolve(backupDir);
276
- if (!resolvedDest.startsWith(resolvedBackupDir + path.sep) && resolvedDest !== resolvedBackupDir) {
277
- throw new Error(`Path traversal detected: ${rel} escapes backup directory`);
278
- }
279
- fs.mkdirSync(path.dirname(dest), { recursive: true });
280
- fs.copyFileSync(filePath, dest);
281
- }
282
-
283
- // =============================================================================
284
- // VERIFICATION
285
- // =============================================================================
286
-
287
- /**
288
- * Re-scan the modified content string to verify secrets are gone.
289
- * Returns true if clean, false if any of the original secrets still appear.
290
- */
291
- function verifyFixed(modifiedContent, changes) {
292
- for (const change of changes) {
293
- // Check that the original matched string is gone from the file
294
- if (modifiedContent.includes(change.secretValue)) {
295
- return false;
296
- }
297
- }
298
- return true;
299
- }
300
-
301
- // =============================================================================
302
- // ENV FILE MANAGEMENT
303
- // =============================================================================
304
-
305
- /**
306
- * Append new env vars to .env file.
307
- * - Creates .env if it doesn't exist
308
- * - Skips vars that already exist in .env
309
- * - Uses atomic write
310
- * - Sets 0o600 permissions on Unix
311
- */
312
- async function writeEnvFile(rootPath, envVars) {
313
- const envPath = path.join(rootPath, '.env');
314
- let existing = '';
315
-
316
- if (fs.existsSync(envPath)) {
317
- existing = fs.readFileSync(envPath, 'utf-8');
318
- }
319
-
320
- const newLines = [];
321
- const addedVars = [];
322
-
323
- for (const [varName, secretValue] of Object.entries(envVars)) {
324
- // Skip if already defined in .env
325
- const alreadyDefined = new RegExp(`^${varName}=`, 'm').test(existing);
326
- if (alreadyDefined) continue;
327
-
328
- newLines.push(`${varName}=${secretValue}`);
329
- addedVars.push(varName);
330
- }
331
-
332
- if (newLines.length === 0) return addedVars;
333
-
334
- const separator = existing.endsWith('\n') || existing === '' ? '' : '\n';
335
- const addition = separator + newLines.join('\n') + '\n';
336
- const newContent = existing + addition;
337
-
338
- await writeFileAtomic(envPath, newContent, { encoding: 'utf8' });
339
-
340
- // Set restrictive permissions on Unix (no-op on Windows)
341
- if (os.platform() !== 'win32') {
342
- fs.chmodSync(envPath, 0o600);
343
- }
344
-
345
- return addedVars;
346
- }
347
-
348
- /**
349
- * Ensure .env is in .gitignore.
350
- * Adds it if missing. Called BEFORE writing .env.
351
- */
352
- function updateGitignore(rootPath) {
353
- const gitignorePath = path.join(rootPath, '.gitignore');
354
- let content = '';
355
-
356
- if (fs.existsSync(gitignorePath)) {
357
- content = fs.readFileSync(gitignorePath, 'utf-8');
358
- }
359
-
360
- const lines = content.split('\n').map(l => l.trim());
361
- const hasEnv = lines.some(l => l === '.env' || l === '*.env');
362
-
363
- if (!hasEnv) {
364
- const addition = content.endsWith('\n') || content === ''
365
- ? '.env\n'
366
- : '\n.env\n';
367
- fs.writeFileSync(gitignorePath, content + addition);
368
- return true; // added
369
- }
370
- return false; // already present
371
- }
372
-
373
- /**
374
- * Add placeholder entries to .env.example.
375
- * Safe to call multiple times — skips vars already in the file.
376
- */
377
- function updateEnvExample(rootPath, envVars) {
378
- const examplePath = path.join(rootPath, '.env.example');
379
- let existing = '';
380
-
381
- if (fs.existsSync(examplePath)) {
382
- existing = fs.readFileSync(examplePath, 'utf-8');
383
- }
384
-
385
- const newLines = [];
386
- for (const varName of Object.keys(envVars)) {
387
- const alreadyDefined = new RegExp(`^${varName}=`, 'm').test(existing);
388
- if (!alreadyDefined) {
389
- newLines.push(`${varName}=your_${varName.toLowerCase()}_here`);
390
- }
391
- }
392
-
393
- if (newLines.length === 0) return;
394
-
395
- const separator = existing.endsWith('\n') || existing === '' ? '' : '\n';
396
- fs.writeFileSync(examplePath, existing + separator + newLines.join('\n') + '\n');
397
- }
398
-
399
- // =============================================================================
400
- // PUBLIC REPO WARNING
401
- // =============================================================================
402
-
403
- function checkPublicRepo(rootPath) {
404
- try {
405
- const remotes = execSync('git remote -v', { cwd: rootPath, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'] }); // ship-safe-ignore
406
- if (remotes.includes('github.com') || remotes.includes('gitlab.com')) {
407
- // We can't easily check visibility without an API call, so warn if it looks like a hosted repo
408
- console.log();
409
- console.log(chalk.yellow.bold(' ⚠ Heads up: this repo is hosted remotely.'));
410
- console.log(chalk.yellow(' If secrets were already pushed, rotating them is more urgent than this fix.'));
411
- console.log(chalk.yellow(' Run ship-safe rotate first if you haven\'t already.'));
412
- }
413
- } catch { /* Not a git repo or no remote — skip */ }
414
- }
415
-
416
- // =============================================================================
417
- // GIT STAGING
418
- // =============================================================================
419
-
420
- function stageFiles(files, rootPath) {
421
- if (files.length === 0) return;
422
- try {
423
- execFileSync('git', ['add', ...files], { cwd: rootPath, stdio: 'inherit' }); // ship-safe-ignore
424
- output.success(`Staged ${files.length} file(s) with git add`);
425
- } catch {
426
- output.warning('Could not stage files — run git add manually.');
427
- }
428
- }
429
-
430
- // =============================================================================
431
- // SCAN (local, includes lineContent for replacement)
432
- // =============================================================================
433
-
434
- async function findFiles(rootPath) {
435
- const globIgnore = Array.from(SKIP_DIRS).map(dir => `**/${dir}/**`);
436
- const files = await fg('**/*', {
437
- cwd: rootPath, absolute: true, onlyFiles: true, ignore: globIgnore, dot: true
438
- });
439
-
440
- const filtered = [];
441
- for (const file of files) {
442
- const ext = path.extname(file).toLowerCase();
443
- if (SKIP_EXTENSIONS.has(ext)) continue;
444
- if (SKIP_FILENAMES.has(path.basename(file))) continue;
445
- const basename = path.basename(file);
446
- if (basename.endsWith('.min.js') || basename.endsWith('.min.css')) continue;
447
- if (TEST_FILE_PATTERNS.some(p => p.test(file))) continue;
448
- if (basename === '.env' || basename === '.env.example') continue;
449
- try {
450
- const stats = fs.statSync(file);
451
- if (stats.size > MAX_FILE_SIZE) continue;
452
- } catch { continue; }
453
- filtered.push(file);
454
- }
455
- return filtered;
456
- }
457
-
458
- async function scanFile(filePath) {
459
- const findings = [];
460
- try {
461
- const content = fs.readFileSync(filePath, 'utf-8');
462
- const lines = content.split('\n');
463
-
464
- for (let lineNum = 0; lineNum < lines.length; lineNum++) {
465
- const line = lines[lineNum];
466
- if (/ship-safe-ignore/i.test(line)) continue;
467
-
468
- for (const pattern of SECRET_PATTERNS) {
469
- pattern.pattern.lastIndex = 0;
470
- let match;
471
- while ((match = pattern.pattern.exec(line)) !== null) {
472
- if (pattern.requiresEntropyCheck && !isHighEntropyMatch(match[0])) continue;
473
- findings.push({
474
- line: lineNum + 1,
475
- column: match.index + 1,
476
- matched: match[0],
477
- patternName: pattern.name,
478
- severity: pattern.severity,
479
- confidence: getConfidence(pattern, match[0]),
480
- });
481
- }
482
- }
483
- }
484
- } catch { /* skip unreadable files */ }
485
-
486
- return findings;
487
- }
488
-
489
- // =============================================================================
490
- // AUTO-FIX AGENT FINDINGS (--all flag)
491
- // =============================================================================
492
-
493
- /**
494
- * Apply automatic fixes for common agent findings:
495
- * 1. Pin GitHub Actions to SHA (uses@tag → uses@sha)
496
- * 2. Add httpOnly/secure/sameSite to cookie-setting code
497
- * 3. Add USER directive to Dockerfiles without one
498
- * 4. Disable debug mode (hardcoded debug → env var) ship-safe-ignore
499
- *
500
- * Returns array of human-readable fix descriptions.
501
- */
502
- async function autoFixAgentFindings(rootPath, options) { // ship-safe-ignore — function name, not an agent with elevated permissions
503
- const fixes = [];
504
-
505
- // ── 1. Pin GitHub Actions to commit SHA ─────────────────────────────
506
- const workflowDir = path.join(rootPath, '.github', 'workflows');
507
- if (fs.existsSync(workflowDir)) {
508
- const yamlFiles = fs.readdirSync(workflowDir).filter(f => f.endsWith('.yml') || f.endsWith('.yaml'));
509
- for (const file of yamlFiles) {
510
- const filePath = path.join(workflowDir, file);
511
- let content = fs.readFileSync(filePath, 'utf-8');
512
- let modified = false;
513
-
514
- // Match uses: owner/repo@v1.2.3 or uses: owner/repo@v1 (not already a SHA)
515
- const usesRegex = /^(\s+uses:\s+)([a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+)@(v?\d+[^\s#]*)/gm;
516
- content = content.replace(usesRegex, (match, prefix, repo, tag) => {
517
- // Skip if already pinned to SHA (40+ hex chars)
518
- if (/^[0-9a-f]{40,}$/i.test(tag)) return match;
519
- // Add a comment noting the original tag
520
- modified = true;
521
- return `${prefix}${repo}@${tag} # TODO: pin to SHA for supply chain safety`;
522
- });
523
-
524
- if (modified) {
525
- fs.writeFileSync(filePath, content);
526
- fixes.push(`.github/workflows/${file} — marked unpinned Actions for SHA pinning`);
527
- }
528
- }
529
- }
530
-
531
- // ── 2. Add httpOnly/secure/sameSite to cookie settings ──────────────
532
- const cookieFiles = await fg('**/*.{js,ts,jsx,tsx,mjs}', {
533
- cwd: rootPath, absolute: true, ignore: ['**/node_modules/**', '**/dist/**', '**/build/**'],
534
- });
535
-
536
- for (const filePath of cookieFiles.slice(0, 200)) {
537
- try {
538
- let content = fs.readFileSync(filePath, 'utf-8');
539
- let modified = false;
540
-
541
- // Pattern: res.cookie('name', value, { ... }) missing httpOnly
542
- // Only fix if we see res.cookie with an options object that lacks httpOnly
543
- const cookiePattern = /(res\.cookie\s*\([^)]*,\s*\{)([^}]*)(})/g;
544
- content = content.replace(cookiePattern, (match, prefix, opts, suffix) => {
545
- if (/httpOnly/i.test(opts)) return match; // already has it
546
- modified = true;
547
- const additions = [];
548
- if (!/httpOnly/i.test(opts)) additions.push(' httpOnly: true');
549
- if (!/secure/i.test(opts)) additions.push(' secure: true');
550
- if (!/sameSite/i.test(opts)) additions.push(" sameSite: 'strict'");
551
- const addStr = additions.length > 0 ? ',' + additions.join(',') : '';
552
- return prefix + opts.trimEnd() + addStr + ' ' + suffix;
553
- });
554
-
555
- if (modified) {
556
- fs.writeFileSync(filePath, content);
557
- const rel = path.relative(rootPath, filePath);
558
- fixes.push(`${rel} — added httpOnly/secure/sameSite to cookie options`);
559
- }
560
- } catch { /* skip */ }
561
- }
562
-
563
- // ── 3. Add USER directive to Dockerfiles ────────────────────────────
564
- const dockerfiles = await fg('**/Dockerfile*', {
565
- cwd: rootPath, absolute: true, ignore: ['**/node_modules/**'],
566
- });
567
-
568
- for (const filePath of dockerfiles) {
569
- try {
570
- let content = fs.readFileSync(filePath, 'utf-8');
571
- if (/^\s*USER\s+/m.test(content)) continue; // already has USER
572
-
573
- // Add USER before CMD/ENTRYPOINT
574
- const cmdMatch = content.match(/^(CMD|ENTRYPOINT)\s/m);
575
- if (cmdMatch) {
576
- const idx = content.indexOf(cmdMatch[0]);
577
- content = content.slice(0, idx) + 'USER 1001\n' + content.slice(idx);
578
- fs.writeFileSync(filePath, content);
579
- const rel = path.relative(rootPath, filePath);
580
- fixes.push(`${rel} — added USER 1001 before CMD/ENTRYPOINT`);
581
- }
582
- } catch { /* skip */ }
583
- }
584
-
585
- // ── 4. Replace hardcoded debug settings with env var reference ──── ship-safe-ignore
586
- const configFiles = await fg('**/*.{py,js,ts,env.example}', {
587
- cwd: rootPath, absolute: true, ignore: ['**/node_modules/**', '**/dist/**', '**/.env'],
588
- });
589
-
590
- for (const filePath of configFiles.slice(0, 100)) {
591
- try {
592
- let content = fs.readFileSync(filePath, 'utf-8');
593
- let modified = false;
594
-
595
- if (filePath.endsWith('.py')) {
596
- // Django/Flask: DEBUG=True → env var reference (ship-safe-ignore — regex pattern, not actual debug setting)
597
- content = content.replace(/^(\s*DEBUG\s*=\s*)True\s*$/gm, (match, prefix) => {
598
- modified = true;
599
- return `${prefix}os.environ.get('DEBUG', 'False') == 'True'`;
600
- });
601
- } else { // ship-safe-ignore — regex pattern matching debug settings, not actual debug config
602
- // JS/TS: debug:true → process.env.DEBUG reference
603
- content = content.replace(/^(\s*(?:DEBUG|debug)\s*[:=]\s*)true\s*([,;]?\s*)$/gm, (match, prefix, suffix) => {
604
- modified = true;
605
- return `${prefix}process.env.DEBUG === 'true'${suffix}`;
606
- });
607
- }
608
-
609
- if (modified) {
610
- fs.writeFileSync(filePath, content);
611
- const rel = path.relative(rootPath, filePath);
612
- fixes.push(`${rel} — replaced hardcoded debug setting with env var`); // ship-safe-ignore
613
- }
614
- } catch { /* skip */ }
615
- }
616
-
617
- return fixes;
618
- }
619
-
620
- // =============================================================================
621
- // MAIN COMMAND
622
- // =============================================================================
623
-
624
- export async function remediateCommand(targetPath = '.', options = {}) {
625
- const absolutePath = path.resolve(targetPath);
626
-
627
- if (!fs.existsSync(absolutePath)) {
628
- output.error(`Path does not exist: ${absolutePath}`);
629
- process.exit(1);
630
- }
631
-
632
- // ── 1. Scan ──────────────────────────────────────────────────────────────
633
- const spinner = ora({ text: 'Scanning for secrets to remediate...', color: 'cyan' }).start();
634
-
635
- const files = await findFiles(absolutePath);
636
- const scanResults = [];
637
-
638
- for (const file of files) {
639
- const findings = await scanFile(file);
640
- if (findings.length > 0) scanResults.push({ file, findings });
641
- }
642
-
643
- spinner.stop();
644
-
645
- if (scanResults.length === 0) {
646
- output.success('No secrets found — nothing to remediate!');
647
- console.log(chalk.gray('\n Run ship-safe scan . to double-check.'));
648
- return;
649
- }
650
-
651
- // ── 2. Build plan ─────────────────────────────────────────────────────────
652
- const framework = detectFramework(absolutePath);
653
- const plan = buildPlan(scanResults, framework, absolutePath);
654
-
655
- const totalFindings = plan.reduce((sum, p) => sum + p.changes.length, 0);
656
-
657
- output.header('Remediation Plan');
658
- console.log(chalk.gray(`\n Framework detected: ${framework}`));
659
- console.log(chalk.gray(` Found ${totalFindings} secret(s) in ${plan.length} file(s) to fix\n`));
660
-
661
- // Show full diff for all files
662
- for (const item of plan) {
663
- showDiff(item, absolutePath);
664
- }
665
-
666
- // ── 3. Dry run ────────────────────────────────────────────────────────────
667
- if (options.dryRun) {
668
- console.log();
669
- console.log(chalk.cyan('\n Dry run — no files modified.'));
670
- console.log(chalk.gray(' Remove --dry-run to apply these changes.'));
671
- return;
672
- }
673
-
674
- // ── 4. Warn if hosted remotely ────────────────────────────────────────────
675
- checkPublicRepo(absolutePath);
676
-
677
- // ── 5. Confirm before starting ────────────────────────────────────────────
678
- if (!options.yes) {
679
- const answer = await confirm(`Apply all ${totalFindings} fix(es)? [y/n]:`);
680
- if (answer !== 'yes') {
681
- console.log(chalk.gray('\n Aborted. No files were modified.'));
682
- return;
683
- }
684
- }
685
-
686
- // ── 6. Ensure .env is in .gitignore BEFORE writing .env ──────────────────
687
- const addedToGitignore = updateGitignore(absolutePath);
688
- if (addedToGitignore) {
689
- output.success('Added .env to .gitignore');
690
- }
691
-
692
- // ── 7. Create backup directory ────────────────────────────────────────────
693
- const backupDir = createBackupDir(absolutePath);
694
-
695
- // ── 8. Process each file ──────────────────────────────────────────────────
696
- const modifiedFiles = [];
697
- const allEnvVars = {}; // varName → secretValue (deduplicated)
698
-
699
- for (const item of plan) {
700
- const relPath = path.relative(absolutePath, item.file);
701
-
702
- // Per-file confirmation in interactive mode
703
- if (!options.yes && plan.length > 1) {
704
- showDiff(item, absolutePath);
705
- const answer = await confirm(`Fix ${relPath}? [y/s(kip)/n(abort)]:`);
706
- if (answer === 'skip') {
707
- console.log(chalk.gray(` Skipped ${relPath}`));
708
- continue;
709
- }
710
- if (answer === 'no') {
711
- console.log(chalk.gray('\n Aborted. Previously fixed files are kept.'));
712
- break;
713
- }
714
- }
715
-
716
- // Backup original
717
- backupFile(item.file, backupDir, absolutePath);
718
-
719
- // Build modified content
720
- const newContent = item.modifiedLines.join('\n');
721
-
722
- // Verify the fix actually removes the secrets before writing
723
- if (!verifyFixed(newContent, item.changes)) {
724
- output.warning(`Verification failed for ${relPath} — skipping (original untouched)`);
725
- continue;
726
- }
727
-
728
- // Atomic write
729
- try {
730
- await writeFileAtomic(item.file, newContent, { encoding: 'utf8' });
731
- } catch (err) {
732
- output.error(`Failed to write ${relPath}: ${err.message}`);
733
- continue;
734
- }
735
-
736
- modifiedFiles.push(item.file);
737
-
738
- // Collect env vars (first value wins for duplicates)
739
- for (const change of item.changes) {
740
- if (!(change.varName in allEnvVars)) {
741
- allEnvVars[change.varName] = change.secretValue;
742
- }
743
- }
744
-
745
- console.log(chalk.green(` ✓ Fixed ${relPath}`));
746
- }
747
-
748
- if (modifiedFiles.length === 0) {
749
- console.log(chalk.yellow('\n No files were modified.'));
750
- return;
751
- }
752
-
753
- // ── 9. Write .env ─────────────────────────────────────────────────────────
754
- const addedVars = await writeEnvFile(absolutePath, allEnvVars);
755
- if (addedVars.length > 0) {
756
- output.success(`.env updated with ${addedVars.length} variable(s)`);
757
- }
758
-
759
- // ── 10. Update .env.example ───────────────────────────────────────────────
760
- updateEnvExample(absolutePath, allEnvVars);
761
- output.success('.env.example updated with placeholders');
762
-
763
- // ── 11. Stage files if --stage ────────────────────────────────────────────
764
- if (options.stage) {
765
- stageFiles(modifiedFiles, absolutePath);
766
- }
767
-
768
- // ── 12. Auto-fix agent findings if --all ─────────────────────────────
769
- if (options.all) {
770
- const autoFixResults = await autoFixAgentFindings(absolutePath, options);
771
- if (autoFixResults.length > 0) {
772
- console.log();
773
- output.success(`Auto-fixed ${autoFixResults.length} additional issue(s):`);
774
- for (const r of autoFixResults) {
775
- console.log(chalk.gray(` ✓ ${r}`));
776
- }
777
- if (options.stage) {
778
- stageFiles(autoFixResults.map(r => r.split(' — ')[0]).filter(f => fs.existsSync(path.resolve(absolutePath, f))), absolutePath);
779
- }
780
- }
781
- }
782
-
783
- // ── 13. Summary ───────────────────────────────────────────────────────────
784
- console.log();
785
- console.log(chalk.cyan.bold(' Remediation complete'));
786
- console.log(chalk.gray(` Files fixed: ${modifiedFiles.length}`));
787
- console.log(chalk.gray(` Env vars added: ${addedVars.length}`));
788
- console.log(chalk.gray(` Backup saved to: .ship-safe-backup/`));
789
-
790
- console.log();
791
- console.log(chalk.yellow.bold(' Next steps — do these in order:'));
792
- console.log(chalk.white(' 1.') + chalk.gray(' Rotate your exposed keys immediately (ship-safe rotate)'));
793
- console.log(chalk.white(' 2.') + chalk.gray(' Commit the fixed files: git add . && git commit -m "fix: remove hardcoded secrets"'));
794
- console.log(chalk.white(' 3.') + chalk.gray(' Copy .env.example → .env and fill in fresh values'));
795
- console.log(chalk.white(' 4.') + chalk.gray(' Run ship-safe scan . to verify everything is clean'));
796
- console.log(chalk.white(' 5.') + chalk.gray(' If secrets were already pushed, also purge git history'));
797
- console.log();
798
- }
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, execFileSync } 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
+ SKIP_FILENAMES,
48
+ TEST_FILE_PATTERNS,
49
+ MAX_FILE_SIZE
50
+ } from '../utils/patterns.js';
51
+ import { isHighEntropyMatch, getConfidence } from '../utils/entropy.js';
52
+ import * as output from '../utils/output.js';
53
+
54
+ // =============================================================================
55
+ // FRAMEWORK DETECTION
56
+ // =============================================================================
57
+
58
+ function detectFramework(rootPath) {
59
+ const pkgPath = path.join(rootPath, 'package.json');
60
+ if (fs.existsSync(pkgPath)) {
61
+ try {
62
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
63
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
64
+ if (deps['next']) return 'nextjs';
65
+ if (deps['nuxt'] || deps['nuxt3']) return 'nuxt';
66
+ } catch { /* ignore */ }
67
+ }
68
+ return 'node'; // default: Node.js / process.env
69
+ }
70
+
71
+ function envVarRef(varName, framework, filePath = '') {
72
+ if (filePath.endsWith('.py')) return `os.environ.get('${varName}')`;
73
+ if (filePath.endsWith('.rb')) return `ENV['${varName}']`;
74
+ // For Next.js keep standard process.env — user decides if NEXT_PUBLIC_ is needed
75
+ return `process.env.${varName}`;
76
+ }
77
+
78
+ // =============================================================================
79
+ // ENV VAR NAME GENERATION
80
+ // =============================================================================
81
+
82
+ /**
83
+ * Convert pattern name to SCREAMING_SNAKE_CASE env var name.
84
+ * e.g. "OpenAI API Key" → "OPENAI_API_KEY" // ship-safe-ignore — example name in doc comment, not a secret value
85
+ * "[custom] My Token" → "MY_TOKEN"
86
+ */
87
+ function patternToEnvVar(patternName) {
88
+ return patternName
89
+ .replace(/^\[custom\]\s*/i, '')
90
+ .toUpperCase()
91
+ .replace(/[^A-Z0-9\s]/g, '')
92
+ .trim()
93
+ .replace(/\s+/g, '_');
94
+ }
95
+
96
+ /**
97
+ * Ensure env var name is unique within the current session.
98
+ * If "OPENAI_API_KEY" is already taken, returns "OPENAI_API_KEY_2". // ship-safe-ignore — example in doc comment
99
+ */
100
+ function uniqueVarName(baseName, seen) {
101
+ if (!seen.has(baseName)) return baseName;
102
+ let i = 2;
103
+ while (seen.has(`${baseName}_${i}`)) i++;
104
+ return `${baseName}_${i}`;
105
+ }
106
+
107
+ // =============================================================================
108
+ // REPLACEMENT LOGIC
109
+ // =============================================================================
110
+
111
+ /**
112
+ * Compute what to replace in a line and extract the raw secret value.
113
+ *
114
+ * Given: matched = 'apiKey = "sk-abc123xyz"', envRef = 'process.env.OPENAI_API_KEY' // ship-safe-ignore — example in doc comment, no real secret
115
+ * Returns:
116
+ * replacement = 'apiKey = process.env.OPENAI_API_KEY' // ship-safe-ignore — example replacement in doc comment
117
+ * secretValue = 'sk-abc123xyz'
118
+ */
119
+ function computeReplacement(matched, envRef) {
120
+ // Case 1: quoted assignment — key = "value" or key: 'value'
121
+ const quotedAssignment = matched.match(/^(.*?[:=]\s*)["']([^"']{4,})["'](.*)$/s);
122
+ if (quotedAssignment) {
123
+ const [, prefix, secretValue, suffix] = quotedAssignment;
124
+ return { replacement: prefix + envRef + suffix, secretValue };
125
+ }
126
+
127
+ // Case 2: unquoted assignment — key = value (no quotes around value)
128
+ const unquotedAssignment = matched.match(/^(.*?[:=]\s*)([^\s"'<>\[\]{},;]{8,})(\s*)$/s);
129
+ if (unquotedAssignment) {
130
+ const [, prefix, secretValue, suffix] = unquotedAssignment;
131
+ return { replacement: prefix + envRef + suffix, secretValue };
132
+ }
133
+
134
+ // Case 3: raw secret with no assignment context (e.g. AKIA..., ghp_...)
135
+ return { replacement: envRef, secretValue: matched };
136
+ }
137
+
138
+ /**
139
+ * Apply a single replacement to a line at the exact column position.
140
+ * Uses column index to avoid regex issues with special characters.
141
+ */
142
+ function replaceInLine(line, matched, colIndex, replacement) {
143
+ const before = line.substring(0, colIndex);
144
+ const after = line.substring(colIndex + matched.length);
145
+ return before + replacement + after;
146
+ }
147
+
148
+ // =============================================================================
149
+ // PLAN BUILDING
150
+ // =============================================================================
151
+
152
+ /**
153
+ * Build a complete remediation plan from scan results.
154
+ * Returns an array of file-level plans, each with:
155
+ * - file: absolute path
156
+ * - originalLines: string[]
157
+ * - modifiedLines: string[]
158
+ * - changes: [{lineNum, originalLine, newLine, varName, secretValue}]
159
+ */
160
+ function buildPlan(scanResults, framework, rootPath) {
161
+ const plan = [];
162
+ const seenVarNames = new Set();
163
+
164
+ for (const { file, findings } of scanResults) {
165
+ const content = fs.readFileSync(file, 'utf-8');
166
+ const originalLines = content.split('\n');
167
+ const modifiedLines = [...originalLines];
168
+ const changes = [];
169
+
170
+ // Group findings by line, sort within each line by column descending
171
+ // so right-to-left replacements don't shift column indices for earlier matches
172
+ const byLine = {};
173
+ for (const f of findings) {
174
+ if (!byLine[f.line]) byLine[f.line] = [];
175
+ byLine[f.line].push(f);
176
+ }
177
+
178
+ let fileHasChanges = false;
179
+
180
+ for (const lineNumStr of Object.keys(byLine).sort((a, b) => Number(a) - Number(b))) {
181
+ const lineNum = Number(lineNumStr);
182
+ const lineFinders = byLine[lineNumStr].sort((a, b) => b.column - a.column);
183
+ let lineContent = modifiedLines[lineNum - 1];
184
+ const originalLine = originalLines[lineNum - 1];
185
+
186
+ for (const f of lineFinders) {
187
+ const baseVarName = patternToEnvVar(f.patternName);
188
+ const varName = uniqueVarName(baseVarName, seenVarNames);
189
+ seenVarNames.add(varName);
190
+
191
+ const ref = envVarRef(varName, framework, file);
192
+ const colIndex = f.column - 1;
193
+
194
+ const { replacement, secretValue } = computeReplacement(f.matched, ref);
195
+
196
+ lineContent = replaceInLine(lineContent, f.matched, colIndex, replacement);
197
+ changes.push({ lineNum, originalLine, newLine: lineContent, varName, secretValue });
198
+ fileHasChanges = true;
199
+ }
200
+
201
+ if (fileHasChanges) {
202
+ modifiedLines[lineNum - 1] = lineContent;
203
+ }
204
+ }
205
+
206
+ if (changes.length > 0) {
207
+ plan.push({ file, originalLines, modifiedLines, changes });
208
+ }
209
+ }
210
+
211
+ return plan;
212
+ }
213
+
214
+ // =============================================================================
215
+ // DIFF DISPLAY
216
+ // =============================================================================
217
+
218
+ function showDiff(planItem, rootPath) {
219
+ const relPath = path.relative(rootPath, planItem.file);
220
+ console.log('\n' + chalk.white.bold(` ${relPath}`));
221
+
222
+ for (const change of planItem.changes) {
223
+ console.log(chalk.gray(` Line ${change.lineNum}:`));
224
+ // Mask secret value in the diff output — never print raw secrets
225
+ const maskedOriginal = maskLine(change.originalLine);
226
+ console.log(chalk.red(` - ${maskedOriginal.trim()}`));
227
+ console.log(chalk.green(` + ${change.newLine.trim()}`));
228
+ }
229
+ }
230
+
231
+ /**
232
+ * Mask what looks like a secret value in a line for safe display.
233
+ * Shows first 4 chars + asterisks so the user can identify which secret it is.
234
+ */
235
+ function maskLine(line) {
236
+ // Mask quoted strings that look like secrets (>8 chars of alphanum)
237
+ return line.replace(/["']([a-zA-Z0-9_\-+/=.]{8,})["']/g, (_, val) => {
238
+ const prefix = val.substring(0, 4);
239
+ return `"${prefix}${'*'.repeat(Math.min(val.length - 4, 12))}"`;
240
+ });
241
+ }
242
+
243
+ // =============================================================================
244
+ // CONFIRMATION PROMPT
245
+ // =============================================================================
246
+
247
+ async function confirm(question) {
248
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
249
+ return new Promise((resolve) => {
250
+ rl.question(chalk.yellow(`\n ${question} `), (answer) => {
251
+ rl.close();
252
+ const a = answer.trim().toLowerCase();
253
+ if (a === 's' || a === 'skip') resolve('skip');
254
+ else if (a === 'n' || a === 'no') resolve('no');
255
+ else resolve('yes');
256
+ });
257
+ });
258
+ }
259
+
260
+ // =============================================================================
261
+ // FILE BACKUP
262
+ // =============================================================================
263
+
264
+ function createBackupDir(rootPath) {
265
+ const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
266
+ const backupDir = path.join(rootPath, '.ship-safe-backup', ts);
267
+ fs.mkdirSync(backupDir, { recursive: true });
268
+ return backupDir;
269
+ }
270
+
271
+ function backupFile(filePath, backupDir, rootPath) {
272
+ const rel = path.relative(rootPath, filePath);
273
+ const dest = path.join(backupDir, rel);
274
+ const resolvedDest = path.resolve(dest);
275
+ const resolvedBackupDir = path.resolve(backupDir);
276
+ if (!resolvedDest.startsWith(resolvedBackupDir + path.sep) && resolvedDest !== resolvedBackupDir) {
277
+ throw new Error(`Path traversal detected: ${rel} escapes backup directory`);
278
+ }
279
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
280
+ fs.copyFileSync(filePath, dest);
281
+ }
282
+
283
+ // =============================================================================
284
+ // VERIFICATION
285
+ // =============================================================================
286
+
287
+ /**
288
+ * Re-scan the modified content string to verify secrets are gone.
289
+ * Returns true if clean, false if any of the original secrets still appear.
290
+ */
291
+ function verifyFixed(modifiedContent, changes) {
292
+ for (const change of changes) {
293
+ // Check that the original matched string is gone from the file
294
+ if (modifiedContent.includes(change.secretValue)) {
295
+ return false;
296
+ }
297
+ }
298
+ return true;
299
+ }
300
+
301
+ // =============================================================================
302
+ // ENV FILE MANAGEMENT
303
+ // =============================================================================
304
+
305
+ /**
306
+ * Append new env vars to .env file.
307
+ * - Creates .env if it doesn't exist
308
+ * - Skips vars that already exist in .env
309
+ * - Uses atomic write
310
+ * - Sets 0o600 permissions on Unix
311
+ */
312
+ async function writeEnvFile(rootPath, envVars) {
313
+ const envPath = path.join(rootPath, '.env');
314
+ let existing = '';
315
+
316
+ if (fs.existsSync(envPath)) {
317
+ existing = fs.readFileSync(envPath, 'utf-8');
318
+ }
319
+
320
+ const newLines = [];
321
+ const addedVars = [];
322
+
323
+ for (const [varName, secretValue] of Object.entries(envVars)) {
324
+ // Skip if already defined in .env
325
+ const alreadyDefined = new RegExp(`^${varName}=`, 'm').test(existing);
326
+ if (alreadyDefined) continue;
327
+
328
+ newLines.push(`${varName}=${secretValue}`);
329
+ addedVars.push(varName);
330
+ }
331
+
332
+ if (newLines.length === 0) return addedVars;
333
+
334
+ const separator = existing.endsWith('\n') || existing === '' ? '' : '\n';
335
+ const addition = separator + newLines.join('\n') + '\n';
336
+ const newContent = existing + addition;
337
+
338
+ await writeFileAtomic(envPath, newContent, { encoding: 'utf8' });
339
+
340
+ // Set restrictive permissions on Unix (no-op on Windows)
341
+ if (os.platform() !== 'win32') {
342
+ fs.chmodSync(envPath, 0o600);
343
+ }
344
+
345
+ return addedVars;
346
+ }
347
+
348
+ /**
349
+ * Ensure .env is in .gitignore.
350
+ * Adds it if missing. Called BEFORE writing .env.
351
+ */
352
+ function updateGitignore(rootPath) {
353
+ const gitignorePath = path.join(rootPath, '.gitignore');
354
+ let content = '';
355
+
356
+ if (fs.existsSync(gitignorePath)) {
357
+ content = fs.readFileSync(gitignorePath, 'utf-8');
358
+ }
359
+
360
+ const lines = content.split('\n').map(l => l.trim());
361
+ const hasEnv = lines.some(l => l === '.env' || l === '*.env');
362
+
363
+ if (!hasEnv) {
364
+ const addition = content.endsWith('\n') || content === ''
365
+ ? '.env\n'
366
+ : '\n.env\n';
367
+ fs.writeFileSync(gitignorePath, content + addition);
368
+ return true; // added
369
+ }
370
+ return false; // already present
371
+ }
372
+
373
+ /**
374
+ * Add placeholder entries to .env.example.
375
+ * Safe to call multiple times — skips vars already in the file.
376
+ */
377
+ function updateEnvExample(rootPath, envVars) {
378
+ const examplePath = path.join(rootPath, '.env.example');
379
+ let existing = '';
380
+
381
+ if (fs.existsSync(examplePath)) {
382
+ existing = fs.readFileSync(examplePath, 'utf-8');
383
+ }
384
+
385
+ const newLines = [];
386
+ for (const varName of Object.keys(envVars)) {
387
+ const alreadyDefined = new RegExp(`^${varName}=`, 'm').test(existing);
388
+ if (!alreadyDefined) {
389
+ newLines.push(`${varName}=your_${varName.toLowerCase()}_here`);
390
+ }
391
+ }
392
+
393
+ if (newLines.length === 0) return;
394
+
395
+ const separator = existing.endsWith('\n') || existing === '' ? '' : '\n';
396
+ fs.writeFileSync(examplePath, existing + separator + newLines.join('\n') + '\n');
397
+ }
398
+
399
+ // =============================================================================
400
+ // PUBLIC REPO WARNING
401
+ // =============================================================================
402
+
403
+ function checkPublicRepo(rootPath) {
404
+ try {
405
+ const remotes = execSync('git remote -v', { cwd: rootPath, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'] }); // ship-safe-ignore
406
+ if (remotes.includes('github.com') || remotes.includes('gitlab.com')) {
407
+ // We can't easily check visibility without an API call, so warn if it looks like a hosted repo
408
+ console.log();
409
+ console.log(chalk.yellow.bold(' ⚠ Heads up: this repo is hosted remotely.'));
410
+ console.log(chalk.yellow(' If secrets were already pushed, rotating them is more urgent than this fix.'));
411
+ console.log(chalk.yellow(' Run ship-safe rotate first if you haven\'t already.'));
412
+ }
413
+ } catch { /* Not a git repo or no remote — skip */ }
414
+ }
415
+
416
+ // =============================================================================
417
+ // GIT STAGING
418
+ // =============================================================================
419
+
420
+ function stageFiles(files, rootPath) {
421
+ if (files.length === 0) return;
422
+ try {
423
+ execFileSync('git', ['add', ...files], { cwd: rootPath, stdio: 'inherit' }); // ship-safe-ignore
424
+ output.success(`Staged ${files.length} file(s) with git add`);
425
+ } catch {
426
+ output.warning('Could not stage files — run git add manually.');
427
+ }
428
+ }
429
+
430
+ // =============================================================================
431
+ // SCAN (local, includes lineContent for replacement)
432
+ // =============================================================================
433
+
434
+ async function findFiles(rootPath) {
435
+ const globIgnore = Array.from(SKIP_DIRS).map(dir => `**/${dir}/**`);
436
+ const files = await fg('**/*', {
437
+ cwd: rootPath, absolute: true, onlyFiles: true, ignore: globIgnore, dot: true
438
+ });
439
+
440
+ const filtered = [];
441
+ for (const file of files) {
442
+ const ext = path.extname(file).toLowerCase();
443
+ if (SKIP_EXTENSIONS.has(ext)) continue;
444
+ if (SKIP_FILENAMES.has(path.basename(file))) continue;
445
+ const basename = path.basename(file);
446
+ if (basename.endsWith('.min.js') || basename.endsWith('.min.css')) continue;
447
+ if (TEST_FILE_PATTERNS.some(p => p.test(file))) continue;
448
+ if (basename === '.env' || basename === '.env.example') continue;
449
+ try {
450
+ const stats = fs.statSync(file);
451
+ if (stats.size > MAX_FILE_SIZE) continue;
452
+ } catch { continue; }
453
+ filtered.push(file);
454
+ }
455
+ return filtered;
456
+ }
457
+
458
+ async function scanFile(filePath) {
459
+ const findings = [];
460
+ try {
461
+ const content = fs.readFileSync(filePath, 'utf-8');
462
+ const lines = content.split('\n');
463
+
464
+ for (let lineNum = 0; lineNum < lines.length; lineNum++) {
465
+ const line = lines[lineNum];
466
+ if (/ship-safe-ignore/i.test(line)) continue;
467
+
468
+ for (const pattern of SECRET_PATTERNS) {
469
+ pattern.pattern.lastIndex = 0;
470
+ let match;
471
+ while ((match = pattern.pattern.exec(line)) !== null) {
472
+ if (pattern.requiresEntropyCheck && !isHighEntropyMatch(match[0])) continue;
473
+ findings.push({
474
+ line: lineNum + 1,
475
+ column: match.index + 1,
476
+ matched: match[0],
477
+ patternName: pattern.name,
478
+ severity: pattern.severity,
479
+ confidence: getConfidence(pattern, match[0]),
480
+ });
481
+ }
482
+ }
483
+ }
484
+ } catch { /* skip unreadable files */ }
485
+
486
+ return findings;
487
+ }
488
+
489
+ // =============================================================================
490
+ // AUTO-FIX AGENT FINDINGS (--all flag)
491
+ // =============================================================================
492
+
493
+ /**
494
+ * Apply automatic fixes for common agent findings:
495
+ * 1. Pin GitHub Actions to SHA (uses@tag → uses@sha)
496
+ * 2. Add httpOnly/secure/sameSite to cookie-setting code
497
+ * 3. Add USER directive to Dockerfiles without one
498
+ * 4. Disable debug mode (hardcoded debug → env var) ship-safe-ignore
499
+ *
500
+ * Returns array of human-readable fix descriptions.
501
+ */
502
+ async function autoFixAgentFindings(rootPath, options) { // ship-safe-ignore — function name, not an agent with elevated permissions
503
+ const fixes = [];
504
+
505
+ // ── 1. Pin GitHub Actions to commit SHA ─────────────────────────────
506
+ const workflowDir = path.join(rootPath, '.github', 'workflows');
507
+ if (fs.existsSync(workflowDir)) {
508
+ const yamlFiles = fs.readdirSync(workflowDir).filter(f => f.endsWith('.yml') || f.endsWith('.yaml'));
509
+ for (const file of yamlFiles) {
510
+ const filePath = path.join(workflowDir, file);
511
+ let content = fs.readFileSync(filePath, 'utf-8');
512
+ let modified = false;
513
+
514
+ // Match uses: owner/repo@v1.2.3 or uses: owner/repo@v1 (not already a SHA)
515
+ const usesRegex = /^(\s+uses:\s+)([a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+)@(v?\d+[^\s#]*)/gm;
516
+ content = content.replace(usesRegex, (match, prefix, repo, tag) => {
517
+ // Skip if already pinned to SHA (40+ hex chars)
518
+ if (/^[0-9a-f]{40,}$/i.test(tag)) return match;
519
+ // Add a comment noting the original tag
520
+ modified = true;
521
+ return `${prefix}${repo}@${tag} # TODO: pin to SHA for supply chain safety`;
522
+ });
523
+
524
+ if (modified) {
525
+ fs.writeFileSync(filePath, content);
526
+ fixes.push(`.github/workflows/${file} — marked unpinned Actions for SHA pinning`);
527
+ }
528
+ }
529
+ }
530
+
531
+ // ── 2. Add httpOnly/secure/sameSite to cookie settings ──────────────
532
+ const cookieFiles = await fg('**/*.{js,ts,jsx,tsx,mjs}', {
533
+ cwd: rootPath, absolute: true, ignore: ['**/node_modules/**', '**/dist/**', '**/build/**'],
534
+ });
535
+
536
+ for (const filePath of cookieFiles.slice(0, 200)) {
537
+ try {
538
+ let content = fs.readFileSync(filePath, 'utf-8');
539
+ let modified = false;
540
+
541
+ // Pattern: res.cookie('name', value, { ... }) missing httpOnly
542
+ // Only fix if we see res.cookie with an options object that lacks httpOnly
543
+ const cookiePattern = /(res\.cookie\s*\([^)]*,\s*\{)([^}]*)(})/g;
544
+ content = content.replace(cookiePattern, (match, prefix, opts, suffix) => {
545
+ if (/httpOnly/i.test(opts)) return match; // already has it
546
+ modified = true;
547
+ const additions = [];
548
+ if (!/httpOnly/i.test(opts)) additions.push(' httpOnly: true');
549
+ if (!/secure/i.test(opts)) additions.push(' secure: true');
550
+ if (!/sameSite/i.test(opts)) additions.push(" sameSite: 'strict'");
551
+ const addStr = additions.length > 0 ? ',' + additions.join(',') : '';
552
+ return prefix + opts.trimEnd() + addStr + ' ' + suffix;
553
+ });
554
+
555
+ if (modified) {
556
+ fs.writeFileSync(filePath, content);
557
+ const rel = path.relative(rootPath, filePath);
558
+ fixes.push(`${rel} — added httpOnly/secure/sameSite to cookie options`);
559
+ }
560
+ } catch { /* skip */ }
561
+ }
562
+
563
+ // ── 3. Add USER directive to Dockerfiles ────────────────────────────
564
+ const dockerfiles = await fg('**/Dockerfile*', {
565
+ cwd: rootPath, absolute: true, ignore: ['**/node_modules/**'],
566
+ });
567
+
568
+ for (const filePath of dockerfiles) {
569
+ try {
570
+ let content = fs.readFileSync(filePath, 'utf-8');
571
+ if (/^\s*USER\s+/m.test(content)) continue; // already has USER
572
+
573
+ // Add USER before CMD/ENTRYPOINT
574
+ const cmdMatch = content.match(/^(CMD|ENTRYPOINT)\s/m);
575
+ if (cmdMatch) {
576
+ const idx = content.indexOf(cmdMatch[0]);
577
+ content = content.slice(0, idx) + 'USER 1001\n' + content.slice(idx);
578
+ fs.writeFileSync(filePath, content);
579
+ const rel = path.relative(rootPath, filePath);
580
+ fixes.push(`${rel} — added USER 1001 before CMD/ENTRYPOINT`);
581
+ }
582
+ } catch { /* skip */ }
583
+ }
584
+
585
+ // ── 4. Replace hardcoded debug settings with env var reference ──── ship-safe-ignore
586
+ const configFiles = await fg('**/*.{py,js,ts,env.example}', {
587
+ cwd: rootPath, absolute: true, ignore: ['**/node_modules/**', '**/dist/**', '**/.env'],
588
+ });
589
+
590
+ for (const filePath of configFiles.slice(0, 100)) {
591
+ try {
592
+ let content = fs.readFileSync(filePath, 'utf-8');
593
+ let modified = false;
594
+
595
+ if (filePath.endsWith('.py')) {
596
+ // Django/Flask: DEBUG=True → env var reference (ship-safe-ignore — regex pattern, not actual debug setting)
597
+ content = content.replace(/^(\s*DEBUG\s*=\s*)True\s*$/gm, (match, prefix) => {
598
+ modified = true;
599
+ return `${prefix}os.environ.get('DEBUG', 'False') == 'True'`;
600
+ });
601
+ } else { // ship-safe-ignore — regex pattern matching debug settings, not actual debug config
602
+ // JS/TS: debug:true → process.env.DEBUG reference
603
+ content = content.replace(/^(\s*(?:DEBUG|debug)\s*[:=]\s*)true\s*([,;]?\s*)$/gm, (match, prefix, suffix) => {
604
+ modified = true;
605
+ return `${prefix}process.env.DEBUG === 'true'${suffix}`;
606
+ });
607
+ }
608
+
609
+ if (modified) {
610
+ fs.writeFileSync(filePath, content);
611
+ const rel = path.relative(rootPath, filePath);
612
+ fixes.push(`${rel} — replaced hardcoded debug setting with env var`); // ship-safe-ignore
613
+ }
614
+ } catch { /* skip */ }
615
+ }
616
+
617
+ return fixes;
618
+ }
619
+
620
+ // =============================================================================
621
+ // MAIN COMMAND
622
+ // =============================================================================
623
+
624
+ export async function remediateCommand(targetPath = '.', options = {}) {
625
+ const absolutePath = path.resolve(targetPath);
626
+
627
+ if (!fs.existsSync(absolutePath)) {
628
+ output.error(`Path does not exist: ${absolutePath}`);
629
+ process.exit(1);
630
+ }
631
+
632
+ // ── 1. Scan ──────────────────────────────────────────────────────────────
633
+ const spinner = ora({ text: 'Scanning for secrets to remediate...', color: 'cyan' }).start();
634
+
635
+ const files = await findFiles(absolutePath);
636
+ const scanResults = [];
637
+
638
+ for (const file of files) {
639
+ const findings = await scanFile(file);
640
+ if (findings.length > 0) scanResults.push({ file, findings });
641
+ }
642
+
643
+ spinner.stop();
644
+
645
+ if (scanResults.length === 0) {
646
+ output.success('No secrets found — nothing to remediate!');
647
+ console.log(chalk.gray('\n Run ship-safe scan . to double-check.'));
648
+ return;
649
+ }
650
+
651
+ // ── 2. Build plan ─────────────────────────────────────────────────────────
652
+ const framework = detectFramework(absolutePath);
653
+ const plan = buildPlan(scanResults, framework, absolutePath);
654
+
655
+ const totalFindings = plan.reduce((sum, p) => sum + p.changes.length, 0);
656
+
657
+ output.header('Remediation Plan');
658
+ console.log(chalk.gray(`\n Framework detected: ${framework}`));
659
+ console.log(chalk.gray(` Found ${totalFindings} secret(s) in ${plan.length} file(s) to fix\n`));
660
+
661
+ // Show full diff for all files
662
+ for (const item of plan) {
663
+ showDiff(item, absolutePath);
664
+ }
665
+
666
+ // ── 3. Dry run ────────────────────────────────────────────────────────────
667
+ if (options.dryRun) {
668
+ console.log();
669
+ console.log(chalk.cyan('\n Dry run — no files modified.'));
670
+ console.log(chalk.gray(' Remove --dry-run to apply these changes.'));
671
+ return;
672
+ }
673
+
674
+ // ── 4. Warn if hosted remotely ────────────────────────────────────────────
675
+ checkPublicRepo(absolutePath);
676
+
677
+ // ── 5. Confirm before starting ────────────────────────────────────────────
678
+ if (!options.yes) {
679
+ const answer = await confirm(`Apply all ${totalFindings} fix(es)? [y/n]:`);
680
+ if (answer !== 'yes') {
681
+ console.log(chalk.gray('\n Aborted. No files were modified.'));
682
+ return;
683
+ }
684
+ }
685
+
686
+ // ── 6. Ensure .env is in .gitignore BEFORE writing .env ──────────────────
687
+ const addedToGitignore = updateGitignore(absolutePath);
688
+ if (addedToGitignore) {
689
+ output.success('Added .env to .gitignore');
690
+ }
691
+
692
+ // ── 7. Create backup directory ────────────────────────────────────────────
693
+ const backupDir = createBackupDir(absolutePath);
694
+
695
+ // ── 8. Process each file ──────────────────────────────────────────────────
696
+ const modifiedFiles = [];
697
+ const allEnvVars = {}; // varName → secretValue (deduplicated)
698
+
699
+ for (const item of plan) {
700
+ const relPath = path.relative(absolutePath, item.file);
701
+
702
+ // Per-file confirmation in interactive mode
703
+ if (!options.yes && plan.length > 1) {
704
+ showDiff(item, absolutePath);
705
+ const answer = await confirm(`Fix ${relPath}? [y/s(kip)/n(abort)]:`);
706
+ if (answer === 'skip') {
707
+ console.log(chalk.gray(` Skipped ${relPath}`));
708
+ continue;
709
+ }
710
+ if (answer === 'no') {
711
+ console.log(chalk.gray('\n Aborted. Previously fixed files are kept.'));
712
+ break;
713
+ }
714
+ }
715
+
716
+ // Backup original
717
+ backupFile(item.file, backupDir, absolutePath);
718
+
719
+ // Build modified content
720
+ const newContent = item.modifiedLines.join('\n');
721
+
722
+ // Verify the fix actually removes the secrets before writing
723
+ if (!verifyFixed(newContent, item.changes)) {
724
+ output.warning(`Verification failed for ${relPath} — skipping (original untouched)`);
725
+ continue;
726
+ }
727
+
728
+ // Atomic write
729
+ try {
730
+ await writeFileAtomic(item.file, newContent, { encoding: 'utf8' });
731
+ } catch (err) {
732
+ output.error(`Failed to write ${relPath}: ${err.message}`);
733
+ continue;
734
+ }
735
+
736
+ modifiedFiles.push(item.file);
737
+
738
+ // Collect env vars (first value wins for duplicates)
739
+ for (const change of item.changes) {
740
+ if (!(change.varName in allEnvVars)) {
741
+ allEnvVars[change.varName] = change.secretValue;
742
+ }
743
+ }
744
+
745
+ console.log(chalk.green(` ✓ Fixed ${relPath}`));
746
+ }
747
+
748
+ if (modifiedFiles.length === 0) {
749
+ console.log(chalk.yellow('\n No files were modified.'));
750
+ return;
751
+ }
752
+
753
+ // ── 9. Write .env ─────────────────────────────────────────────────────────
754
+ const addedVars = await writeEnvFile(absolutePath, allEnvVars);
755
+ if (addedVars.length > 0) {
756
+ output.success(`.env updated with ${addedVars.length} variable(s)`);
757
+ }
758
+
759
+ // ── 10. Update .env.example ───────────────────────────────────────────────
760
+ updateEnvExample(absolutePath, allEnvVars);
761
+ output.success('.env.example updated with placeholders');
762
+
763
+ // ── 11. Stage files if --stage ────────────────────────────────────────────
764
+ if (options.stage) {
765
+ stageFiles(modifiedFiles, absolutePath);
766
+ }
767
+
768
+ // ── 12. Auto-fix agent findings if --all ─────────────────────────────
769
+ if (options.all) {
770
+ const autoFixResults = await autoFixAgentFindings(absolutePath, options);
771
+ if (autoFixResults.length > 0) {
772
+ console.log();
773
+ output.success(`Auto-fixed ${autoFixResults.length} additional issue(s):`);
774
+ for (const r of autoFixResults) {
775
+ console.log(chalk.gray(` ✓ ${r}`));
776
+ }
777
+ if (options.stage) {
778
+ stageFiles(autoFixResults.map(r => r.split(' — ')[0]).filter(f => fs.existsSync(path.resolve(absolutePath, f))), absolutePath);
779
+ }
780
+ }
781
+ }
782
+
783
+ // ── 13. Summary ───────────────────────────────────────────────────────────
784
+ console.log();
785
+ console.log(chalk.cyan.bold(' Remediation complete'));
786
+ console.log(chalk.gray(` Files fixed: ${modifiedFiles.length}`));
787
+ console.log(chalk.gray(` Env vars added: ${addedVars.length}`));
788
+ console.log(chalk.gray(` Backup saved to: .ship-safe-backup/`));
789
+
790
+ console.log();
791
+ console.log(chalk.yellow.bold(' Next steps — do these in order:'));
792
+ console.log(chalk.white(' 1.') + chalk.gray(' Rotate your exposed keys immediately (ship-safe rotate)'));
793
+ console.log(chalk.white(' 2.') + chalk.gray(' Commit the fixed files: git add . && git commit -m "fix: remove hardcoded secrets"'));
794
+ console.log(chalk.white(' 3.') + chalk.gray(' Copy .env.example → .env and fill in fresh values'));
795
+ console.log(chalk.white(' 4.') + chalk.gray(' Run ship-safe scan . to verify everything is clean'));
796
+ console.log(chalk.white(' 5.') + chalk.gray(' If secrets were already pushed, also purge git history'));
797
+ console.log();
798
+ }