ship-safe 5.0.1 → 6.1.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 (43) hide show
  1. package/README.md +110 -23
  2. package/cli/agents/abom-generator.js +225 -0
  3. package/cli/agents/agent-config-scanner.js +547 -0
  4. package/cli/agents/agentic-security-agent.js +1 -1
  5. package/cli/agents/api-fuzzer.js +1 -1
  6. package/cli/agents/auth-bypass-agent.js +2 -2
  7. package/cli/agents/config-auditor.js +3 -11
  8. package/cli/agents/exception-handler-agent.js +187 -0
  9. package/cli/agents/html-reporter.js +532 -370
  10. package/cli/agents/index.js +11 -1
  11. package/cli/agents/mcp-security-agent.js +182 -0
  12. package/cli/agents/pii-compliance-agent.js +4 -4
  13. package/cli/agents/scoring-engine.js +25 -6
  14. package/cli/agents/vibe-coding-agent.js +250 -0
  15. package/cli/bin/ship-safe.js +96 -6
  16. package/cli/commands/abom.js +73 -0
  17. package/cli/commands/agent.js +4 -4
  18. package/cli/commands/audit.js +15 -7
  19. package/cli/commands/baseline.js +1 -1
  20. package/cli/commands/benchmark.js +327 -0
  21. package/cli/commands/ci.js +81 -1
  22. package/cli/commands/deps.js +73 -4
  23. package/cli/commands/diff.js +200 -0
  24. package/cli/commands/doctor.js +14 -4
  25. package/cli/commands/fix.js +1 -1
  26. package/cli/commands/guard.js +99 -0
  27. package/cli/commands/init.js +407 -349
  28. package/cli/commands/openclaw.js +378 -0
  29. package/cli/commands/red-team.js +2 -2
  30. package/cli/commands/remediate.js +153 -7
  31. package/cli/commands/scan-skill.js +329 -0
  32. package/cli/commands/update-intel.js +55 -0
  33. package/cli/commands/vibe-check.js +276 -0
  34. package/cli/commands/watch.js +124 -4
  35. package/cli/data/threat-intel.json +85 -0
  36. package/cli/index.js +9 -0
  37. package/cli/utils/cache-manager.js +1 -1
  38. package/cli/utils/compliance-map.js +125 -0
  39. package/cli/utils/output.js +5 -2
  40. package/cli/utils/patterns.js +3 -0
  41. package/cli/utils/pdf-generator.js +1 -1
  42. package/cli/utils/threat-intel.js +167 -0
  43. package/package.json +2 -2
