ship-safe 6.4.0 → 8.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,383 @@
1
+ /**
2
+ * Autofix Command — Agentic Auto-Fix PRs
3
+ * ========================================
4
+ *
5
+ * Reads findings from a ship-safe report (or the last audit run), applies the
6
+ * LLM-generated fixes from Tier 3 deep analysis, commits them to a branch, and
7
+ * opens a GitHub pull request.
8
+ *
9
+ * Requires:
10
+ * - A report with deepAnalysis.fix fields (run with `--deep` flag)
11
+ * - Git repository
12
+ * - GitHub CLI (`gh`) installed for PR creation, OR GITHUB_TOKEN + repo info
13
+ *
14
+ * USAGE:
15
+ * npx ship-safe autofix Auto-fix from last report
16
+ * npx ship-safe autofix --report report.json Auto-fix from specific report
17
+ * npx ship-safe autofix --dry-run Preview fixes without applying
18
+ * npx ship-safe autofix --severity high Only fix critical+high findings
19
+ *
20
+ * SAFETY:
21
+ * - Never auto-commits secrets, config files, or .env
22
+ * - Always creates a new branch (never pushes to main/master/develop)
23
+ * - Dry-run mode shows a diff without writing any files
24
+ * - Each fix is applied atomically — if a file fails, others continue
25
+ */
26
+
27
+ import fs from 'fs';
28
+ import path from 'path';
29
+ import { execFileSync, execSync } from 'child_process';
30
+ import chalk from 'chalk';
31
+ import * as output from '../utils/output.js';
32
+
33
+ // Severity rank for filtering
34
+ const SEV_RANK = { critical: 4, high: 3, medium: 2, low: 1 };
35
+
36
+ // Files we never auto-edit (secrets, config, generated)
37
+ const NEVER_EDIT = [
38
+ /\.env(\.|$)/i,
39
+ /\.pem$|\.key$|\.p12$|\.pfx$/i,
40
+ /package-lock\.json$|yarn\.lock$|pnpm-lock\.yaml$/i,
41
+ /\.min\.(js|css)$/,
42
+ /node_modules\//,
43
+ /dist\//,
44
+ /build\//,
45
+ ];
46
+
47
+ // =============================================================================
48
+ // MAIN
49
+ // =============================================================================
50
+
51
+ export async function autofixCommand(options = {}) {
52
+ const rootPath = path.resolve(options.path || '.');
53
+ const dryRun = options.dryRun || false;
54
+ const minSev = options.severity || 'high';
55
+ const minRank = SEV_RANK[minSev] ?? 3;
56
+ const reportPath = options.report
57
+ ? path.resolve(options.report)
58
+ : findLastReport(rootPath);
59
+
60
+ console.log();
61
+ output.header('Ship Safe — Agentic Autofix');
62
+ console.log();
63
+
64
+ if (!reportPath || !fs.existsSync(reportPath)) {
65
+ output.error('No report found. Run `npx ship-safe audit . --deep --json` first, or pass --report <path>.');
66
+ console.log(chalk.gray(' The --deep flag enables Tier 3 exploit-chain analysis that generates fix suggestions.'));
67
+ process.exit(1);
68
+ }
69
+
70
+ // ── Load Report ─────────────────────────────────────────────────────────
71
+ let report;
72
+ try {
73
+ report = JSON.parse(fs.readFileSync(reportPath, 'utf-8'));
74
+ } catch (err) {
75
+ output.error(`Failed to parse report: ${err.message}`);
76
+ process.exit(1);
77
+ }
78
+
79
+ const findings = report.findings ?? [];
80
+ console.log(chalk.gray(` Report: ${reportPath}`));
81
+ console.log(chalk.gray(` Total findings: ${findings.length}`));
82
+ console.log();
83
+
84
+ // ── Filter to fixable findings ──────────────────────────────────────────
85
+ const fixable = findings.filter(f => {
86
+ if (!f.deepAnalysis?.fix) return false;
87
+ if ((SEV_RANK[f.severity] ?? 0) < minRank) return false;
88
+ if (!f.file) return false;
89
+ const absFile = path.resolve(rootPath, f.file);
90
+ if (NEVER_EDIT.some(p => p.test(absFile.replace(/\\/g, '/')))) return false;
91
+ if (!fs.existsSync(absFile)) return false;
92
+ return true;
93
+ });
94
+
95
+ if (fixable.length === 0) {
96
+ console.log(chalk.yellow(` No fixable findings found at severity >= ${minSev}.`));
97
+ console.log(chalk.gray(' Tip: Run `npx ship-safe audit . --deep` to generate AI-powered fix suggestions.'));
98
+ return;
99
+ }
100
+
101
+ console.log(chalk.cyan(` Found ${fixable.length} fixable finding(s) at severity >= ${minSev}:`));
102
+ console.log();
103
+
104
+ for (const f of fixable) {
105
+ const sev = f.severity === 'critical' ? chalk.red.bold(f.severity)
106
+ : f.severity === 'high' ? chalk.yellow(f.severity)
107
+ : chalk.blue(f.severity);
108
+ console.log(` ${sev} ${chalk.white(f.title)}`);
109
+ console.log(` ${chalk.gray('File:')} ${f.file}${f.line ? `:${f.line}` : ''}`);
110
+ console.log(` ${chalk.gray('Fix:')} ${f.deepAnalysis.fix}`);
111
+ console.log();
112
+ }
113
+
114
+ if (dryRun) {
115
+ console.log(chalk.yellow(' Dry-run mode — no files will be changed.'));
116
+ console.log(chalk.gray(' Remove --dry-run to apply fixes and open a PR.'));
117
+ return;
118
+ }
119
+
120
+ // ── Confirm ──────────────────────────────────────────────────────────────
121
+ if (!options.yes) {
122
+ const { createInterface } = await import('readline');
123
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
124
+ const answer = await new Promise(resolve => {
125
+ rl.question(chalk.cyan(` Apply ${fixable.length} fix(es) and open a PR? [y/N] `), resolve);
126
+ });
127
+ rl.close();
128
+ if (!/^y/i.test(answer)) {
129
+ console.log(chalk.gray('\n Cancelled.\n'));
130
+ return;
131
+ }
132
+ console.log();
133
+ }
134
+
135
+ // ── Check git state ──────────────────────────────────────────────────────
136
+ if (!isGitRepo(rootPath)) {
137
+ output.error('Not a git repository. Autofix requires git.');
138
+ process.exit(1);
139
+ }
140
+
141
+ const currentBranch = getCurrentBranch(rootPath);
142
+ const protectedBranches = ['main', 'master', 'develop', 'dev', 'production', 'staging'];
143
+ if (protectedBranches.includes(currentBranch)) {
144
+ // Work on a new branch instead
145
+ }
146
+
147
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
148
+ const branchName = `ship-safe/autofix-${timestamp}`;
149
+
150
+ console.log(chalk.gray(` Creating branch: ${branchName}`));
151
+ try {
152
+ execFileSync('git', ['checkout', '-b', branchName], { cwd: rootPath, stdio: 'pipe' });
153
+ } catch (err) {
154
+ output.error(`Failed to create branch: ${err.message}`);
155
+ process.exit(1);
156
+ }
157
+
158
+ // ── Apply fixes ──────────────────────────────────────────────────────────
159
+ const applied = [];
160
+ const failed = [];
161
+
162
+ for (const f of fixable) {
163
+ const absFile = path.resolve(rootPath, f.file);
164
+ const fix = f.deepAnalysis.fix;
165
+
166
+ try {
167
+ applyInlineAnnotation(absFile, f.line, fix);
168
+ applied.push(f);
169
+ console.log(chalk.green(` ✔ Annotated: ${f.file}:${f.line ?? ''}`));
170
+ } catch (err) {
171
+ failed.push({ finding: f, error: err.message });
172
+ console.log(chalk.yellow(` ⚠ Skipped: ${f.file} — ${err.message}`));
173
+ }
174
+ }
175
+
176
+ if (applied.length === 0) {
177
+ console.log(chalk.yellow('\n No files were changed. Cleaning up branch...'));
178
+ try {
179
+ execFileSync('git', ['checkout', currentBranch], { cwd: rootPath, stdio: 'pipe' });
180
+ execFileSync('git', ['branch', '-D', branchName], { cwd: rootPath, stdio: 'pipe' });
181
+ } catch { /* ignore cleanup errors */ }
182
+ return;
183
+ }
184
+
185
+ // ── Commit ───────────────────────────────────────────────────────────────
186
+ console.log(chalk.gray(`\n Committing ${applied.length} fix annotation(s)...`));
187
+
188
+ try {
189
+ const filesToStage = [...new Set(applied.map(f => path.resolve(rootPath, f.file)))];
190
+ execFileSync('git', ['add', ...filesToStage], { cwd: rootPath, stdio: 'pipe' });
191
+
192
+ const commitMsg = [
193
+ `fix(security): apply ship-safe autofix annotations`,
194
+ '',
195
+ `Addresses ${applied.length} finding(s) from ship-safe audit:`,
196
+ ...applied.map(f => `- ${f.severity.toUpperCase()}: ${f.title} (${f.file}${f.line ? `:${f.line}` : ''})`),
197
+ '',
198
+ 'Fix suggestions generated by ship-safe Tier 3 (Opus) deep analysis.',
199
+ 'Review each annotation and apply the suggested code change.',
200
+ ].join('\n');
201
+
202
+ execFileSync('git', ['commit', '-m', commitMsg], { cwd: rootPath, stdio: 'pipe' });
203
+ console.log(chalk.green(' Committed.'));
204
+ } catch (err) {
205
+ output.error(`Commit failed: ${err.message}`);
206
+ // Restore branch state
207
+ try { execFileSync('git', ['checkout', currentBranch], { cwd: rootPath, stdio: 'pipe' }); } catch { /* ignore */ }
208
+ process.exit(1);
209
+ }
210
+
211
+ // ── Push and open PR ─────────────────────────────────────────────────────
212
+ console.log(chalk.gray(' Pushing branch...'));
213
+ try {
214
+ execFileSync('git', ['push', '-u', 'origin', branchName], { cwd: rootPath, stdio: 'pipe' });
215
+ } catch (err) {
216
+ console.log(chalk.yellow(` Push failed: ${err.message}`));
217
+ console.log(chalk.gray(` Branch created locally: ${branchName}`));
218
+ console.log(chalk.gray(' Push manually with: git push -u origin ' + branchName));
219
+ return;
220
+ }
221
+
222
+ // ── Open PR ───────────────────────────────────────────────────────────────
223
+ const prBody = buildPRBody(applied, failed, reportPath);
224
+ const prTitle = `fix(security): ship-safe autofix — ${applied.length} finding(s)`;
225
+
226
+ let prUrl = null;
227
+ const ghAvailable = isCommandAvailable('gh');
228
+
229
+ if (ghAvailable) {
230
+ try {
231
+ const result = execFileSync('gh', [
232
+ 'pr', 'create',
233
+ '--title', prTitle,
234
+ '--body', prBody,
235
+ '--base', currentBranch,
236
+ '--head', branchName,
237
+ ], { cwd: rootPath, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
238
+ prUrl = result.trim();
239
+ } catch (err) {
240
+ console.log(chalk.yellow(` gh pr create failed: ${err.stderr?.toString().trim() || err.message}`));
241
+ }
242
+ }
243
+
244
+ // ── Summary ───────────────────────────────────────────────────────────────
245
+ console.log();
246
+ console.log(chalk.green.bold(` ✔ Autofix complete`));
247
+ console.log(` Applied: ${chalk.white(applied.length)} fix annotation(s)`);
248
+ if (failed.length > 0) console.log(` Skipped: ${chalk.yellow(failed.length)} (see above)`);
249
+ console.log(` Branch: ${chalk.cyan(branchName)}`);
250
+ if (prUrl) {
251
+ console.log(` PR: ${chalk.cyan(prUrl)}`);
252
+ } else {
253
+ console.log(chalk.gray(' Install `gh` (GitHub CLI) to auto-open pull requests.'));
254
+ }
255
+ console.log();
256
+ }
257
+
258
+ // =============================================================================
259
+ // HELPERS
260
+ // =============================================================================
261
+
262
+ /**
263
+ * Apply the fix as a structured comment annotation above the finding line.
264
+ * The comment explains the issue and the fix — a developer reviews and applies.
265
+ * We never blindly rewrite code; instead we annotate so engineers make the call.
266
+ */
267
+ /**
268
+ * Apply fix annotations to a list of findings in-place.
269
+ * Returns the count of files successfully annotated.
270
+ * Exported for use by the --agentic audit loop.
271
+ */
272
+ export function applyInlineAnnotations(findings) {
273
+ const NEVER_EDIT = new Set(['.env', '.env.local', '.env.production', 'secrets.json', '.npmrc', '.netrc']);
274
+ const fixable = findings.filter(f =>
275
+ f.fix && f.file && fs.existsSync(f.file) && !NEVER_EDIT.has(path.basename(f.file))
276
+ );
277
+ let count = 0;
278
+ for (const f of fixable.slice(0, 10)) {
279
+ try {
280
+ applyInlineAnnotation(f.file, f.line, f.fix);
281
+ count++;
282
+ } catch { /* skip unwritable */ }
283
+ }
284
+ return count;
285
+ }
286
+
287
+ export function applyInlineAnnotation(filePath, lineNum, fix) {
288
+ const content = fs.readFileSync(filePath, 'utf-8');
289
+ const lines = content.split('\n');
290
+ const idx = Math.max(0, (lineNum || 1) - 1);
291
+
292
+ if (idx >= lines.length) {
293
+ throw new Error(`Line ${lineNum} out of range`);
294
+ }
295
+
296
+ // Already annotated?
297
+ if (idx > 0 && /ship-safe-fix/i.test(lines[idx - 1])) return;
298
+
299
+ const indent = lines[idx].match(/^(\s*)/)?.[1] ?? '';
300
+ const isJs = /\.(js|ts|jsx|tsx|mjs|cjs|java|c|cpp|cs|go|rs|swift|kt)$/.test(filePath);
301
+ const isPy = /\.py$/.test(filePath);
302
+
303
+ // Wrap fix in a structured annotation comment
304
+ const fixLines = fix.split('\n').map(l => l.trim()).filter(Boolean);
305
+ let annotation;
306
+
307
+ if (isPy) {
308
+ annotation = [
309
+ `${indent}# ship-safe-fix [REVIEW REQUIRED]`,
310
+ ...fixLines.map(l => `${indent}# ${l}`),
311
+ ].join('\n');
312
+ } else {
313
+ annotation = [
314
+ `${indent}// ship-safe-fix [REVIEW REQUIRED]`,
315
+ ...fixLines.map(l => `${indent}// ${l}`),
316
+ ].join('\n');
317
+ }
318
+
319
+ lines.splice(idx, 0, annotation);
320
+ fs.writeFileSync(filePath, lines.join('\n'), 'utf-8');
321
+ }
322
+
323
+ function buildPRBody(applied, failed, reportPath) {
324
+ const rows = applied.map(f =>
325
+ `| ${f.severity} | ${f.title} | \`${f.file}${f.line ? `:${f.line}` : ''}\` | ${f.deepAnalysis.fix?.slice(0, 80) ?? ''} |`
326
+ ).join('\n');
327
+
328
+ return `## Security Autofix
329
+
330
+ Ship Safe detected **${applied.length}** security finding(s) and has annotated the affected files with fix instructions. Each annotation is marked \`// ship-safe-fix [REVIEW REQUIRED]\` — **please review and apply the suggested changes before merging.**
331
+
332
+ ### Findings Fixed
333
+
334
+ | Severity | Title | Location | Fix Summary |
335
+ |----------|-------|----------|-------------|
336
+ ${rows}
337
+
338
+ ${failed.length > 0 ? `### Skipped (${failed.length})\n\n${failed.map(f => `- ${f.finding.title}: ${f.error}`).join('\n')}` : ''}
339
+
340
+ ---
341
+
342
+ > Generated by [ship-safe](https://shipsafecli.com) — AI-powered security scanner.
343
+ > Run \`npx ship-safe audit . --deep\` to regenerate findings.
344
+
345
+ 🤖 Generated with [Claude Code](https://claude.com/claude-code)`;
346
+ }
347
+
348
+ function findLastReport(rootPath) {
349
+ // Look for common report filenames in order of preference
350
+ const candidates = [
351
+ 'ship-safe-report.json',
352
+ '.ship-safe/last-report.json',
353
+ 'security-report.json',
354
+ ].map(f => path.join(rootPath, f));
355
+
356
+ return candidates.find(p => fs.existsSync(p)) ?? null;
357
+ }
358
+
359
+ function isGitRepo(rootPath) {
360
+ try {
361
+ execFileSync('git', ['rev-parse', '--git-dir'], { cwd: rootPath, stdio: 'pipe' });
362
+ return true;
363
+ } catch {
364
+ return false;
365
+ }
366
+ }
367
+
368
+ function getCurrentBranch(rootPath) {
369
+ try {
370
+ return execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: rootPath, encoding: 'utf-8', stdio: 'pipe' }).trim();
371
+ } catch {
372
+ return 'main';
373
+ }
374
+ }
375
+
376
+ function isCommandAvailable(cmd) {
377
+ try {
378
+ execFileSync(cmd, ['--version'], { stdio: 'pipe' });
379
+ return true;
380
+ } catch {
381
+ return false;
382
+ }
383
+ }