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.
- package/README.md +626 -459
- package/cli/bin/ship-safe.js +200 -139
- package/cli/commands/agent.js +606 -0
- package/cli/commands/deps.js +447 -0
- package/cli/commands/fix.js +3 -3
- package/cli/commands/init.js +86 -3
- package/cli/commands/mcp.js +2 -2
- package/cli/commands/remediate.js +646 -0
- package/cli/commands/rotate.js +571 -0
- package/cli/commands/scan.js +64 -23
- package/cli/commands/score.js +446 -0
- package/cli/index.js +4 -1
- package/cli/utils/entropy.js +6 -0
- package/cli/utils/output.js +42 -2
- package/cli/utils/patterns.js +393 -1
- package/package.json +64 -63
|
@@ -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
|
+
}
|