@@ -1,370 +1,532 @@
1
- /**
2
- * HTML Report Generator
3
- * ======================
4
- *
5
- * Generates a standalone HTML security report.
6
- * No external dependencies — everything inline.
7
- */
8
-
9
- import fs from 'fs';
10
- import path from 'path';
11
-
12
- export class HTMLReporter {
13
- /**
14
- * Generate an HTML report from scan results.
15
- *
16
- * @param {object} scoreResult — From ScoringEngine.compute()
17
- * @param {object[]} findings — Array of finding objects
18
- * @param {object} recon — ReconAgent output
19
- * @param {string} rootPath — Project root
20
- * @returns {string} — HTML string
21
- */
22
- generate(scoreResult, findings, recon, rootPath) {
23
- const projectName = path.basename(rootPath);
24
- const date = new Date().toLocaleDateString('en-US', {
25
- year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit',
26
- });
27
-
28
- const gradeColors = { A: '#22c55e', B: '#06b6d4', C: '#eab308', D: '#ef4444', F: '#dc2626' };
29
- const sevColors = { critical: '#dc2626', high: '#f97316', medium: '#eab308', low: '#3b82f6' };
30
-
31
- const bySeverity = { critical: 0, high: 0, medium: 0, low: 0 };
32
- for (const f of findings) bySeverity[f.severity] = (bySeverity[f.severity] || 0) + 1;
33
-
34
- const categoryRows = Object.entries(scoreResult.categories)
35
- .map(([key, cat]) => {
36
- const count = Object.values(cat.counts).reduce((a, b) => a + b, 0);
37
- return `<tr>
38
- <td>${cat.label}</td>
39
- <td>${count}</td>
40
- <td style="color:${cat.deduction > 0 ? '#ef4444' : '#22c55e'}">${cat.deduction > 0 ? '-' + cat.deduction : '0'}</td>
41
- </tr>`;
42
- }).join('\n');
43
-
44
- const findingRows = findings.slice(0, 200).map(f => {
45
- const relFile = path.relative(rootPath, f.file).replace(/\\/g, '/');
46
- return `<tr>
47
- <td><span class="sev sev-${f.severity}">${f.severity.toUpperCase()}</span></td>
48
- <td><code>${relFile}:${f.line}</code></td>
49
- <td><strong>${f.title || f.rule}</strong><br><small>${f.description?.slice(0, 120) || ''}</small></td>
50
- <td><code>${(f.matched || '').slice(0, 60)}</code></td>
51
- <td>${f.fix ? `<small>${f.fix.slice(0, 100)}</small>` : ''}</td>
52
- </tr>`;
53
- }).join('\n');
54
-
55
- return `<!DOCTYPE html>
56
- <html lang="en">
57
- <head>
58
- <meta charset="utf-8">
59
- <meta name="viewport" content="width=device-width,initial-scale=1">
60
- <title>Ship Safe Security Report — ${projectName}</title>
61
- <style>
62
- *{margin:0;padding:0;box-sizing:border-box}
63
- body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#0f172a;color:#e2e8f0;padding:2rem}
64
- .container{max-width:1200px;margin:0 auto}
65
- h1{font-size:2rem;margin-bottom:0.5rem;color:#38bdf8}
66
- h2{font-size:1.3rem;margin:2rem 0 1rem;color:#94a3b8;border-bottom:1px solid #1e293b;padding-bottom:0.5rem}
67
- .meta{color:#64748b;margin-bottom:2rem}
68
- .score-card{display:flex;align-items:center;gap:2rem;background:#1e293b;padding:2rem;border-radius:12px;margin-bottom:2rem}
69
- .score-number{font-size:4rem;font-weight:bold}
70
- .grade{font-size:3rem;font-weight:bold;width:80px;height:80px;display:flex;align-items:center;justify-content:center;border-radius:12px}
71
- .stats{display:grid;grid-template-columns:repeat(4,1fr);gap:1rem;margin-bottom:2rem}
72
- .stat{background:#1e293b;padding:1.5rem;border-radius:8px;text-align:center}
73
- .stat-number{font-size:2rem;font-weight:bold}
74
- .stat-label{color:#64748b;font-size:0.85rem}
75
- table{width:100%;border-collapse:collapse;background:#1e293b;border-radius:8px;overflow:hidden;margin-bottom:2rem}
76
- th{background:#334155;text-align:left;padding:0.75rem 1rem;font-size:0.8rem;text-transform:uppercase;color:#94a3b8}
77
- td{padding:0.75rem 1rem;border-top:1px solid #1e293b;font-size:0.85rem;vertical-align:top}
78
- tr:hover{background:#334155}
79
- code{background:#0f172a;padding:2px 6px;border-radius:4px;font-size:0.8rem;color:#38bdf8}
80
- small{color:#64748b}
81
- .sev{padding:2px 8px;border-radius:4px;font-size:0.7rem;font-weight:bold;text-transform:uppercase}
82
- .sev-critical{background:#dc262633;color:#fca5a5}
83
- .sev-high{background:#f9731633;color:#fdba74}
84
- .sev-medium{background:#eab30833;color:#fde047}
85
- .sev-low{background:#3b82f633;color:#93c5fd}
86
- .footer{text-align:center;color:#475569;margin-top:3rem;padding:2rem;border-top:1px solid #1e293b}
87
- </style>
88
- </head>
89
- <body>
90
- <div class="container">
91
- <h1>Ship Safe Security Report</h1>
92
- <p class="meta">${projectName} — ${date}</p>
93
-
94
- <div class="score-card">
95
- <div class="grade" style="background:${gradeColors[scoreResult.grade.letter]}22;color:${gradeColors[scoreResult.grade.letter]}">${scoreResult.grade.letter}</div>
96
- <div>
97
- <div class="score-number" style="color:${gradeColors[scoreResult.grade.letter]}">${scoreResult.score}/100</div>
98
- <div style="color:#94a3b8">${scoreResult.grade.label}</div>
99
- </div>
100
- </div>
101
-
102
- <div class="stats">
103
- <div class="stat"><div class="stat-number" style="color:${sevColors.critical}">${bySeverity.critical}</div><div class="stat-label">Critical</div></div>
104
- <div class="stat"><div class="stat-number" style="color:${sevColors.high}">${bySeverity.high}</div><div class="stat-label">High</div></div>
105
- <div class="stat"><div class="stat-number" style="color:${sevColors.medium}">${bySeverity.medium}</div><div class="stat-label">Medium</div></div>
106
- <div class="stat"><div class="stat-number" style="color:${sevColors.low}">${bySeverity.low}</div><div class="stat-label">Low</div></div>
107
- </div>
108
-
109
- <h2>Category Breakdown</h2>
110
- <table>
111
- <thead><tr><th>Category</th><th>Findings</th><th>Deduction</th></tr></thead>
112
- <tbody>${categoryRows}</tbody>
113
- </table>
114
-
115
- <h2>Findings (${findings.length})</h2>
116
- <table>
117
- <thead><tr><th>Severity</th><th>Location</th><th>Issue</th><th>Code</th><th>Fix</th></tr></thead>
118
- <tbody>${findingRows || '<tr><td colspan="5" style="text-align:center;color:#22c55e">No findings — clean!</td></tr>'}</tbody>
119
- </table>
120
-
121
- ${recon ? `<h2>Attack Surface</h2>
122
- <table>
123
- <tbody>
124
- <tr><td>Frameworks</td><td>${(recon.frameworks || []).join(', ') || 'None detected'}</td></tr>
125
- <tr><td>Languages</td><td>${(recon.languages || []).join(', ') || 'None detected'}</td></tr>
126
- <tr><td>Databases</td><td>${(recon.databases || []).join(', ') || 'None detected'}</td></tr>
127
- <tr><td>Cloud Providers</td><td>${(recon.cloudProviders || []).join(', ') || 'None detected'}</td></tr>
128
- <tr><td>Auth Patterns</td><td>${(recon.authPatterns || []).join(', ') || 'None detected'}</td></tr>
129
- <tr><td>CI/CD</td><td>${(recon.cicd || []).map(c => c.platform).join(', ') || 'None detected'}</td></tr>
130
- <tr><td>API Routes</td><td>${(recon.apiRoutes || []).length} discovered</td></tr>
131
- </tbody>
132
- </table>` : ''}
133
-
134
- <div class="footer">
135
- Generated by <strong>Ship Safe v4.3</strong> — Security toolkit for developers<br>
136
- <a href="https://github.com/asamassekou10/ship-safe" style="color:#38bdf8">github.com/asamassekou10/ship-safe</a>
137
- </div>
138
- </div>
139
- </body>
140
- </html>`;
141
- }
142
-
143
- /**
144
- * Generate and write HTML report to file.
145
- */
146
- generateToFile(scoreResult, findings, recon, rootPath, outputPath) {
147
- const html = this.generate(scoreResult, findings, recon, rootPath);
148
- fs.writeFileSync(outputPath, html);
149
- return outputPath;
150
- }
151
-
152
- /**
153
- * Generate a full audit report including deps and remediation plan.
154
- */
155
- generateFullReport(scoreResult, findings, depVulns, recon, remediationPlan, rootPath, outputPath) {
156
- const projectName = path.basename(rootPath);
157
- const date = new Date().toLocaleDateString('en-US', {
158
- year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit',
159
- });
160
-
161
- const gradeColors = { A: '#22c55e', B: '#06b6d4', C: '#eab308', D: '#ef4444', F: '#dc2626' };
162
- const sevColors = { critical: '#dc2626', high: '#f97316', medium: '#eab308', low: '#3b82f6' };
163
-
164
- const bySeverity = { critical: 0, high: 0, medium: 0, low: 0 };
165
- for (const f of findings) bySeverity[f.severity] = (bySeverity[f.severity] || 0) + 1;
166
-
167
- const categoryRows = Object.entries(scoreResult.categories)
168
- .map(([key, cat]) => {
169
- const count = Object.values(cat.counts).reduce((a, b) => a + b, 0);
170
- return `<tr>
171
- <td>${cat.label}</td>
172
- <td>${count}</td>
173
- <td style="color:${cat.deduction > 0 ? '#ef4444' : '#22c55e'}">${cat.deduction > 0 ? '-' + cat.deduction : '0'}</td>
174
- </tr>`;
175
- }).join('\n');
176
-
177
- const findingRows = findings.slice(0, 200).map(f => {
178
- const relFile = path.relative(rootPath, f.file).replace(/\\/g, '/');
179
- let codeBlock = '';
180
- if (f.codeContext && f.codeContext.length > 0) {
181
- const codeLines = f.codeContext.map(c =>
182
- `<span style="${c.highlight ? 'background:#dc262633;display:block;' : ''}">${String(c.line).padStart(4)} ${this.esc(c.text)}</span>`
183
- ).join('');
184
- codeBlock = `<pre style="background:#0f172a;padding:0.5rem;border-radius:4px;font-size:0.75rem;margin-top:0.5rem;overflow-x:auto;line-height:1.4"><code>${codeLines}</code></pre>`;
185
- }
186
- return `<tr>
187
- <td><span class="sev sev-${f.severity}">${f.severity.toUpperCase()}</span></td>
188
- <td><code>${relFile}:${f.line}</code></td>
189
- <td><strong>${this.esc(f.title || f.rule)}</strong><br><small>${this.esc((f.description || '').slice(0, 120))}</small>${codeBlock}</td>
190
- <td><code>${this.esc((f.matched || '').slice(0, 60))}</code></td>
191
- <td>${f.fix ? `<small>${this.esc(f.fix.slice(0, 100))}</small>` : ''}</td>
192
- </tr>`;
193
- }).join('\n');
194
-
195
- // Dep vuln rows
196
- const depRows = (depVulns || []).slice(0, 100).map(d => {
197
- const sev = d.severity === 'moderate' ? 'medium' : d.severity;
198
- return `<tr>
199
- <td><span class="sev sev-${sev}">${(d.severity || 'unknown').toUpperCase()}</span></td>
200
- <td><code>${this.esc(d.package || d.id || 'unknown')}</code></td>
201
- <td>${this.esc((d.description || '').slice(0, 150))}</td>
202
- </tr>`;
203
- }).join('\n');
204
-
205
- // Remediation plan rows
206
- const sevIcons = { critical: '&#x1F534;', high: '&#x1F7E0;', medium: '&#x1F7E1;', low: '&#x1F535;' };
207
- let currentSev = null;
208
- let planHTML = '';
209
- for (const item of (remediationPlan || []).slice(0, 100)) {
210
- if (item.severity !== currentSev) {
211
- currentSev = item.severity;
212
- const label = { critical: 'CRITICAL — fix immediately', high: 'HIGH — fix before deploy', medium: 'MEDIUM — fix soon', low: 'LOW — review when possible' };
213
- planHTML += `<tr class="sev-header"><td colspan="5" style="background:#1e293b;padding:1rem;font-weight:bold;color:${sevColors[currentSev] || '#94a3b8'}">${sevIcons[currentSev] || ''} ${label[currentSev] || currentSev.toUpperCase()}</td></tr>\n`;
214
- }
215
- planHTML += `<tr>
216
- <td>${item.priority}</td>
217
- <td><span class="sev sev-${item.severity}">${item.categoryLabel}</span></td>
218
- <td><strong>${this.esc(item.title)}</strong></td>
219
- <td><code>${this.esc(item.file)}</code></td>
220
- <td><small>${this.esc((item.action || '').slice(0, 120))}</small></td>
221
- </tr>\n`;
222
- }
223
-
224
- const html = `<!DOCTYPE html>
225
- <html lang="en">
226
- <head>
227
- <meta charset="utf-8">
228
- <meta name="viewport" content="width=device-width,initial-scale=1">
229
- <title>Ship Safe Full Audit Report — ${this.esc(projectName)}</title>
230
- <style>
231
- *{margin:0;padding:0;box-sizing:border-box}
232
- body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#0f172a;color:#e2e8f0;padding:2rem}
233
- .container{max-width:1200px;margin:0 auto}
234
- h1{font-size:2rem;margin-bottom:0.5rem;color:#38bdf8}
235
- h2{font-size:1.3rem;margin:2rem 0 1rem;color:#94a3b8;border-bottom:1px solid #1e293b;padding-bottom:0.5rem}
236
- .meta{color:#64748b;margin-bottom:2rem}
237
- .score-card{display:flex;align-items:center;gap:2rem;background:#1e293b;padding:2rem;border-radius:12px;margin-bottom:2rem}
238
- .score-number{font-size:4rem;font-weight:bold}
239
- .grade{font-size:3rem;font-weight:bold;width:80px;height:80px;display:flex;align-items:center;justify-content:center;border-radius:12px}
240
- .stats{display:grid;grid-template-columns:repeat(4,1fr);gap:1rem;margin-bottom:2rem}
241
- .stat{background:#1e293b;padding:1.5rem;border-radius:8px;text-align:center}
242
- .stat-number{font-size:2rem;font-weight:bold}
243
- .stat-label{color:#64748b;font-size:0.85rem}
244
- .summary-grid{display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin-bottom:2rem}
245
- .summary-card{background:#1e293b;padding:1.5rem;border-radius:8px}
246
- .summary-card h3{color:#38bdf8;font-size:1rem;margin-bottom:0.5rem}
247
- .summary-card .big{font-size:2.5rem;font-weight:bold}
248
- table{width:100%;border-collapse:collapse;background:#1e293b;border-radius:8px;overflow:hidden;margin-bottom:2rem}
249
- th{background:#334155;text-align:left;padding:0.75rem 1rem;font-size:0.8rem;text-transform:uppercase;color:#94a3b8}
250
- td{padding:0.75rem 1rem;border-top:1px solid #0f172a;font-size:0.85rem;vertical-align:top}
251
- tr:hover{background:#334155}
252
- code{background:#0f172a;padding:2px 6px;border-radius:4px;font-size:0.8rem;color:#38bdf8}
253
- small{color:#94a3b8}
254
- .sev{padding:2px 8px;border-radius:4px;font-size:0.7rem;font-weight:bold;text-transform:uppercase}
255
- .sev-critical{background:#dc262633;color:#fca5a5}
256
- .sev-high{background:#f9731633;color:#fdba74}
257
- .sev-medium,.sev-moderate{background:#eab30833;color:#fde047}
258
- .sev-low{background:#3b82f633;color:#93c5fd}
259
- .toc{background:#1e293b;padding:1.5rem 2rem;border-radius:8px;margin-bottom:2rem}
260
- .toc a{color:#38bdf8;text-decoration:none;display:block;padding:0.3rem 0}
261
- .toc a:hover{text-decoration:underline}
262
- .footer{text-align:center;color:#475569;margin-top:3rem;padding:2rem;border-top:1px solid #1e293b}
263
- .footer a{color:#38bdf8}
264
- @media print{body{background:#fff;color:#1e293b}table,th,td{border:1px solid #e2e8f0}.score-card,.stat,.summary-card,.toc{background:#f8fafc}}
265
- </style>
266
- </head>
267
- <body>
268
- <div class="container">
269
- <h1>Ship Safe — Full Security Audit Report</h1>
270
- <p class="meta">${this.esc(projectName)} ${date}</p>
271
-
272
- <div class="toc">
273
- <strong>Contents</strong>
274
- <a href="#score">1. Security Score</a>
275
- <a href="#summary">2. Executive Summary</a>
276
- <a href="#categories">3. Category Breakdown</a>
277
- <a href="#plan">4. Remediation Plan (${(remediationPlan || []).length} items)</a>
278
- <a href="#findings">5. All Findings (${findings.length})</a>
279
- <a href="#deps">6. Dependency Vulnerabilities (${(depVulns || []).length})</a>
280
- <a href="#surface">7. Attack Surface</a>
281
- </div>
282
-
283
- <h2 id="score">1. Security Score</h2>
284
- <div class="score-card">
285
- <div class="grade" style="background:${gradeColors[scoreResult.grade.letter]}22;color:${gradeColors[scoreResult.grade.letter]}">${scoreResult.grade.letter}</div>
286
- <div>
287
- <div class="score-number" style="color:${gradeColors[scoreResult.grade.letter]}">${scoreResult.score}/100</div>
288
- <div style="color:#94a3b8">${scoreResult.grade.label}</div>
289
- </div>
290
- </div>
291
-
292
- <div class="stats">
293
- <div class="stat"><div class="stat-number" style="color:${sevColors.critical}">${bySeverity.critical}</div><div class="stat-label">Critical</div></div>
294
- <div class="stat"><div class="stat-number" style="color:${sevColors.high}">${bySeverity.high}</div><div class="stat-label">High</div></div>
295
- <div class="stat"><div class="stat-number" style="color:${sevColors.medium}">${bySeverity.medium}</div><div class="stat-label">Medium</div></div>
296
- <div class="stat"><div class="stat-number" style="color:${sevColors.low}">${bySeverity.low}</div><div class="stat-label">Low</div></div>
297
- </div>
298
-
299
- <h2 id="summary">2. Executive Summary</h2>
300
- <div class="summary-grid">
301
- <div class="summary-card">
302
- <h3>Code Findings</h3>
303
- <div class="big" style="color:${findings.length > 0 ? '#ef4444' : '#22c55e'}">${findings.length}</div>
304
- <small>Across ${Object.keys(scoreResult.categories).length} categories</small>
305
- </div>
306
- <div class="summary-card">
307
- <h3>Dependency CVEs</h3>
308
- <div class="big" style="color:${(depVulns || []).length > 0 ? '#ef4444' : '#22c55e'}">${(depVulns || []).length}</div>
309
- <small>From npm/pip/bundler audit</small>
310
- </div>
311
- </div>
312
-
313
- <h2 id="categories">3. Category Breakdown</h2>
314
- <table>
315
- <thead><tr><th>Category</th><th>Findings</th><th>Score Deduction</th></tr></thead>
316
- <tbody>${categoryRows}</tbody>
317
- </table>
318
-
319
- <h2 id="plan">4. Remediation Plan</h2>
320
- <p style="color:#94a3b8;margin-bottom:1rem">Prioritized list of fixes. Address critical items first.</p>
321
- ${(remediationPlan || []).length > 0 ? `<table>
322
- <thead><tr><th>#</th><th>Category</th><th>Issue</th><th>Location</th><th>Fix</th></tr></thead>
323
- <tbody>${planHTML}</tbody>
324
- </table>` : '<p style="color:#22c55e;font-weight:bold">No issues found — all clear!</p>'}
325
-
326
- <h2 id="findings">5. All Findings (${findings.length})</h2>
327
- <table>
328
- <thead><tr><th>Severity</th><th>Location</th><th>Issue</th><th>Code</th><th>Fix</th></tr></thead>
329
- <tbody>${findingRows || '<tr><td colspan="5" style="text-align:center;color:#22c55e">No findings — clean!</td></tr>'}</tbody>
330
- </table>
331
-
332
- <h2 id="deps">6. Dependency Vulnerabilities (${(depVulns || []).length})</h2>
333
- ${(depVulns || []).length > 0 ? `<table>
334
- <thead><tr><th>Severity</th><th>Package</th><th>Description</th></tr></thead>
335
- <tbody>${depRows}</tbody>
336
- </table>` : '<p style="color:#22c55e;font-weight:bold">No vulnerable dependencies found.</p>'}
337
-
338
- ${recon ? `<h2 id="surface">7. Attack Surface</h2>
339
- <table>
340
- <tbody>
341
- <tr><td>Frameworks</td><td>${(recon.frameworks || []).join(', ') || 'None detected'}</td></tr>
342
- <tr><td>Languages</td><td>${(recon.languages || []).join(', ') || 'None detected'}</td></tr>
343
- <tr><td>Databases</td><td>${(recon.databases || []).join(', ') || 'None detected'}</td></tr>
344
- <tr><td>Cloud Providers</td><td>${(recon.cloudProviders || []).join(', ') || 'None detected'}</td></tr>
345
- <tr><td>Auth Patterns</td><td>${(recon.authPatterns || []).join(', ') || 'None detected'}</td></tr>
346
- <tr><td>CI/CD</td><td>${(recon.cicd || []).map(c => c.platform).join(', ') || 'None detected'}</td></tr>
347
- <tr><td>API Routes</td><td>${(recon.apiRoutes || []).length} discovered</td></tr>
348
- </tbody>
349
- </table>` : ''}
350
-
351
- <div class="footer">
352
- Generated by <strong>Ship Safe v4.3</strong> — Full Security Audit<br>
353
- <a href="https://github.com/asamassekou10/ship-safe">github.com/asamassekou10/ship-safe</a>
354
- </div>
355
- </div>
356
- </body>
357
- </html>`;
358
-
359
- fs.writeFileSync(outputPath, html);
360
- return outputPath;
361
- }
362
-
363
- /** Escape HTML entities */
364
- esc(str) {
365
- if (!str) return '';
366
- return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
367
- }
368
- }
369
-
370
- export default HTMLReporter;
1
+ /**
2
+ * HTML Report Generator
3
+ * ======================
4
+ *
5
+ * Generates a standalone interactive HTML security report.
6
+ * No external dependencies — everything inline.
7
+ *
8
+ * Features:
9
+ * - Severity filter toolbar (toggle critical/high/medium/low)
10
+ * - Category bar chart (deductions visualization)
11
+ * - Collapsible finding rows with code context
12
+ * - Click-to-copy ship-safe-ignore annotations
13
+ * - Text search across findings
14
+ * - Print-friendly styles
15
+ */
16
+
17
+ import fs from 'fs';
18
+ import path from 'path';
19
+ import { getComplianceSummary } from '../utils/compliance-map.js';
20
+
21
+ export class HTMLReporter {
22
+ /**
23
+ * Generate an HTML report from scan results.
24
+ */
25
+ generate(scoreResult, findings, recon, rootPath) {
26
+ const projectName = path.basename(rootPath);
27
+ const date = new Date().toLocaleDateString('en-US', {
28
+ year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit',
29
+ });
30
+
31
+ const gradeColors = { A: '#22c55e', B: '#06b6d4', C: '#eab308', D: '#ef4444', F: '#dc2626' };
32
+ const sevColors = { critical: '#dc2626', high: '#f97316', medium: '#eab308', low: '#3b82f6' };
33
+
34
+ const bySeverity = { critical: 0, high: 0, medium: 0, low: 0 };
35
+ for (const f of findings) bySeverity[f.severity] = (bySeverity[f.severity] || 0) + 1;
36
+
37
+ const categoryRows = Object.entries(scoreResult.categories)
38
+ .map(([key, cat]) => {
39
+ const count = Object.values(cat.counts).reduce((a, b) => a + b, 0);
40
+ return `<tr>
41
+ <td>${cat.label}</td>
42
+ <td>${count}</td>
43
+ <td style="color:${cat.deduction > 0 ? '#ef4444' : '#22c55e'}">${cat.deduction > 0 ? '-' + cat.deduction : '0'}</td>
44
+ </tr>`;
45
+ }).join('\n');
46
+
47
+ const findingRows = findings.slice(0, 200).map(f => {
48
+ const relFile = path.relative(rootPath, f.file).replace(/\\/g, '/');
49
+ return `<tr>
50
+ <td><span class="sev sev-${f.severity}">${f.severity.toUpperCase()}</span></td>
51
+ <td><code>${relFile}:${f.line}</code></td>
52
+ <td><strong>${f.title || f.rule}</strong><br><small>${f.description?.slice(0, 120) || ''}</small></td>
53
+ <td><code>${(f.matched || '').slice(0, 60)}</code></td>
54
+ <td>${f.fix ? `<small>${f.fix.slice(0, 100)}</small>` : ''}</td>
55
+ </tr>`;
56
+ }).join('\n');
57
+
58
+ return `<!DOCTYPE html>
59
+ <html lang="en">
60
+ <head>
61
+ <meta charset="utf-8">
62
+ <meta name="viewport" content="width=device-width,initial-scale=1">
63
+ <title>Ship Safe Security Report — ${projectName}</title>
64
+ <style>
65
+ *{margin:0;padding:0;box-sizing:border-box}
66
+ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#0f172a;color:#e2e8f0;padding:2rem}
67
+ .container{max-width:1200px;margin:0 auto}
68
+ h1{font-size:2rem;margin-bottom:0.5rem;color:#38bdf8}
69
+ h2{font-size:1.3rem;margin:2rem 0 1rem;color:#94a3b8;border-bottom:1px solid #1e293b;padding-bottom:0.5rem}
70
+ .meta{color:#64748b;margin-bottom:2rem}
71
+ .score-card{display:flex;align-items:center;gap:2rem;background:#1e293b;padding:2rem;border-radius:12px;margin-bottom:2rem}
72
+ .score-number{font-size:4rem;font-weight:bold}
73
+ .grade{font-size:3rem;font-weight:bold;width:80px;height:80px;display:flex;align-items:center;justify-content:center;border-radius:12px}
74
+ .stats{display:grid;grid-template-columns:repeat(4,1fr);gap:1rem;margin-bottom:2rem}
75
+ .stat{background:#1e293b;padding:1.5rem;border-radius:8px;text-align:center}
76
+ .stat-number{font-size:2rem;font-weight:bold}
77
+ .stat-label{color:#64748b;font-size:0.85rem}
78
+ table{width:100%;border-collapse:collapse;background:#1e293b;border-radius:8px;overflow:hidden;margin-bottom:2rem}
79
+ th{background:#334155;text-align:left;padding:0.75rem 1rem;font-size:0.8rem;text-transform:uppercase;color:#94a3b8}
80
+ td{padding:0.75rem 1rem;border-top:1px solid #1e293b;font-size:0.85rem;vertical-align:top}
81
+ tr:hover{background:#334155}
82
+ code{background:#0f172a;padding:2px 6px;border-radius:4px;font-size:0.8rem;color:#38bdf8}
83
+ small{color:#64748b}
84
+ .sev{padding:2px 8px;border-radius:4px;font-size:0.7rem;font-weight:bold;text-transform:uppercase}
85
+ .sev-critical{background:#dc262633;color:#fca5a5}
86
+ .sev-high{background:#f9731633;color:#fdba74}
87
+ .sev-medium{background:#eab30833;color:#fde047}
88
+ .sev-low{background:#3b82f633;color:#93c5fd}
89
+ .footer{text-align:center;color:#475569;margin-top:3rem;padding:2rem;border-top:1px solid #1e293b}
90
+ </style>
91
+ </head>
92
+ <body>
93
+ <div class="container">
94
+ <h1>Ship Safe Security Report</h1>
95
+ <p class="meta">${projectName} — ${date}</p>
96
+
97
+ <div class="score-card">
98
+ <div class="grade" style="background:${gradeColors[scoreResult.grade.letter]}22;color:${gradeColors[scoreResult.grade.letter]}">${scoreResult.grade.letter}</div>
99
+ <div>
100
+ <div class="score-number" style="color:${gradeColors[scoreResult.grade.letter]}">${scoreResult.score}/100</div>
101
+ <div style="color:#94a3b8">${scoreResult.grade.label}</div>
102
+ </div>
103
+ </div>
104
+
105
+ <div class="stats">
106
+ <div class="stat"><div class="stat-number" style="color:${sevColors.critical}">${bySeverity.critical}</div><div class="stat-label">Critical</div></div>
107
+ <div class="stat"><div class="stat-number" style="color:${sevColors.high}">${bySeverity.high}</div><div class="stat-label">High</div></div>
108
+ <div class="stat"><div class="stat-number" style="color:${sevColors.medium}">${bySeverity.medium}</div><div class="stat-label">Medium</div></div>
109
+ <div class="stat"><div class="stat-number" style="color:${sevColors.low}">${bySeverity.low}</div><div class="stat-label">Low</div></div>
110
+ </div>
111
+
112
+ <h2>Category Breakdown</h2>
113
+ <table>
114
+ <thead><tr><th>Category</th><th>Findings</th><th>Deduction</th></tr></thead>
115
+ <tbody>${categoryRows}</tbody>
116
+ </table>
117
+
118
+ <h2>Findings (${findings.length})</h2>
119
+ <table>
120
+ <thead><tr><th>Severity</th><th>Location</th><th>Issue</th><th>Code</th><th>Fix</th></tr></thead>
121
+ <tbody>${findingRows || '<tr><td colspan="5" style="text-align:center;color:#22c55e">No findings — clean!</td></tr>'}</tbody>
122
+ </table>
123
+
124
+ <h2>Compliance Mapping</h2>
125
+ ${(() => {
126
+ const compliance = getComplianceSummary(findings);
127
+ const s = compliance.summary;
128
+ return `<div class="stats">
129
+ <div class="stat"><div class="stat-number" style="color:#38bdf8">${s.soc2Controls}</div><div class="stat-label">SOC 2 Controls</div></div>
130
+ <div class="stat"><div class="stat-number" style="color:#38bdf8">${s.iso27001Controls}</div><div class="stat-label">ISO 27001 Controls</div></div>
131
+ <div class="stat"><div class="stat-number" style="color:#38bdf8">${s.nistAiRmfControls}</div><div class="stat-label">NIST AI RMF Controls</div></div>
132
+ <div class="stat"><div class="stat-number" style="color:#94a3b8">${s.totalFindings}</div><div class="stat-label">Mapped Findings</div></div>
133
+ </div>
134
+ <table>
135
+ <thead><tr><th>Framework</th><th>Controls Impacted</th><th>Details</th></tr></thead>
136
+ <tbody>
137
+ <tr><td>SOC 2 Type II</td><td>${s.soc2Controls}</td><td>${Object.entries(compliance.soc2).map(([k,v]) => k + ' (' + v + ')').join(', ') || 'None'}</td></tr>
138
+ <tr><td>ISO 27001:2022</td><td>${s.iso27001Controls}</td><td>${Object.entries(compliance.iso27001).map(([k,v]) => k + ' (' + v + ')').join(', ') || 'None'}</td></tr>
139
+ <tr><td>NIST AI RMF</td><td>${s.nistAiRmfControls}</td><td>${Object.entries(compliance.nistAiRmf).map(([k,v]) => k + ' (' + v + ')').join(', ') || 'None'}</td></tr>
140
+ </tbody>
141
+ </table>`;
142
+ })()}
143
+
144
+ ${recon ? `<h2>Attack Surface</h2>
145
+ <table>
146
+ <tbody>
147
+ <tr><td>Frameworks</td><td>${(recon.frameworks || []).join(', ') || 'None detected'}</td></tr>
148
+ <tr><td>Languages</td><td>${(recon.languages || []).join(', ') || 'None detected'}</td></tr>
149
+ <tr><td>Databases</td><td>${(recon.databases || []).join(', ') || 'None detected'}</td></tr>
150
+ <tr><td>Cloud Providers</td><td>${(recon.cloudProviders || []).join(', ') || 'None detected'}</td></tr>
151
+ <tr><td>Auth Patterns</td><td>${(recon.authPatterns || []).join(', ') || 'None detected'}</td></tr>
152
+ <tr><td>CI/CD</td><td>${(recon.cicd || []).map(c => c.platform).join(', ') || 'None detected'}</td></tr>
153
+ <tr><td>API Routes</td><td>${(recon.apiRoutes || []).length} discovered</td></tr>
154
+ </tbody>
155
+ </table>` : ''}
156
+
157
+ <div class="footer">
158
+ Generated by <strong>Ship Safe v6.0</strong> Security toolkit for developers<br>
159
+ <a href="https://shipsafecli.com" style="color:#38bdf8">shipsafecli.com</a>
160
+ </div>
161
+ </div>
162
+ </body>
163
+ </html>`;
164
+ }
165
+
166
+ /**
167
+ * Generate and write HTML report to file.
168
+ */
169
+ generateToFile(scoreResult, findings, recon, rootPath, outputPath) {
170
+ const html = this.generate(scoreResult, findings, recon, rootPath);
171
+ fs.writeFileSync(outputPath, html);
172
+ return outputPath;
173
+ }
174
+
175
+ /**
176
+ * Generate a full interactive audit report including deps and remediation plan.
177
+ *
178
+ * Interactive features:
179
+ * - Severity filter toolbar
180
+ * - Category deduction bar chart
181
+ * - Collapsible finding rows with code context
182
+ * - Click-to-copy ship-safe-ignore annotations
183
+ * - Text search across findings
184
+ */
185
+ generateFullReport(scoreResult, findings, depVulns, recon, remediationPlan, rootPath, outputPath) {
186
+ const projectName = path.basename(rootPath);
187
+ const date = new Date().toLocaleDateString('en-US', {
188
+ year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit',
189
+ });
190
+
191
+ const gradeColors = { A: '#22c55e', B: '#06b6d4', C: '#eab308', D: '#ef4444', F: '#dc2626' };
192
+ const sevColors = { critical: '#dc2626', high: '#f97316', medium: '#eab308', low: '#3b82f6' };
193
+
194
+ const bySeverity = { critical: 0, high: 0, medium: 0, low: 0 };
195
+ for (const f of findings) bySeverity[f.severity] = (bySeverity[f.severity] || 0) + 1;
196
+
197
+ // Category chart data
198
+ const catEntries = Object.entries(scoreResult.categories);
199
+ const maxDeduction = Math.max(...catEntries.map(([, c]) => c.deduction), 1);
200
+ const categoryBars = catEntries.map(([key, cat]) => {
201
+ const count = Object.values(cat.counts).reduce((a, b) => a + b, 0);
202
+ const pct = Math.round((cat.deduction / maxDeduction) * 100);
203
+ const color = cat.deduction > 5 ? '#ef4444' : cat.deduction > 0 ? '#f97316' : '#22c55e';
204
+ return `<div class="bar-row">
205
+ <span class="bar-label">${this.esc(cat.label)}</span>
206
+ <div class="bar-track"><div class="bar-fill" style="width:${pct}%;background:${color}"></div></div>
207
+ <span class="bar-value" style="color:${color}">${cat.deduction > 0 ? '-' + Math.round(cat.deduction * 10) / 10 : '0'} pts</span>
208
+ <span class="bar-count">${count} findings</span>
209
+ </div>`;
210
+ }).join('\n');
211
+
212
+ // Finding rows with collapsible detail
213
+ const findingRows = findings.slice(0, 500).map((f, i) => {
214
+ const relFile = path.relative(rootPath, f.file).replace(/\\/g, '/');
215
+ let codeBlock = '';
216
+ if (f.codeContext && f.codeContext.length > 0) {
217
+ const codeLines = f.codeContext.map(c =>
218
+ `<span style="${c.highlight ? 'background:#dc262633;display:block;' : ''}">${String(c.line).padStart(4)} ${this.esc(c.text)}</span>`
219
+ ).join('');
220
+ codeBlock = `<pre class="code-block"><code>${codeLines}</code></pre>`;
221
+ }
222
+ const ignoreAnnotation = `ship-safe-ignore ${f.rule || ''}`.trim();
223
+ return `<tr class="finding-row" data-sev="${f.severity}" data-rule="${this.esc(f.rule || '')}" data-text="${this.esc((f.title || '') + ' ' + (f.description || '') + ' ' + relFile).toLowerCase()}">
224
+ <td><span class="sev sev-${f.severity}">${f.severity.toUpperCase()}</span></td>
225
+ <td><code>${this.esc(relFile)}:${f.line}</code></td>
226
+ <td>
227
+ <strong class="finding-title" onclick="toggleDetail(${i})">${this.esc(f.title || f.rule)}</strong>
228
+ <div id="detail-${i}" class="finding-detail" style="display:none">
229
+ <p>${this.esc((f.description || '').slice(0, 300))}</p>
230
+ ${f.cwe ? `<p class="finding-meta">CWE: ${this.esc(f.cwe)}${f.owasp ? ` | OWASP: ${this.esc(f.owasp)}` : ''}</p>` : ''}
231
+ ${codeBlock}
232
+ ${f.fix ? `<p class="finding-fix">Fix: ${this.esc(f.fix.slice(0, 200))}</p>` : ''}
233
+ <button class="copy-btn" onclick="copyIgnore('${this.esc(ignoreAnnotation)}',this);event.stopPropagation()">Copy ignore annotation</button>
234
+ </div>
235
+ </td>
236
+ <td><code>${this.esc((f.matched || '').slice(0, 60))}</code></td>
237
+ <td>${f.fix ? `<small>${this.esc(f.fix.slice(0, 100))}</small>` : ''}</td>
238
+ </tr>`;
239
+ }).join('\n');
240
+
241
+ // Dep vuln rows
242
+ const depRows = (depVulns || []).slice(0, 100).map(d => {
243
+ const sev = d.severity === 'moderate' ? 'medium' : d.severity;
244
+ return `<tr>
245
+ <td><span class="sev sev-${sev}">${(d.severity || 'unknown').toUpperCase()}</span></td>
246
+ <td><code>${this.esc(d.package || d.id || 'unknown')}</code></td>
247
+ <td>${this.esc((d.description || '').slice(0, 150))}</td>
248
+ </tr>`;
249
+ }).join('\n');
250
+
251
+ // Remediation plan rows
252
+ const sevIcons = { critical: '&#x1F534;', high: '&#x1F7E0;', medium: '&#x1F7E1;', low: '&#x1F535;' };
253
+ let currentSev = null;
254
+ let planHTML = '';
255
+ for (const item of (remediationPlan || []).slice(0, 100)) {
256
+ if (item.severity !== currentSev) {
257
+ currentSev = item.severity;
258
+ const label = { critical: 'CRITICAL — fix immediately', high: 'HIGH — fix before deploy', medium: 'MEDIUM — fix soon', low: 'LOW — review when possible' };
259
+ planHTML += `<tr class="sev-header"><td colspan="5" style="background:#1e293b;padding:1rem;font-weight:bold;color:${sevColors[currentSev] || '#94a3b8'}">${sevIcons[currentSev] || ''} ${label[currentSev] || currentSev.toUpperCase()}</td></tr>\n`;
260
+ }
261
+ planHTML += `<tr>
262
+ <td>${item.priority}</td>
263
+ <td><span class="sev sev-${item.severity}">${this.esc(item.categoryLabel)}</span></td>
264
+ <td><strong>${this.esc(item.title)}</strong></td>
265
+ <td><code>${this.esc(item.file)}</code></td>
266
+ <td><small>${this.esc((item.action || '').slice(0, 120))}</small></td>
267
+ </tr>\n`;
268
+ }
269
+
270
+ const html = `<!DOCTYPE html>
271
+ <html lang="en">
272
+ <head>
273
+ <meta charset="utf-8">
274
+ <meta name="viewport" content="width=device-width,initial-scale=1">
275
+ <title>Ship Safe Full Audit Report — ${this.esc(projectName)}</title>
276
+ <style>
277
+ *{margin:0;padding:0;box-sizing:border-box}
278
+ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#0f172a;color:#e2e8f0;padding:2rem}
279
+ .container{max-width:1200px;margin:0 auto}
280
+ h1{font-size:2rem;margin-bottom:0.5rem;color:#38bdf8}
281
+ h2{font-size:1.3rem;margin:2rem 0 1rem;color:#94a3b8;border-bottom:1px solid #1e293b;padding-bottom:0.5rem}
282
+ .meta{color:#64748b;margin-bottom:2rem}
283
+ .score-card{display:flex;align-items:center;gap:2rem;background:#1e293b;padding:2rem;border-radius:12px;margin-bottom:2rem}
284
+ .score-number{font-size:4rem;font-weight:bold}
285
+ .grade{font-size:3rem;font-weight:bold;width:80px;height:80px;display:flex;align-items:center;justify-content:center;border-radius:12px}
286
+ .stats{display:grid;grid-template-columns:repeat(4,1fr);gap:1rem;margin-bottom:2rem}
287
+ .stat{background:#1e293b;padding:1.5rem;border-radius:8px;text-align:center;cursor:pointer;transition:transform .15s,box-shadow .15s}
288
+ .stat:hover{transform:translateY(-2px);box-shadow:0 4px 12px rgba(0,0,0,.3)}
289
+ .stat.active{outline:2px solid #38bdf8}
290
+ .stat-number{font-size:2rem;font-weight:bold}
291
+ .stat-label{color:#64748b;font-size:0.85rem}
292
+ .summary-grid{display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin-bottom:2rem}
293
+ .summary-card{background:#1e293b;padding:1.5rem;border-radius:8px}
294
+ .summary-card h3{color:#38bdf8;font-size:1rem;margin-bottom:0.5rem}
295
+ .summary-card .big{font-size:2.5rem;font-weight:bold}
296
+ /* Bar chart */
297
+ .chart{background:#1e293b;border-radius:8px;padding:1.5rem;margin-bottom:2rem}
298
+ .bar-row{display:flex;align-items:center;gap:0.75rem;padding:0.4rem 0}
299
+ .bar-label{width:160px;font-size:0.8rem;color:#94a3b8;text-align:right;flex-shrink:0}
300
+ .bar-track{flex:1;height:20px;background:#0f172a;border-radius:4px;overflow:hidden}
301
+ .bar-fill{height:100%;border-radius:4px;transition:width .4s ease}
302
+ .bar-value{width:70px;font-size:0.8rem;font-weight:bold;flex-shrink:0}
303
+ .bar-count{width:80px;font-size:0.75rem;color:#64748b;flex-shrink:0}
304
+ /* Filter bar */
305
+ .filter-bar{display:flex;align-items:center;gap:1rem;background:#1e293b;padding:1rem 1.5rem;border-radius:8px;margin-bottom:1rem;flex-wrap:wrap}
306
+ .filter-bar label{font-size:0.8rem;color:#94a3b8}
307
+ .filter-btn{padding:4px 12px;border-radius:4px;border:1px solid #334155;background:#0f172a;color:#e2e8f0;cursor:pointer;font-size:0.8rem;transition:background .15s}
308
+ .filter-btn.active{border-color:#38bdf8;background:#38bdf822}
309
+ .filter-btn:hover{background:#334155}
310
+ .search-input{background:#0f172a;border:1px solid #334155;border-radius:4px;padding:6px 12px;color:#e2e8f0;font-size:0.8rem;width:200px}
311
+ .search-input:focus{outline:none;border-color:#38bdf8}
312
+ .filter-bar .count-label{margin-left:auto;font-size:0.8rem;color:#64748b}
313
+ /* Table */
314
+ table{width:100%;border-collapse:collapse;background:#1e293b;border-radius:8px;overflow:hidden;margin-bottom:2rem}
315
+ th{background:#334155;text-align:left;padding:0.75rem 1rem;font-size:0.8rem;text-transform:uppercase;color:#94a3b8;cursor:pointer;user-select:none}
316
+ th:hover{color:#e2e8f0}
317
+ td{padding:0.75rem 1rem;border-top:1px solid #0f172a;font-size:0.85rem;vertical-align:top}
318
+ tr:hover{background:#334155}
319
+ code{background:#0f172a;padding:2px 6px;border-radius:4px;font-size:0.8rem;color:#38bdf8;word-break:break-all}
320
+ small{color:#94a3b8}
321
+ .sev{padding:2px 8px;border-radius:4px;font-size:0.7rem;font-weight:bold;text-transform:uppercase;white-space:nowrap}
322
+ .sev-critical{background:#dc262633;color:#fca5a5}
323
+ .sev-high{background:#f9731633;color:#fdba74}
324
+ .sev-medium,.sev-moderate{background:#eab30833;color:#fde047}
325
+ .sev-low{background:#3b82f633;color:#93c5fd}
326
+ /* Collapsible findings */
327
+ .finding-title{cursor:pointer;border-bottom:1px dashed #475569}
328
+ .finding-title:hover{color:#38bdf8}
329
+ .finding-detail{margin-top:0.5rem;padding:0.75rem;background:#0f172a;border-radius:6px;border-left:3px solid #38bdf8}
330
+ .finding-detail p{font-size:0.8rem;color:#94a3b8;margin-bottom:0.4rem}
331
+ .finding-meta{font-size:0.75rem;color:#64748b}
332
+ .finding-fix{color:#22c55e;font-size:0.8rem}
333
+ .code-block{background:#020617;padding:0.5rem;border-radius:4px;font-size:0.75rem;margin:0.5rem 0;overflow-x:auto;line-height:1.4}
334
+ .copy-btn{background:#334155;color:#38bdf8;border:1px solid #475569;border-radius:4px;padding:3px 10px;font-size:0.7rem;cursor:pointer;margin-top:0.4rem}
335
+ .copy-btn:hover{background:#475569}
336
+ .copy-btn.copied{background:#22c55e33;color:#22c55e;border-color:#22c55e}
337
+ /* TOC */
338
+ .toc{background:#1e293b;padding:1.5rem 2rem;border-radius:8px;margin-bottom:2rem}
339
+ .toc a{color:#38bdf8;text-decoration:none;display:block;padding:0.3rem 0}
340
+ .toc a:hover{text-decoration:underline}
341
+ .footer{text-align:center;color:#475569;margin-top:3rem;padding:2rem;border-top:1px solid #1e293b}
342
+ .footer a{color:#38bdf8}
343
+ /* Hidden row */
344
+ .hidden-row{display:none}
345
+ /* Print */
346
+ @media print{
347
+ body{background:#fff;color:#1e293b}
348
+ table,th,td{border:1px solid #e2e8f0}
349
+ .score-card,.stat,.summary-card,.toc,.chart,.filter-bar{background:#f8fafc}
350
+ .copy-btn,.search-input{display:none}
351
+ .finding-detail{display:block!important}
352
+ }
353
+ </style>
354
+ </head>
355
+ <body>
356
+ <div class="container">
357
+ <h1>Ship Safe — Full Security Audit Report</h1>
358
+ <p class="meta">${this.esc(projectName)} — ${date}</p>
359
+
360
+ <div class="toc">
361
+ <strong>Contents</strong>
362
+ <a href="#score">1. Security Score</a>
363
+ <a href="#summary">2. Executive Summary</a>
364
+ <a href="#categories">3. Category Breakdown</a>
365
+ <a href="#plan">4. Remediation Plan (${(remediationPlan || []).length} items)</a>
366
+ <a href="#findings">5. All Findings (${findings.length})</a>
367
+ <a href="#deps">6. Dependency Vulnerabilities (${(depVulns || []).length})</a>
368
+ <a href="#surface">7. Attack Surface</a>
369
+ </div>
370
+
371
+ <h2 id="score">1. Security Score</h2>
372
+ <div class="score-card">
373
+ <div class="grade" style="background:${gradeColors[scoreResult.grade.letter]}22;color:${gradeColors[scoreResult.grade.letter]}">${scoreResult.grade.letter}</div>
374
+ <div>
375
+ <div class="score-number" style="color:${gradeColors[scoreResult.grade.letter]}">${scoreResult.score}/100</div>
376
+ <div style="color:#94a3b8">${scoreResult.grade.label}</div>
377
+ </div>
378
+ </div>
379
+
380
+ <div class="stats" id="severity-stats">
381
+ <div class="stat" onclick="toggleSevFilter('critical')" id="stat-critical"><div class="stat-number" style="color:${sevColors.critical}">${bySeverity.critical}</div><div class="stat-label">Critical</div></div>
382
+ <div class="stat" onclick="toggleSevFilter('high')" id="stat-high"><div class="stat-number" style="color:${sevColors.high}">${bySeverity.high}</div><div class="stat-label">High</div></div>
383
+ <div class="stat" onclick="toggleSevFilter('medium')" id="stat-medium"><div class="stat-number" style="color:${sevColors.medium}">${bySeverity.medium}</div><div class="stat-label">Medium</div></div>
384
+ <div class="stat" onclick="toggleSevFilter('low')" id="stat-low"><div class="stat-number" style="color:${sevColors.low}">${bySeverity.low}</div><div class="stat-label">Low</div></div>
385
+ </div>
386
+
387
+ <h2 id="summary">2. Executive Summary</h2>
388
+ <div class="summary-grid">
389
+ <div class="summary-card">
390
+ <h3>Code Findings</h3>
391
+ <div class="big" style="color:${findings.length > 0 ? '#ef4444' : '#22c55e'}">${findings.length}</div>
392
+ <small>Across ${Object.keys(scoreResult.categories).length} categories</small>
393
+ </div>
394
+ <div class="summary-card">
395
+ <h3>Dependency CVEs</h3>
396
+ <div class="big" style="color:${(depVulns || []).length > 0 ? '#ef4444' : '#22c55e'}">${(depVulns || []).length}</div>
397
+ <small>From npm/pip/bundler audit</small>
398
+ </div>
399
+ </div>
400
+
401
+ <h2 id="categories">3. Category Breakdown</h2>
402
+ <div class="chart">
403
+ ${categoryBars}
404
+ </div>
405
+
406
+ <h2 id="plan">4. Remediation Plan</h2>
407
+ <p style="color:#94a3b8;margin-bottom:1rem">Prioritized list of fixes. Address critical items first.</p>
408
+ ${(remediationPlan || []).length > 0 ? `<table>
409
+ <thead><tr><th>#</th><th>Category</th><th>Issue</th><th>Location</th><th>Fix</th></tr></thead>
410
+ <tbody>${planHTML}</tbody>
411
+ </table>` : '<p style="color:#22c55e;font-weight:bold">No issues found — all clear!</p>'}
412
+
413
+ <h2 id="findings">5. All Findings (${findings.length})</h2>
414
+ <div class="filter-bar">
415
+ <label>Filter:</label>
416
+ <button class="filter-btn active" data-sev="all" onclick="filterSev('all',this)">All</button>
417
+ <button class="filter-btn" data-sev="critical" onclick="filterSev('critical',this)">Critical (${bySeverity.critical})</button>
418
+ <button class="filter-btn" data-sev="high" onclick="filterSev('high',this)">High (${bySeverity.high})</button>
419
+ <button class="filter-btn" data-sev="medium" onclick="filterSev('medium',this)">Medium (${bySeverity.medium})</button>
420
+ <button class="filter-btn" data-sev="low" onclick="filterSev('low',this)">Low (${bySeverity.low})</button>
421
+ <input class="search-input" type="text" placeholder="Search findings..." oninput="searchFindings(this.value)">
422
+ <span class="count-label" id="visible-count">${findings.length} shown</span>
423
+ </div>
424
+ <table id="findings-table">
425
+ <thead><tr><th>Severity</th><th>Location</th><th>Issue</th><th>Code</th><th>Fix</th></tr></thead>
426
+ <tbody>${findingRows || '<tr><td colspan="5" style="text-align:center;color:#22c55e">No findings — clean!</td></tr>'}</tbody>
427
+ </table>
428
+
429
+ <h2 id="deps">6. Dependency Vulnerabilities (${(depVulns || []).length})</h2>
430
+ ${(depVulns || []).length > 0 ? `<table>
431
+ <thead><tr><th>Severity</th><th>Package</th><th>Description</th></tr></thead>
432
+ <tbody>${depRows}</tbody>
433
+ </table>` : '<p style="color:#22c55e;font-weight:bold">No vulnerable dependencies found.</p>'}
434
+
435
+ ${recon ? `<h2 id="surface">7. Attack Surface</h2>
436
+ <table>
437
+ <tbody>
438
+ <tr><td>Frameworks</td><td>${(recon.frameworks || []).join(', ') || 'None detected'}</td></tr>
439
+ <tr><td>Languages</td><td>${(recon.languages || []).join(', ') || 'None detected'}</td></tr>
440
+ <tr><td>Databases</td><td>${(recon.databases || []).join(', ') || 'None detected'}</td></tr>
441
+ <tr><td>Cloud Providers</td><td>${(recon.cloudProviders || []).join(', ') || 'None detected'}</td></tr>
442
+ <tr><td>Auth Patterns</td><td>${(recon.authPatterns || []).join(', ') || 'None detected'}</td></tr>
443
+ <tr><td>CI/CD</td><td>${(recon.cicd || []).map(c => c.platform).join(', ') || 'None detected'}</td></tr>
444
+ <tr><td>API Routes</td><td>${(recon.apiRoutes || []).length} discovered</td></tr>
445
+ </tbody>
446
+ </table>` : ''}
447
+
448
+ <div class="footer">
449
+ Generated by <strong>Ship Safe v6.0</strong> — Full Security Audit<br>
450
+ <a href="https://shipsafecli.com">shipsafecli.com</a>
451
+ </div>
452
+ </div>
453
+
454
+ <script>
455
+ // ── Severity filter ────────────────────────────────────────────────────────
456
+ let activeSev = 'all';
457
+ let searchTerm = '';
458
+
459
+ function filterSev(sev, btn) {
460
+ activeSev = sev;
461
+ document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
462
+ btn.classList.add('active');
463
+ applyFilters();
464
+ }
465
+
466
+ function toggleSevFilter(sev) {
467
+ const btn = document.querySelector('.filter-btn[data-sev="' + sev + '"]');
468
+ if (activeSev === sev) {
469
+ filterSev('all', document.querySelector('.filter-btn[data-sev="all"]'));
470
+ } else if (btn) {
471
+ filterSev(sev, btn);
472
+ }
473
+ }
474
+
475
+ function searchFindings(term) {
476
+ searchTerm = term.toLowerCase();
477
+ applyFilters();
478
+ }
479
+
480
+ function applyFilters() {
481
+ const rows = document.querySelectorAll('.finding-row');
482
+ let visible = 0;
483
+ rows.forEach(row => {
484
+ const matchSev = activeSev === 'all' || row.dataset.sev === activeSev;
485
+ const matchSearch = !searchTerm || row.dataset.text.includes(searchTerm);
486
+ if (matchSev && matchSearch) {
487
+ row.classList.remove('hidden-row');
488
+ visible++;
489
+ } else {
490
+ row.classList.add('hidden-row');
491
+ }
492
+ });
493
+ document.getElementById('visible-count').textContent = visible + ' shown';
494
+
495
+ // Highlight active stat card
496
+ document.querySelectorAll('.stat').forEach(s => s.classList.remove('active'));
497
+ if (activeSev !== 'all') {
498
+ const el = document.getElementById('stat-' + activeSev);
499
+ if (el) el.classList.add('active');
500
+ }
501
+ }
502
+
503
+ // ── Collapsible detail ─────────────────────────────────────────────────────
504
+ function toggleDetail(idx) {
505
+ const el = document.getElementById('detail-' + idx);
506
+ if (el) el.style.display = el.style.display === 'none' ? 'block' : 'none';
507
+ }
508
+
509
+ // ── Copy ignore annotation ─────────────────────────────────────────────────
510
+ function copyIgnore(text, btn) {
511
+ navigator.clipboard.writeText('// ' + text).then(() => {
512
+ btn.textContent = 'Copied!';
513
+ btn.classList.add('copied');
514
+ setTimeout(() => { btn.textContent = 'Copy ignore annotation'; btn.classList.remove('copied'); }, 2000);
515
+ });
516
+ }
517
+ </script>
518
+ </body>
519
+ </html>`;
520
+
521
+ fs.writeFileSync(outputPath, html);
522
+ return outputPath;
523
+ }
524
+
525
+ /** Escape HTML entities */
526
+ esc(str) {
527
+ if (!str) return '';
528
+ return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
529
+ }
530
+ }
531
+
532
+ export default HTMLReporter;