ship-safe 9.1.1 → 9.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/cli/agents/llm-redteam.js +24 -2
- package/cli/agents/stateful-watcher.js +4 -7
- package/cli/agents/swarm-orchestrator.js +27 -65
- package/cli/bin/ship-safe.js +62 -7
- package/cli/commands/agent-fix.js +960 -0
- package/cli/commands/audit.js +24 -11
- package/cli/commands/red-team.js +10 -6
- package/cli/commands/shell.js +415 -0
- package/cli/commands/team-report.js +415 -0
- package/cli/commands/undo.js +143 -0
- package/cli/providers/llm-provider.js +149 -18
- package/cli/utils/output.js +21 -0
- package/package.json +1 -1
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Team Report Command
|
|
3
|
+
* ====================
|
|
4
|
+
*
|
|
5
|
+
* Converts raw Hermes Agent team output into a professional Ship Safe report.
|
|
6
|
+
* Strips ANSI codes and terminal chrome, parses structured FINDING: lines,
|
|
7
|
+
* and renders everything through Ship Safe's HTML reporter.
|
|
8
|
+
*
|
|
9
|
+
* USAGE:
|
|
10
|
+
* ship-safe team-report Read from stdin (pipe Hermes output)
|
|
11
|
+
* ship-safe team-report output.txt Read from file
|
|
12
|
+
* ship-safe team-report output.txt --html Save as HTML
|
|
13
|
+
* ship-safe team-report output.txt --json JSON output
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import fs from 'fs';
|
|
17
|
+
import path from 'path';
|
|
18
|
+
import chalk from 'chalk';
|
|
19
|
+
import * as output from '../utils/output.js';
|
|
20
|
+
import { printBanner } from '../utils/output.js';
|
|
21
|
+
|
|
22
|
+
// =============================================================================
|
|
23
|
+
// ANSI + TERMINAL NOISE STRIPPING
|
|
24
|
+
// =============================================================================
|
|
25
|
+
|
|
26
|
+
function stripAnsi(str) {
|
|
27
|
+
// Remove all ANSI escape sequences (colors, cursor moves, clears, etc.)
|
|
28
|
+
return str
|
|
29
|
+
.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '')
|
|
30
|
+
.replace(/\x1b\][^\x07]*\x07/g, '')
|
|
31
|
+
.replace(/\x1b[()][AB012]/g, '')
|
|
32
|
+
.replace(/\x9b[0-9;]*[A-Za-z]/g, '');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function stripHermesChrome(text) {
|
|
36
|
+
const lines = text.split('\n');
|
|
37
|
+
const cleaned = [];
|
|
38
|
+
let inSplash = false;
|
|
39
|
+
|
|
40
|
+
for (const line of lines) {
|
|
41
|
+
const t = line.trim();
|
|
42
|
+
|
|
43
|
+
// Skip the Hermes splash box (╭─ ... ─╮ ... ╰─ ... ─╯)
|
|
44
|
+
if (t.startsWith('╭─') || t.startsWith('╰─')) { inSplash = !inSplash; continue; }
|
|
45
|
+
if (inSplash) continue;
|
|
46
|
+
|
|
47
|
+
// Skip raw system prompt instructions leaked into output
|
|
48
|
+
if (t.startsWith('EXACTLY this format') || t.startsWith('FINDING: {"severity"')) continue;
|
|
49
|
+
if (t.match(/^─{10,}$/)) continue;
|
|
50
|
+
|
|
51
|
+
// Skip Hermes warning lines
|
|
52
|
+
if (t.startsWith('⚠') && t.includes('hermes')) continue;
|
|
53
|
+
if (t.startsWith('⚠') && t.includes('OPENROUTER')) continue;
|
|
54
|
+
if (t.startsWith('⚠') && (t.includes('API call failed') || t.includes('credits'))) continue;
|
|
55
|
+
if (t.startsWith('⏱') || t.startsWith('❌')) continue;
|
|
56
|
+
|
|
57
|
+
// Skip terminal screen-clear sequences
|
|
58
|
+
if (t === '[2J' || t === '[H' || t === '[2J[H') continue;
|
|
59
|
+
|
|
60
|
+
cleaned.push(line);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return cleaned.join('\n');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// =============================================================================
|
|
67
|
+
// FINDING PARSER
|
|
68
|
+
// =============================================================================
|
|
69
|
+
|
|
70
|
+
function parseFindings(text) {
|
|
71
|
+
const findings = [];
|
|
72
|
+
const findingRegex = /^FINDING:\s*(\{.+\})\s*$/gm;
|
|
73
|
+
let match;
|
|
74
|
+
|
|
75
|
+
while ((match = findingRegex.exec(text)) !== null) {
|
|
76
|
+
try {
|
|
77
|
+
const f = JSON.parse(match[1]);
|
|
78
|
+
if (f.severity && f.title) findings.push(f);
|
|
79
|
+
} catch { /* skip malformed */ }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return findings;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// =============================================================================
|
|
86
|
+
// AGENT SECTION PARSER
|
|
87
|
+
// =============================================================================
|
|
88
|
+
|
|
89
|
+
function parseAgentSections(text) {
|
|
90
|
+
const sections = [];
|
|
91
|
+
// Matches: ### Agent Name (Role) — N finding(s)
|
|
92
|
+
const sectionRegex = /###\s+(.+?)\s*(?:\(([^)]+)\))?\s*[—–-]+\s*(\d+)\s*finding/gi;
|
|
93
|
+
let match;
|
|
94
|
+
|
|
95
|
+
while ((match = sectionRegex.exec(text)) !== null) {
|
|
96
|
+
sections.push({
|
|
97
|
+
name: match[1].trim(),
|
|
98
|
+
role: match[2]?.trim() || '',
|
|
99
|
+
count: parseInt(match[3], 10),
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Also collect bullet findings under each section
|
|
104
|
+
const bulletRegex = /\[(CRITICAL|HIGH|MEDIUM|LOW|INFO)\]\s+(.+?)\s*[—–-]+\s*(.+)/gi;
|
|
105
|
+
const bullets = [];
|
|
106
|
+
while ((match = bulletRegex.exec(text)) !== null) {
|
|
107
|
+
bullets.push({
|
|
108
|
+
severity: match[1].toLowerCase(),
|
|
109
|
+
title: match[2].trim(),
|
|
110
|
+
location: match[3].trim(),
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return { sections, bullets };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// =============================================================================
|
|
118
|
+
// SYNTHESIS PARSER
|
|
119
|
+
// =============================================================================
|
|
120
|
+
|
|
121
|
+
function parseSynthesis(text) {
|
|
122
|
+
// Extract the Hermes synthesis block (inside ╭─ ⚕ Hermes ─╮ ... ╰─╯)
|
|
123
|
+
// After stripping chrome, look for the summary block
|
|
124
|
+
const lines = text.split('\n');
|
|
125
|
+
const synthesisLines = [];
|
|
126
|
+
let capturing = false;
|
|
127
|
+
|
|
128
|
+
for (const line of lines) {
|
|
129
|
+
const t = line.trim();
|
|
130
|
+
|
|
131
|
+
// The synthesis is the content after the agent section summary and before errors
|
|
132
|
+
if (t.match(/^Overall risk posture:/i)) { capturing = true; }
|
|
133
|
+
if (capturing) {
|
|
134
|
+
if (t.startsWith('⚠') || t.startsWith('❌') || t.startsWith('⏱')) break;
|
|
135
|
+
synthesisLines.push(line);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Also look for risk posture statement
|
|
140
|
+
const riskMatch = text.match(/Overall risk posture:\s*(.+)/i);
|
|
141
|
+
const riskPosture = riskMatch ? riskMatch[1].trim() : null;
|
|
142
|
+
|
|
143
|
+
// Parse roadmap sections
|
|
144
|
+
const immediateMatch = text.match(/\*\*Immediate[^*]*\*\*:?\s*([^\n]+(?:\n(?!\*\*)[^\n]+)*)/i);
|
|
145
|
+
const shortTermMatch = text.match(/\*\*Short-term[^*]*\*\*:?\s*([^\n]+(?:\n(?!\*\*)[^\n]+)*)/i);
|
|
146
|
+
const longTermMatch = text.match(/\*\*Long-term[^*]*\*\*:?\s*([^\n]+(?:\n(?!\*\*)[^\n]+)*)/i);
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
riskPosture,
|
|
150
|
+
synthesis: synthesisLines.join('\n').trim(),
|
|
151
|
+
roadmap: {
|
|
152
|
+
immediate: immediateMatch?.[1]?.trim() || null,
|
|
153
|
+
shortTerm: shortTermMatch?.[1]?.trim() || null,
|
|
154
|
+
longTerm: longTermMatch?.[1]?.trim() || null,
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// =============================================================================
|
|
160
|
+
// TARGET PARSER
|
|
161
|
+
// =============================================================================
|
|
162
|
+
|
|
163
|
+
function parseTarget(text) {
|
|
164
|
+
const match = text.match(/assessments?\s+of\s+\*\*([^*]+)\*\*/i);
|
|
165
|
+
return match ? match[1].trim() : 'Unknown Target';
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// =============================================================================
|
|
169
|
+
// HTML RENDERER
|
|
170
|
+
// =============================================================================
|
|
171
|
+
|
|
172
|
+
function generateHTML(target, findings, agentSections, synthesis, bullets) {
|
|
173
|
+
const date = new Date().toLocaleDateString('en-US', {
|
|
174
|
+
year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit',
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
const counts = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
|
|
178
|
+
for (const f of findings) counts[f.severity] = (counts[f.severity] || 0) + 1;
|
|
179
|
+
|
|
180
|
+
// Merge FINDING: JSON lines with bullet-parsed findings (bullets are fallback)
|
|
181
|
+
const allFindings = findings.length > 0 ? findings : bullets.map(b => ({
|
|
182
|
+
severity: b.severity,
|
|
183
|
+
title: b.title,
|
|
184
|
+
location: b.location,
|
|
185
|
+
remediation: '',
|
|
186
|
+
}));
|
|
187
|
+
|
|
188
|
+
// Recalculate counts from allFindings
|
|
189
|
+
const sevCounts = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
|
|
190
|
+
for (const f of allFindings) sevCounts[f.severity] = (sevCounts[f.severity] || 0) + 1;
|
|
191
|
+
|
|
192
|
+
const riskColor = (rp) => {
|
|
193
|
+
if (!rp) return '#94a3b8';
|
|
194
|
+
const lc = rp.toLowerCase();
|
|
195
|
+
if (lc.includes('critical')) return '#dc2626';
|
|
196
|
+
if (lc.includes('high')) return '#f97316';
|
|
197
|
+
if (lc.includes('medium')) return '#eab308';
|
|
198
|
+
return '#22c55e';
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const sevColors = { critical: '#dc2626', high: '#f97316', medium: '#eab308', low: '#3b82f6', info: '#94a3b8' };
|
|
202
|
+
|
|
203
|
+
const findingRows = allFindings.map(f => `
|
|
204
|
+
<tr>
|
|
205
|
+
<td><span class="sev sev-${f.severity}">${f.severity.toUpperCase()}</span></td>
|
|
206
|
+
<td><code>${f.location || '—'}</code></td>
|
|
207
|
+
<td><strong>${f.title}</strong>${f.cve ? `<br><small>CVE: ${f.cve}</small>` : ''}</td>
|
|
208
|
+
<td><small>${f.remediation || '—'}</small></td>
|
|
209
|
+
</tr>`).join('');
|
|
210
|
+
|
|
211
|
+
const agentRows = agentSections.sections.map(s => `
|
|
212
|
+
<tr>
|
|
213
|
+
<td>${s.name}</td>
|
|
214
|
+
<td><code>${s.role || '—'}</code></td>
|
|
215
|
+
<td style="color:${s.count > 0 ? '#f97316' : '#22c55e'}">${s.count}</td>
|
|
216
|
+
</tr>`).join('');
|
|
217
|
+
|
|
218
|
+
const roadmap = synthesis.roadmap;
|
|
219
|
+
const roadmapHTML = (roadmap.immediate || roadmap.shortTerm || roadmap.longTerm) ? `
|
|
220
|
+
<h2>Remediation Roadmap</h2>
|
|
221
|
+
<table>
|
|
222
|
+
<tbody>
|
|
223
|
+
${roadmap.immediate ? `<tr><td style="color:#dc2626;white-space:nowrap;font-weight:600">⚡ Immediate (24–48h)</td><td>${roadmap.immediate}</td></tr>` : ''}
|
|
224
|
+
${roadmap.shortTerm ? `<tr><td style="color:#f97316;white-space:nowrap;font-weight:600">📅 Short-term (1–2 weeks)</td><td>${roadmap.shortTerm}</td></tr>` : ''}
|
|
225
|
+
${roadmap.longTerm ? `<tr><td style="color:#eab308;white-space:nowrap;font-weight:600">🏗 Long-term (1–3 months)</td><td>${roadmap.longTerm}</td></tr>` : ''}
|
|
226
|
+
</tbody>
|
|
227
|
+
</table>` : '';
|
|
228
|
+
|
|
229
|
+
return `<!DOCTYPE html>
|
|
230
|
+
<html lang="en">
|
|
231
|
+
<head>
|
|
232
|
+
<meta charset="utf-8">
|
|
233
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
234
|
+
<title>Ship Safe Team Report — ${target}</title>
|
|
235
|
+
<style>
|
|
236
|
+
*{margin:0;padding:0;box-sizing:border-box}
|
|
237
|
+
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#0f172a;color:#e2e8f0;padding:2rem}
|
|
238
|
+
.container{max-width:1100px;margin:0 auto}
|
|
239
|
+
.header{display:flex;align-items:center;gap:1rem;margin-bottom:2rem}
|
|
240
|
+
.logo{font-size:1.5rem;font-weight:800;color:#38bdf8;letter-spacing:-1px}
|
|
241
|
+
.badge{background:#1e293b;padding:3px 10px;border-radius:20px;font-size:0.75rem;color:#94a3b8;border:1px solid #334155}
|
|
242
|
+
h1{font-size:1.8rem;font-weight:700;color:#f1f5f9;margin-bottom:0.25rem}
|
|
243
|
+
h2{font-size:1.1rem;font-weight:600;margin:2rem 0 1rem;color:#94a3b8;border-bottom:1px solid #1e293b;padding-bottom:0.5rem;text-transform:uppercase;letter-spacing:0.05em}
|
|
244
|
+
.meta{color:#64748b;font-size:0.85rem;margin-bottom:2rem}
|
|
245
|
+
.risk-card{background:#1e293b;border:1px solid #334155;border-radius:12px;padding:1.5rem 2rem;margin-bottom:2rem;display:flex;align-items:center;gap:1.5rem}
|
|
246
|
+
.risk-label{font-size:0.75rem;text-transform:uppercase;color:#64748b;margin-bottom:0.25rem}
|
|
247
|
+
.risk-value{font-size:1.5rem;font-weight:700}
|
|
248
|
+
.risk-desc{color:#94a3b8;font-size:0.9rem;flex:1}
|
|
249
|
+
.stats{display:grid;grid-template-columns:repeat(5,1fr);gap:0.75rem;margin-bottom:2rem}
|
|
250
|
+
.stat{background:#1e293b;padding:1.25rem;border-radius:8px;text-align:center;border:1px solid #334155}
|
|
251
|
+
.stat-number{font-size:2rem;font-weight:bold}
|
|
252
|
+
.stat-label{color:#64748b;font-size:0.75rem;margin-top:0.25rem;text-transform:uppercase}
|
|
253
|
+
table{width:100%;border-collapse:collapse;background:#1e293b;border-radius:8px;overflow:hidden;margin-bottom:2rem;border:1px solid #334155}
|
|
254
|
+
th{background:#334155;text-align:left;padding:0.75rem 1rem;font-size:0.75rem;text-transform:uppercase;color:#94a3b8;font-weight:600;letter-spacing:0.05em}
|
|
255
|
+
td{padding:0.75rem 1rem;border-top:1px solid #0f172a;font-size:0.85rem;vertical-align:top}
|
|
256
|
+
tr:hover{background:#263248}
|
|
257
|
+
code{background:#0f172a;padding:2px 6px;border-radius:4px;font-size:0.8rem;color:#38bdf8;word-break:break-all}
|
|
258
|
+
small{color:#64748b}
|
|
259
|
+
.sev{padding:2px 8px;border-radius:4px;font-size:0.7rem;font-weight:700;text-transform:uppercase;letter-spacing:0.05em}
|
|
260
|
+
.sev-critical{background:#dc262622;color:#fca5a5;border:1px solid #dc262644}
|
|
261
|
+
.sev-high{background:#f9731622;color:#fdba74;border:1px solid #f9731644}
|
|
262
|
+
.sev-medium{background:#eab30822;color:#fde047;border:1px solid #eab30844}
|
|
263
|
+
.sev-low{background:#3b82f622;color:#93c5fd;border:1px solid #3b82f644}
|
|
264
|
+
.sev-info{background:#94a3b822;color:#cbd5e1;border:1px solid #94a3b844}
|
|
265
|
+
.empty{text-align:center;color:#22c55e;padding:2rem}
|
|
266
|
+
.footer{text-align:center;color:#334155;margin-top:3rem;padding-top:1.5rem;border-top:1px solid #1e293b;font-size:0.8rem}
|
|
267
|
+
.powered{color:#38bdf8}
|
|
268
|
+
</style>
|
|
269
|
+
</head>
|
|
270
|
+
<body>
|
|
271
|
+
<div class="container">
|
|
272
|
+
|
|
273
|
+
<div class="header">
|
|
274
|
+
<span class="logo">Ship Safe</span>
|
|
275
|
+
<span class="badge">Team Security Report</span>
|
|
276
|
+
<span class="badge">Powered by Hermes Agent</span>
|
|
277
|
+
</div>
|
|
278
|
+
|
|
279
|
+
<h1>${target}</h1>
|
|
280
|
+
<p class="meta">Generated ${date} · ${allFindings.length} finding${allFindings.length !== 1 ? 's' : ''} · ${agentSections.sections.length} agent${agentSections.sections.length !== 1 ? 's' : ''}</p>
|
|
281
|
+
|
|
282
|
+
${synthesis.riskPosture ? `
|
|
283
|
+
<div class="risk-card">
|
|
284
|
+
<div>
|
|
285
|
+
<div class="risk-label">Overall Risk Posture</div>
|
|
286
|
+
<div class="risk-value" style="color:${riskColor(synthesis.riskPosture)}">${synthesis.riskPosture.split('—')[0].trim()}</div>
|
|
287
|
+
</div>
|
|
288
|
+
<div class="risk-desc">${synthesis.riskPosture.includes('—') ? synthesis.riskPosture.split('—').slice(1).join('—').trim() : ''}</div>
|
|
289
|
+
</div>` : ''}
|
|
290
|
+
|
|
291
|
+
<div class="stats">
|
|
292
|
+
<div class="stat"><div class="stat-number" style="color:#dc2626">${sevCounts.critical}</div><div class="stat-label">Critical</div></div>
|
|
293
|
+
<div class="stat"><div class="stat-number" style="color:#f97316">${sevCounts.high}</div><div class="stat-label">High</div></div>
|
|
294
|
+
<div class="stat"><div class="stat-number" style="color:#eab308">${sevCounts.medium}</div><div class="stat-label">Medium</div></div>
|
|
295
|
+
<div class="stat"><div class="stat-number" style="color:#3b82f6">${sevCounts.low}</div><div class="stat-label">Low</div></div>
|
|
296
|
+
<div class="stat"><div class="stat-number" style="color:#94a3b8">${sevCounts.info}</div><div class="stat-label">Info</div></div>
|
|
297
|
+
</div>
|
|
298
|
+
|
|
299
|
+
<h2>Findings</h2>
|
|
300
|
+
<table>
|
|
301
|
+
<thead><tr><th>Severity</th><th>Location</th><th>Issue</th><th>Remediation</th></tr></thead>
|
|
302
|
+
<tbody>${findingRows || '<tr><td colspan="4" class="empty">No findings — clean!</td></tr>'}</tbody>
|
|
303
|
+
</table>
|
|
304
|
+
|
|
305
|
+
${agentSections.sections.length > 0 ? `
|
|
306
|
+
<h2>Agent Team Summary</h2>
|
|
307
|
+
<table>
|
|
308
|
+
<thead><tr><th>Agent</th><th>Role</th><th>Findings</th></tr></thead>
|
|
309
|
+
<tbody>${agentRows}</tbody>
|
|
310
|
+
</table>` : ''}
|
|
311
|
+
|
|
312
|
+
${roadmapHTML}
|
|
313
|
+
|
|
314
|
+
<div class="footer">
|
|
315
|
+
Secured by <span class="powered">Ship Safe</span> · shipsafecli.com · <code>npx ship-safe red-team .</code>
|
|
316
|
+
</div>
|
|
317
|
+
</div>
|
|
318
|
+
</body>
|
|
319
|
+
</html>`;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// =============================================================================
|
|
323
|
+
// MAIN COMMAND
|
|
324
|
+
// =============================================================================
|
|
325
|
+
|
|
326
|
+
export async function teamReportCommand(inputFile, options = {}) {
|
|
327
|
+
let raw;
|
|
328
|
+
|
|
329
|
+
if (inputFile) {
|
|
330
|
+
if (!fs.existsSync(inputFile)) {
|
|
331
|
+
output.error(`File not found: ${inputFile}`);
|
|
332
|
+
process.exit(1);
|
|
333
|
+
}
|
|
334
|
+
raw = fs.readFileSync(inputFile, 'utf-8');
|
|
335
|
+
} else {
|
|
336
|
+
// Read from stdin
|
|
337
|
+
raw = fs.readFileSync('/dev/stdin', 'utf-8');
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Clean the input
|
|
341
|
+
const stripped = stripAnsi(raw);
|
|
342
|
+
const cleaned = stripHermesChrome(stripped);
|
|
343
|
+
|
|
344
|
+
// Parse
|
|
345
|
+
const target = parseTarget(stripped);
|
|
346
|
+
const findings = parseFindings(cleaned);
|
|
347
|
+
const agentSections = parseAgentSections(cleaned);
|
|
348
|
+
const synthesis = parseSynthesis(cleaned);
|
|
349
|
+
|
|
350
|
+
const allFindings = findings.length > 0 ? findings : agentSections.bullets.map(b => ({
|
|
351
|
+
severity: b.severity,
|
|
352
|
+
title: b.title,
|
|
353
|
+
location: b.location,
|
|
354
|
+
remediation: '',
|
|
355
|
+
}));
|
|
356
|
+
|
|
357
|
+
if (options.json) {
|
|
358
|
+
console.log(JSON.stringify({ target, findings: allFindings, agentSections: agentSections.sections, synthesis }, null, 2));
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
if (options.html !== undefined) {
|
|
363
|
+
const htmlPath = typeof options.html === 'string' ? options.html : 'team-report.html';
|
|
364
|
+
const html = generateHTML(target, findings, agentSections, synthesis, agentSections.bullets);
|
|
365
|
+
fs.writeFileSync(htmlPath, html, 'utf-8');
|
|
366
|
+
output.success(`Team report saved to ${htmlPath}`);
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Terminal output
|
|
371
|
+
printBanner();
|
|
372
|
+
console.log(chalk.cyan.bold(' Team Security Report'));
|
|
373
|
+
console.log(chalk.gray(` Target: ${target}`));
|
|
374
|
+
console.log();
|
|
375
|
+
|
|
376
|
+
if (synthesis.riskPosture) {
|
|
377
|
+
const rp = synthesis.riskPosture;
|
|
378
|
+
const color = rp.toLowerCase().includes('critical') ? chalk.red.bold
|
|
379
|
+
: rp.toLowerCase().includes('high') ? chalk.yellow.bold
|
|
380
|
+
: rp.toLowerCase().includes('medium') ? chalk.yellow
|
|
381
|
+
: chalk.green;
|
|
382
|
+
console.log(` ${chalk.white.bold('Risk Posture:')} ${color(rp)}`);
|
|
383
|
+
console.log();
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const sevColor = { critical: chalk.red.bold, high: chalk.yellow, medium: chalk.blue, low: chalk.gray, info: chalk.gray };
|
|
387
|
+
for (const f of allFindings) {
|
|
388
|
+
const col = sevColor[f.severity] || chalk.white;
|
|
389
|
+
console.log(` ${col(`[${f.severity.toUpperCase()}]`.padEnd(11))} ${chalk.white(f.title)}`);
|
|
390
|
+
if (f.location) console.log(` ${' '.repeat(11)} ${chalk.gray(f.location)}`);
|
|
391
|
+
if (f.remediation) console.log(` ${' '.repeat(11)} ${chalk.green('Fix:')} ${f.remediation.slice(0, 90)}`);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
if (allFindings.length === 0) {
|
|
395
|
+
console.log(chalk.green(' No findings — clean!'));
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
console.log();
|
|
399
|
+
if (synthesis.roadmap.immediate) {
|
|
400
|
+
console.log(chalk.red.bold(' ⚡ Immediate (24–48h):'));
|
|
401
|
+
console.log(chalk.gray(` ${synthesis.roadmap.immediate}`));
|
|
402
|
+
}
|
|
403
|
+
if (synthesis.roadmap.shortTerm) {
|
|
404
|
+
console.log(chalk.yellow.bold(' 📅 Short-term (1–2 weeks):'));
|
|
405
|
+
console.log(chalk.gray(` ${synthesis.roadmap.shortTerm}`));
|
|
406
|
+
}
|
|
407
|
+
if (synthesis.roadmap.longTerm) {
|
|
408
|
+
console.log(chalk.white.bold(' 🏗 Long-term (1–3 months):'));
|
|
409
|
+
console.log(chalk.gray(` ${synthesis.roadmap.longTerm}`));
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
console.log();
|
|
413
|
+
console.log(chalk.gray(' Generate HTML report: ') + chalk.cyan(`ship-safe team-report ${inputFile || '<file>'} --html report.html`));
|
|
414
|
+
console.log();
|
|
415
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Undo Command
|
|
3
|
+
* ============
|
|
4
|
+
*
|
|
5
|
+
* Reverts changes applied by `ship-safe agent`.
|
|
6
|
+
*
|
|
7
|
+
* Reads .ship-safe/fixes.jsonl, takes the most recent entry (or all entries
|
|
8
|
+
* with --all), and reverses each edit. Per-fix git commits made by the agent
|
|
9
|
+
* are preferred over manual reversal when available.
|
|
10
|
+
*
|
|
11
|
+
* USAGE:
|
|
12
|
+
* ship-safe undo Revert the last applied fix
|
|
13
|
+
* ship-safe undo --all Revert every fix in the log
|
|
14
|
+
* ship-safe undo --dry-run Show what would be reverted, but don't write
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import fs from 'fs';
|
|
18
|
+
import path from 'path';
|
|
19
|
+
import { execFileSync } from 'child_process';
|
|
20
|
+
import chalk from 'chalk';
|
|
21
|
+
import * as output from '../utils/output.js';
|
|
22
|
+
|
|
23
|
+
const FIX_LOG_PATH = '.ship-safe/fixes.jsonl';
|
|
24
|
+
|
|
25
|
+
export async function undoCommand(targetPath = '.', options = {}) {
|
|
26
|
+
const root = path.resolve(targetPath);
|
|
27
|
+
const logPath = path.join(root, FIX_LOG_PATH);
|
|
28
|
+
|
|
29
|
+
if (!fs.existsSync(logPath)) {
|
|
30
|
+
output.error(`No fix log found at ${FIX_LOG_PATH}`);
|
|
31
|
+
console.log(chalk.gray(' Run `ship-safe agent` first to apply fixes.'));
|
|
32
|
+
process.exit(1);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const entries = fs.readFileSync(logPath, 'utf8')
|
|
36
|
+
.split('\n')
|
|
37
|
+
.filter(Boolean)
|
|
38
|
+
.map(line => { try { return JSON.parse(line); } catch { return null; } })
|
|
39
|
+
.filter(Boolean);
|
|
40
|
+
|
|
41
|
+
if (entries.length === 0) {
|
|
42
|
+
output.error('Fix log is empty.');
|
|
43
|
+
process.exit(1);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const toUndo = options.all ? [...entries].reverse() : [entries[entries.length - 1]];
|
|
47
|
+
|
|
48
|
+
console.log();
|
|
49
|
+
output.header('Ship Safe — Undo');
|
|
50
|
+
console.log();
|
|
51
|
+
console.log(chalk.gray(` Reverting ${toUndo.length} fix(es) from ${FIX_LOG_PATH}`));
|
|
52
|
+
console.log();
|
|
53
|
+
|
|
54
|
+
let reverted = 0;
|
|
55
|
+
let failed = 0;
|
|
56
|
+
|
|
57
|
+
for (const entry of toUndo) {
|
|
58
|
+
const file = entry.file || entry.finding?.file || '(unknown)';
|
|
59
|
+
console.log(chalk.bold(` ${chalk.cyan(file)}`));
|
|
60
|
+
|
|
61
|
+
if (options.dryRun) {
|
|
62
|
+
console.log(chalk.gray(` Would reverse plan: ${entry.plan?.summary || 'no summary'}`));
|
|
63
|
+
reverted++;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
reverseEntry(root, entry);
|
|
69
|
+
console.log(chalk.green(' Reverted.'));
|
|
70
|
+
reverted++;
|
|
71
|
+
} catch (err) {
|
|
72
|
+
console.log(chalk.red(` Failed: ${err.message}`));
|
|
73
|
+
failed++;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Truncate the log
|
|
78
|
+
if (!options.dryRun && reverted > 0) {
|
|
79
|
+
const remaining = options.all ? [] : entries.slice(0, -1);
|
|
80
|
+
if (remaining.length === 0) {
|
|
81
|
+
fs.unlinkSync(logPath);
|
|
82
|
+
} else {
|
|
83
|
+
fs.writeFileSync(logPath, remaining.map(e => JSON.stringify(e)).join('\n') + '\n', 'utf8');
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
console.log();
|
|
88
|
+
console.log(chalk.green(` Reverted: ${reverted}`));
|
|
89
|
+
if (failed > 0) console.log(chalk.red(` Failed: ${failed}`));
|
|
90
|
+
console.log();
|
|
91
|
+
|
|
92
|
+
if (failed > 0) {
|
|
93
|
+
console.log(chalk.gray(' For failed entries, try `git checkout` or `git reset --hard` if you committed via --branch.'));
|
|
94
|
+
console.log();
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function reverseEntry(root, entry) {
|
|
99
|
+
const plan = entry.plan;
|
|
100
|
+
if (!plan || !Array.isArray(plan.files) || plan.files.length === 0) {
|
|
101
|
+
throw new Error('entry has no plan to reverse');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
for (const fileChange of plan.files) {
|
|
105
|
+
const abs = path.resolve(root, fileChange.path);
|
|
106
|
+
|
|
107
|
+
if (fileChange.create) {
|
|
108
|
+
// We created the file — delete it
|
|
109
|
+
if (fs.existsSync(abs)) fs.unlinkSync(abs);
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (fileChange.append !== undefined) {
|
|
114
|
+
if (!fs.existsSync(abs)) continue;
|
|
115
|
+
const current = fs.readFileSync(abs, 'utf8');
|
|
116
|
+
// Try to remove the appended text (it may be at the end)
|
|
117
|
+
const idx = current.lastIndexOf(fileChange.append);
|
|
118
|
+
if (idx === -1) {
|
|
119
|
+
throw new Error(`appended text not found in ${fileChange.path}`);
|
|
120
|
+
}
|
|
121
|
+
const reverted = current.slice(0, idx) + current.slice(idx + fileChange.append.length);
|
|
122
|
+
fs.writeFileSync(abs, reverted, 'utf8');
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Standard edits — reverse find/replace
|
|
127
|
+
if (!fs.existsSync(abs)) {
|
|
128
|
+
throw new Error(`file no longer exists: ${fileChange.path}`);
|
|
129
|
+
}
|
|
130
|
+
let content = fs.readFileSync(abs, 'utf8');
|
|
131
|
+
// Reverse in opposite order in case edits are positionally dependent
|
|
132
|
+
const reversed = [...fileChange.edits].reverse();
|
|
133
|
+
for (const e of reversed) {
|
|
134
|
+
const newStr = e.replace;
|
|
135
|
+
const oldStr = e._resolvedFind || e.find;
|
|
136
|
+
if (!content.includes(newStr)) {
|
|
137
|
+
throw new Error(`reverted text not found in ${fileChange.path} (file changed since fix)`);
|
|
138
|
+
}
|
|
139
|
+
content = content.replace(newStr, oldStr);
|
|
140
|
+
}
|
|
141
|
+
fs.writeFileSync(abs, content, 'utf8');
|
|
142
|
+
}
|
|
143
|
+
}
|