ship-safe 6.1.1 → 6.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +735 -641
  2. package/cli/agents/api-fuzzer.js +345 -345
  3. package/cli/agents/auth-bypass-agent.js +348 -348
  4. package/cli/agents/base-agent.js +272 -272
  5. package/cli/agents/cicd-scanner.js +236 -201
  6. package/cli/agents/config-auditor.js +521 -521
  7. package/cli/agents/deep-analyzer.js +6 -2
  8. package/cli/agents/git-history-scanner.js +170 -170
  9. package/cli/agents/html-reporter.js +568 -568
  10. package/cli/agents/index.js +84 -84
  11. package/cli/agents/injection-tester.js +500 -500
  12. package/cli/agents/llm-redteam.js +251 -251
  13. package/cli/agents/mobile-scanner.js +231 -231
  14. package/cli/agents/orchestrator.js +322 -322
  15. package/cli/agents/pii-compliance-agent.js +301 -301
  16. package/cli/agents/scoring-engine.js +248 -248
  17. package/cli/agents/supabase-rls-agent.js +154 -154
  18. package/cli/agents/supply-chain-agent.js +650 -507
  19. package/cli/bin/ship-safe.js +452 -426
  20. package/cli/commands/agent.js +608 -608
  21. package/cli/commands/audit.js +986 -980
  22. package/cli/commands/baseline.js +193 -193
  23. package/cli/commands/ci.js +342 -342
  24. package/cli/commands/deps.js +516 -516
  25. package/cli/commands/doctor.js +159 -159
  26. package/cli/commands/fix.js +218 -218
  27. package/cli/commands/hooks.js +268 -0
  28. package/cli/commands/init.js +407 -407
  29. package/cli/commands/mcp.js +304 -304
  30. package/cli/commands/red-team.js +7 -1
  31. package/cli/commands/remediate.js +798 -798
  32. package/cli/commands/rotate.js +571 -571
  33. package/cli/commands/scan.js +569 -569
  34. package/cli/commands/score.js +449 -449
  35. package/cli/commands/watch.js +281 -281
  36. package/cli/hooks/patterns.js +313 -0
  37. package/cli/hooks/post-tool-use.js +140 -0
  38. package/cli/hooks/pre-tool-use.js +186 -0
  39. package/cli/index.js +73 -69
  40. package/cli/providers/llm-provider.js +397 -287
  41. package/cli/utils/autofix-rules.js +74 -74
  42. package/cli/utils/cache-manager.js +311 -311
  43. package/cli/utils/output.js +230 -230
  44. package/cli/utils/patterns.js +1121 -1121
  45. package/cli/utils/pdf-generator.js +94 -94
  46. package/package.json +69 -69
  47. package/configs/supabase/rls-templates.sql +0 -242
@@ -1,568 +1,568 @@
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 style="text-align:center;margin:1.5rem 0">
158
- <a href="https://twitter.com/intent/tweet?text=${encodeURIComponent(`My project scored ${scoreResult.score}/100 (Grade ${scoreResult.grade.letter}) on Ship Safe! Scan yours: npx ship-safe audit . https://shipsafecli.com`)}" target="_blank" rel="noopener" style="display:inline-block;padding:8px 18px;background:#000;color:#fff;border-radius:6px;font-size:0.85rem;font-weight:600;text-decoration:none;margin:0 4px">Share on X</a>
159
- <a href="https://www.linkedin.com/sharing/share-offsite/?url=https://shipsafecli.com" target="_blank" rel="noopener" style="display:inline-block;padding:8px 18px;background:#0a66c2;color:#fff;border-radius:6px;font-size:0.85rem;font-weight:600;text-decoration:none;margin:0 4px">Share on LinkedIn</a>
160
- </div>
161
-
162
- <div class="footer">
163
- Generated by <strong>Ship Safe v6.1</strong> — Security toolkit for developers<br>
164
- <a href="https://shipsafecli.com" style="color:#38bdf8">shipsafecli.com</a> &middot; <a href="https://shipsafecli.com/pricing" style="color:#38bdf8">Cloud Dashboard</a>
165
- </div>
166
- </div>
167
- </body>
168
- </html>`;
169
- }
170
-
171
- /**
172
- * Generate and write HTML report to file.
173
- */
174
- generateToFile(scoreResult, findings, recon, rootPath, outputPath) {
175
- const html = this.generate(scoreResult, findings, recon, rootPath);
176
- fs.writeFileSync(outputPath, html);
177
- return outputPath;
178
- }
179
-
180
- /**
181
- * Generate a full interactive audit report including deps and remediation plan.
182
- *
183
- * Interactive features:
184
- * - Severity filter toolbar
185
- * - Category deduction bar chart
186
- * - Collapsible finding rows with code context
187
- * - Click-to-copy ship-safe-ignore annotations
188
- * - Text search across findings
189
- */
190
- generateFullReport(scoreResult, findings, depVulns, recon, remediationPlan, rootPath, outputPath) {
191
- const projectName = path.basename(rootPath);
192
- const date = new Date().toLocaleDateString('en-US', {
193
- year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit',
194
- });
195
-
196
- const gradeColors = { A: '#22c55e', B: '#06b6d4', C: '#eab308', D: '#ef4444', F: '#dc2626' };
197
- const sevColors = { critical: '#dc2626', high: '#f97316', medium: '#eab308', low: '#3b82f6' };
198
-
199
- const bySeverity = { critical: 0, high: 0, medium: 0, low: 0 };
200
- for (const f of findings) bySeverity[f.severity] = (bySeverity[f.severity] || 0) + 1;
201
-
202
- // Category chart data
203
- const catEntries = Object.entries(scoreResult.categories);
204
- const maxDeduction = Math.max(...catEntries.map(([, c]) => c.deduction), 1);
205
- const categoryBars = catEntries.map(([key, cat]) => {
206
- const count = Object.values(cat.counts).reduce((a, b) => a + b, 0);
207
- const pct = Math.round((cat.deduction / maxDeduction) * 100);
208
- const color = cat.deduction > 5 ? '#ef4444' : cat.deduction > 0 ? '#f97316' : '#22c55e';
209
- return `<div class="bar-row">
210
- <span class="bar-label">${this.esc(cat.label)}</span>
211
- <div class="bar-track"><div class="bar-fill" style="width:${pct}%;background:${color}"></div></div>
212
- <span class="bar-value" style="color:${color}">${cat.deduction > 0 ? '-' + Math.round(cat.deduction * 10) / 10 : '0'} pts</span>
213
- <span class="bar-count">${count} findings</span>
214
- </div>`;
215
- }).join('\n');
216
-
217
- // Finding rows with collapsible detail
218
- const findingRows = findings.slice(0, 500).map((f, i) => {
219
- const relFile = path.relative(rootPath, f.file).replace(/\\/g, '/');
220
- let codeBlock = '';
221
- if (f.codeContext && f.codeContext.length > 0) {
222
- const codeLines = f.codeContext.map(c =>
223
- `<span style="${c.highlight ? 'background:#dc262633;display:block;' : ''}">${String(c.line).padStart(4)} ${this.esc(c.text)}</span>`
224
- ).join('');
225
- codeBlock = `<pre class="code-block"><code>${codeLines}</code></pre>`;
226
- }
227
- const ignoreAnnotation = `ship-safe-ignore ${f.rule || ''}`.trim();
228
- 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()}">
229
- <td><span class="sev sev-${f.severity}">${f.severity.toUpperCase()}</span></td>
230
- <td><code>${this.esc(relFile)}:${f.line}</code></td>
231
- <td>
232
- <strong class="finding-title" onclick="toggleDetail(${i})">${this.esc(f.title || f.rule)}</strong>
233
- <div id="detail-${i}" class="finding-detail" style="display:none">
234
- <p>${this.esc((f.description || '').slice(0, 300))}</p>
235
- ${f.cwe ? `<p class="finding-meta">CWE: ${this.esc(f.cwe)}${f.owasp ? ` | OWASP: ${this.esc(f.owasp)}` : ''}</p>` : ''}
236
- ${codeBlock}
237
- ${f.fix ? `<p class="finding-fix">Fix: ${this.esc(f.fix.slice(0, 200))}</p>` : ''}
238
- <button class="copy-btn" onclick="copyIgnore('${this.esc(ignoreAnnotation)}',this);event.stopPropagation()">Copy ignore annotation</button>
239
- </div>
240
- </td>
241
- <td><code>${this.esc((f.matched || '').slice(0, 60))}</code></td>
242
- <td>${f.fix ? `<small>${this.esc(f.fix.slice(0, 100))}</small>` : ''}</td>
243
- </tr>`;
244
- }).join('\n');
245
-
246
- // Dep vuln rows
247
- const depRows = (depVulns || []).slice(0, 100).map(d => {
248
- const sev = d.severity === 'moderate' ? 'medium' : d.severity;
249
- return `<tr>
250
- <td><span class="sev sev-${sev}">${(d.severity || 'unknown').toUpperCase()}</span></td>
251
- <td><code>${this.esc(d.package || d.id || 'unknown')}</code></td>
252
- <td>${this.esc((d.description || '').slice(0, 150))}</td>
253
- </tr>`;
254
- }).join('\n');
255
-
256
- // Remediation plan rows
257
- const sevIcons = { critical: '&#x1F534;', high: '&#x1F7E0;', medium: '&#x1F7E1;', low: '&#x1F535;' };
258
- let currentSev = null;
259
- let planHTML = '';
260
- for (const item of (remediationPlan || []).slice(0, 100)) {
261
- if (item.severity !== currentSev) {
262
- currentSev = item.severity;
263
- const label = { critical: 'CRITICAL — fix immediately', high: 'HIGH — fix before deploy', medium: 'MEDIUM — fix soon', low: 'LOW — review when possible' };
264
- 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`;
265
- }
266
- planHTML += `<tr>
267
- <td>${item.priority}</td>
268
- <td><span class="sev sev-${item.severity}">${this.esc(item.categoryLabel)}</span></td>
269
- <td><strong>${this.esc(item.title)}</strong></td>
270
- <td><code>${this.esc(item.file)}</code></td>
271
- <td><small>${this.esc((item.action || '').slice(0, 120))}</small></td>
272
- </tr>\n`;
273
- }
274
-
275
- const html = `<!DOCTYPE html>
276
- <html lang="en">
277
- <head>
278
- <meta charset="utf-8">
279
- <meta name="viewport" content="width=device-width,initial-scale=1">
280
- <title>Ship Safe Full Audit Report — ${this.esc(projectName)}</title>
281
- <style>
282
- *{margin:0;padding:0;box-sizing:border-box}
283
- body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#0f172a;color:#e2e8f0;padding:2rem}
284
- .container{max-width:1200px;margin:0 auto}
285
- h1{font-size:2rem;margin-bottom:0.5rem;color:#38bdf8}
286
- h2{font-size:1.3rem;margin:2rem 0 1rem;color:#94a3b8;border-bottom:1px solid #1e293b;padding-bottom:0.5rem}
287
- .meta{color:#64748b;margin-bottom:2rem}
288
- .score-card{display:flex;align-items:center;gap:2rem;background:#1e293b;padding:2rem;border-radius:12px;margin-bottom:2rem}
289
- .score-number{font-size:4rem;font-weight:bold}
290
- .grade{font-size:3rem;font-weight:bold;width:80px;height:80px;display:flex;align-items:center;justify-content:center;border-radius:12px}
291
- .stats{display:grid;grid-template-columns:repeat(4,1fr);gap:1rem;margin-bottom:2rem}
292
- .stat{background:#1e293b;padding:1.5rem;border-radius:8px;text-align:center;cursor:pointer;transition:transform .15s,box-shadow .15s}
293
- .stat:hover{transform:translateY(-2px);box-shadow:0 4px 12px rgba(0,0,0,.3)}
294
- .stat.active{outline:2px solid #38bdf8}
295
- .stat-number{font-size:2rem;font-weight:bold}
296
- .stat-label{color:#64748b;font-size:0.85rem}
297
- .summary-grid{display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin-bottom:2rem}
298
- .summary-card{background:#1e293b;padding:1.5rem;border-radius:8px}
299
- .summary-card h3{color:#38bdf8;font-size:1rem;margin-bottom:0.5rem}
300
- .summary-card .big{font-size:2.5rem;font-weight:bold}
301
- /* Bar chart */
302
- .chart{background:#1e293b;border-radius:8px;padding:1.5rem;margin-bottom:2rem}
303
- .bar-row{display:flex;align-items:center;gap:0.75rem;padding:0.4rem 0}
304
- .bar-label{width:160px;font-size:0.8rem;color:#94a3b8;text-align:right;flex-shrink:0}
305
- .bar-track{flex:1;height:20px;background:#0f172a;border-radius:4px;overflow:hidden}
306
- .bar-fill{height:100%;border-radius:4px;transition:width .4s ease}
307
- .bar-value{width:70px;font-size:0.8rem;font-weight:bold;flex-shrink:0}
308
- .bar-count{width:80px;font-size:0.75rem;color:#64748b;flex-shrink:0}
309
- /* Filter bar */
310
- .filter-bar{display:flex;align-items:center;gap:1rem;background:#1e293b;padding:1rem 1.5rem;border-radius:8px;margin-bottom:1rem;flex-wrap:wrap}
311
- .filter-bar label{font-size:0.8rem;color:#94a3b8}
312
- .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}
313
- .filter-btn.active{border-color:#38bdf8;background:#38bdf822}
314
- .filter-btn:hover{background:#334155}
315
- .search-input{background:#0f172a;border:1px solid #334155;border-radius:4px;padding:6px 12px;color:#e2e8f0;font-size:0.8rem;width:200px}
316
- .search-input:focus{outline:none;border-color:#38bdf8}
317
- .filter-bar .count-label{margin-left:auto;font-size:0.8rem;color:#64748b}
318
- /* Table */
319
- table{width:100%;border-collapse:collapse;background:#1e293b;border-radius:8px;overflow:hidden;margin-bottom:2rem}
320
- th{background:#334155;text-align:left;padding:0.75rem 1rem;font-size:0.8rem;text-transform:uppercase;color:#94a3b8;cursor:pointer;user-select:none}
321
- th:hover{color:#e2e8f0}
322
- td{padding:0.75rem 1rem;border-top:1px solid #0f172a;font-size:0.85rem;vertical-align:top}
323
- tr:hover{background:#334155}
324
- code{background:#0f172a;padding:2px 6px;border-radius:4px;font-size:0.8rem;color:#38bdf8;word-break:break-all}
325
- small{color:#94a3b8}
326
- .sev{padding:2px 8px;border-radius:4px;font-size:0.7rem;font-weight:bold;text-transform:uppercase;white-space:nowrap}
327
- .sev-critical{background:#dc262633;color:#fca5a5}
328
- .sev-high{background:#f9731633;color:#fdba74}
329
- .sev-medium,.sev-moderate{background:#eab30833;color:#fde047}
330
- .sev-low{background:#3b82f633;color:#93c5fd}
331
- /* Collapsible findings */
332
- .finding-title{cursor:pointer;border-bottom:1px dashed #475569}
333
- .finding-title:hover{color:#38bdf8}
334
- .finding-detail{margin-top:0.5rem;padding:0.75rem;background:#0f172a;border-radius:6px;border-left:3px solid #38bdf8}
335
- .finding-detail p{font-size:0.8rem;color:#94a3b8;margin-bottom:0.4rem}
336
- .finding-meta{font-size:0.75rem;color:#64748b}
337
- .finding-fix{color:#22c55e;font-size:0.8rem}
338
- .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}
339
- .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}
340
- .copy-btn:hover{background:#475569}
341
- .copy-btn.copied{background:#22c55e33;color:#22c55e;border-color:#22c55e}
342
- /* TOC */
343
- .toc{background:#1e293b;padding:1.5rem 2rem;border-radius:8px;margin-bottom:2rem}
344
- .toc a{color:#38bdf8;text-decoration:none;display:block;padding:0.3rem 0}
345
- .toc a:hover{text-decoration:underline}
346
- .footer{text-align:center;color:#475569;margin-top:3rem;padding:2rem;border-top:1px solid #1e293b}
347
- .footer a{color:#38bdf8}
348
- .share-bar{display:flex;justify-content:center;gap:0.75rem;margin:1.5rem 0}
349
- .share-btn{display:inline-flex;align-items:center;gap:6px;padding:8px 18px;border-radius:6px;font-size:0.85rem;font-weight:600;text-decoration:none;cursor:pointer;border:none;transition:opacity .15s}
350
- .share-btn:hover{opacity:0.85}
351
- .share-btn-x{background:#000;color:#fff}
352
- .share-btn-li{background:#0a66c2;color:#fff}
353
- .share-btn-copy{background:#334155;color:#38bdf8;border:1px solid #475569}
354
- .share-btn-copy.copied{background:#22c55e33;color:#22c55e;border-color:#22c55e}
355
- .badge-section{background:#1e293b;border-radius:8px;padding:1.5rem;margin-top:1.5rem;text-align:center}
356
- .badge-section img{margin-bottom:0.75rem}
357
- .badge-section code{display:block;background:#0f172a;padding:8px 12px;border-radius:4px;font-size:0.75rem;margin-top:0.5rem;word-break:break-all;user-select:all}
358
- /* Hidden row */
359
- .hidden-row{display:none}
360
- /* Print */
361
- @media print{
362
- body{background:#fff;color:#1e293b}
363
- table,th,td{border:1px solid #e2e8f0}
364
- .score-card,.stat,.summary-card,.toc,.chart,.filter-bar{background:#f8fafc}
365
- .copy-btn,.search-input{display:none}
366
- .finding-detail{display:block!important}
367
- }
368
- </style>
369
- </head>
370
- <body>
371
- <div class="container">
372
- <h1>Ship Safe — Full Security Audit Report</h1>
373
- <p class="meta">${this.esc(projectName)} — ${date}</p>
374
-
375
- <div class="toc">
376
- <strong>Contents</strong>
377
- <a href="#score">1. Security Score</a>
378
- <a href="#summary">2. Executive Summary</a>
379
- <a href="#categories">3. Category Breakdown</a>
380
- <a href="#plan">4. Remediation Plan (${(remediationPlan || []).length} items)</a>
381
- <a href="#findings">5. All Findings (${findings.length})</a>
382
- <a href="#deps">6. Dependency Vulnerabilities (${(depVulns || []).length})</a>
383
- <a href="#surface">7. Attack Surface</a>
384
- </div>
385
-
386
- <h2 id="score">1. Security Score</h2>
387
- <div class="score-card">
388
- <div class="grade" style="background:${gradeColors[scoreResult.grade.letter]}22;color:${gradeColors[scoreResult.grade.letter]}">${scoreResult.grade.letter}</div>
389
- <div>
390
- <div class="score-number" style="color:${gradeColors[scoreResult.grade.letter]}">${scoreResult.score}/100</div>
391
- <div style="color:#94a3b8">${scoreResult.grade.label}</div>
392
- </div>
393
- </div>
394
-
395
- <div class="stats" id="severity-stats">
396
- <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>
397
- <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>
398
- <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>
399
- <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>
400
- </div>
401
-
402
- <h2 id="summary">2. Executive Summary</h2>
403
- <div class="summary-grid">
404
- <div class="summary-card">
405
- <h3>Code Findings</h3>
406
- <div class="big" style="color:${findings.length > 0 ? '#ef4444' : '#22c55e'}">${findings.length}</div>
407
- <small>Across ${Object.keys(scoreResult.categories).length} categories</small>
408
- </div>
409
- <div class="summary-card">
410
- <h3>Dependency CVEs</h3>
411
- <div class="big" style="color:${(depVulns || []).length > 0 ? '#ef4444' : '#22c55e'}">${(depVulns || []).length}</div>
412
- <small>From npm/pip/bundler audit</small>
413
- </div>
414
- </div>
415
-
416
- <h2 id="categories">3. Category Breakdown</h2>
417
- <div class="chart">
418
- ${categoryBars}
419
- </div>
420
-
421
- <h2 id="plan">4. Remediation Plan</h2>
422
- <p style="color:#94a3b8;margin-bottom:1rem">Prioritized list of fixes. Address critical items first.</p>
423
- ${(remediationPlan || []).length > 0 ? `<table>
424
- <thead><tr><th>#</th><th>Category</th><th>Issue</th><th>Location</th><th>Fix</th></tr></thead>
425
- <tbody>${planHTML}</tbody>
426
- </table>` : '<p style="color:#22c55e;font-weight:bold">No issues found — all clear!</p>'}
427
-
428
- <h2 id="findings">5. All Findings (${findings.length})</h2>
429
- <div class="filter-bar">
430
- <label>Filter:</label>
431
- <button class="filter-btn active" data-sev="all" onclick="filterSev('all',this)">All</button>
432
- <button class="filter-btn" data-sev="critical" onclick="filterSev('critical',this)">Critical (${bySeverity.critical})</button>
433
- <button class="filter-btn" data-sev="high" onclick="filterSev('high',this)">High (${bySeverity.high})</button>
434
- <button class="filter-btn" data-sev="medium" onclick="filterSev('medium',this)">Medium (${bySeverity.medium})</button>
435
- <button class="filter-btn" data-sev="low" onclick="filterSev('low',this)">Low (${bySeverity.low})</button>
436
- <input class="search-input" type="text" placeholder="Search findings..." oninput="searchFindings(this.value)">
437
- <span class="count-label" id="visible-count">${findings.length} shown</span>
438
- </div>
439
- <table id="findings-table">
440
- <thead><tr><th>Severity</th><th>Location</th><th>Issue</th><th>Code</th><th>Fix</th></tr></thead>
441
- <tbody>${findingRows || '<tr><td colspan="5" style="text-align:center;color:#22c55e">No findings — clean!</td></tr>'}</tbody>
442
- </table>
443
-
444
- <h2 id="deps">6. Dependency Vulnerabilities (${(depVulns || []).length})</h2>
445
- ${(depVulns || []).length > 0 ? `<table>
446
- <thead><tr><th>Severity</th><th>Package</th><th>Description</th></tr></thead>
447
- <tbody>${depRows}</tbody>
448
- </table>` : '<p style="color:#22c55e;font-weight:bold">No vulnerable dependencies found.</p>'}
449
-
450
- ${recon ? `<h2 id="surface">7. Attack Surface</h2>
451
- <table>
452
- <tbody>
453
- <tr><td>Frameworks</td><td>${(recon.frameworks || []).join(', ') || 'None detected'}</td></tr>
454
- <tr><td>Languages</td><td>${(recon.languages || []).join(', ') || 'None detected'}</td></tr>
455
- <tr><td>Databases</td><td>${(recon.databases || []).join(', ') || 'None detected'}</td></tr>
456
- <tr><td>Cloud Providers</td><td>${(recon.cloudProviders || []).join(', ') || 'None detected'}</td></tr>
457
- <tr><td>Auth Patterns</td><td>${(recon.authPatterns || []).join(', ') || 'None detected'}</td></tr>
458
- <tr><td>CI/CD</td><td>${(recon.cicd || []).map(c => c.platform).join(', ') || 'None detected'}</td></tr>
459
- <tr><td>API Routes</td><td>${(recon.apiRoutes || []).length} discovered</td></tr>
460
- </tbody>
461
- </table>` : ''}
462
-
463
- <div class="share-bar">
464
- <a class="share-btn share-btn-x" href="https://twitter.com/intent/tweet?text=${encodeURIComponent(`My project scored ${scoreResult.score}/100 (Grade ${scoreResult.grade.letter}) on Ship Safe security audit! 18 AI agents, 80+ attack classes.\n\nScan yours: npx ship-safe audit .\n\nhttps://shipsafecli.com`)}" target="_blank" rel="noopener">Share on X</a>
465
- <a class="share-btn share-btn-li" href="https://www.linkedin.com/sharing/share-offsite/?url=https://shipsafecli.com" target="_blank" rel="noopener">Share on LinkedIn</a>
466
- <button class="share-btn share-btn-copy" onclick="copyShareText(this)">Copy Score Summary</button>
467
- </div>
468
-
469
- <div class="badge-section">
470
- <p style="color:#94a3b8;margin-bottom:0.75rem"><strong>Add a security badge to your README:</strong></p>
471
- <img src="https://img.shields.io/badge/Ship_Safe-${scoreResult.grade.letter}-${gradeColors[scoreResult.grade.letter].replace('#','')}" alt="Ship Safe Grade ${scoreResult.grade.letter}" />
472
- <code>[![Ship Safe](https://img.shields.io/badge/Ship_Safe-${scoreResult.grade.letter}-${gradeColors[scoreResult.grade.letter].replace('#','')})](https://shipsafecli.com)</code>
473
- </div>
474
-
475
- <div class="footer">
476
- Generated by <strong>Ship Safe v6.1</strong> — Full Security Audit<br>
477
- <a href="https://shipsafecli.com">shipsafecli.com</a> &middot; <a href="https://shipsafecli.com/pricing">Cloud Dashboard</a>
478
- </div>
479
- </div>
480
-
481
- <script>
482
- function copyShareText(btn) {
483
- const text = 'My project scored ${scoreResult.score}/100 (Grade ${scoreResult.grade.letter}) on Ship Safe security audit!\\nScan yours: npx ship-safe audit .\\nhttps://shipsafecli.com';
484
- navigator.clipboard.writeText(text).then(() => {
485
- btn.textContent = 'Copied!';
486
- btn.classList.add('copied');
487
- setTimeout(() => { btn.textContent = 'Copy Score Summary'; btn.classList.remove('copied'); }, 2000);
488
- });
489
- }
490
-
491
- // ── Severity filter ────────────────────────────────────────────────────────
492
- let activeSev = 'all';
493
- let searchTerm = '';
494
-
495
- function filterSev(sev, btn) {
496
- activeSev = sev;
497
- document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
498
- btn.classList.add('active');
499
- applyFilters();
500
- }
501
-
502
- function toggleSevFilter(sev) {
503
- const btn = document.querySelector('.filter-btn[data-sev="' + sev + '"]');
504
- if (activeSev === sev) {
505
- filterSev('all', document.querySelector('.filter-btn[data-sev="all"]'));
506
- } else if (btn) {
507
- filterSev(sev, btn);
508
- }
509
- }
510
-
511
- function searchFindings(term) {
512
- searchTerm = term.toLowerCase();
513
- applyFilters();
514
- }
515
-
516
- function applyFilters() {
517
- const rows = document.querySelectorAll('.finding-row');
518
- let visible = 0;
519
- rows.forEach(row => {
520
- const matchSev = activeSev === 'all' || row.dataset.sev === activeSev;
521
- const matchSearch = !searchTerm || row.dataset.text.includes(searchTerm);
522
- if (matchSev && matchSearch) {
523
- row.classList.remove('hidden-row');
524
- visible++;
525
- } else {
526
- row.classList.add('hidden-row');
527
- }
528
- });
529
- document.getElementById('visible-count').textContent = visible + ' shown';
530
-
531
- // Highlight active stat card
532
- document.querySelectorAll('.stat').forEach(s => s.classList.remove('active'));
533
- if (activeSev !== 'all') {
534
- const el = document.getElementById('stat-' + activeSev);
535
- if (el) el.classList.add('active');
536
- }
537
- }
538
-
539
- // ── Collapsible detail ─────────────────────────────────────────────────────
540
- function toggleDetail(idx) {
541
- const el = document.getElementById('detail-' + idx);
542
- if (el) el.style.display = el.style.display === 'none' ? 'block' : 'none';
543
- }
544
-
545
- // ── Copy ignore annotation ─────────────────────────────────────────────────
546
- function copyIgnore(text, btn) {
547
- navigator.clipboard.writeText('// ' + text).then(() => {
548
- btn.textContent = 'Copied!';
549
- btn.classList.add('copied');
550
- setTimeout(() => { btn.textContent = 'Copy ignore annotation'; btn.classList.remove('copied'); }, 2000);
551
- });
552
- }
553
- </script>
554
- </body>
555
- </html>`;
556
-
557
- fs.writeFileSync(outputPath, html);
558
- return outputPath;
559
- }
560
-
561
- /** Escape HTML entities */
562
- esc(str) {
563
- if (!str) return '';
564
- return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
565
- }
566
- }
567
-
568
- 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 style="text-align:center;margin:1.5rem 0">
158
+ <a href="https://twitter.com/intent/tweet?text=${encodeURIComponent(`My project scored ${scoreResult.score}/100 (Grade ${scoreResult.grade.letter}) on Ship Safe! Scan yours: npx ship-safe audit . https://shipsafecli.com`)}" target="_blank" rel="noopener" style="display:inline-block;padding:8px 18px;background:#000;color:#fff;border-radius:6px;font-size:0.85rem;font-weight:600;text-decoration:none;margin:0 4px">Share on X</a>
159
+ <a href="https://www.linkedin.com/sharing/share-offsite/?url=https://shipsafecli.com" target="_blank" rel="noopener" style="display:inline-block;padding:8px 18px;background:#0a66c2;color:#fff;border-radius:6px;font-size:0.85rem;font-weight:600;text-decoration:none;margin:0 4px">Share on LinkedIn</a>
160
+ </div>
161
+
162
+ <div class="footer">
163
+ Generated by <strong>Ship Safe v6.1</strong> — Security toolkit for developers<br>
164
+ <a href="https://shipsafecli.com" style="color:#38bdf8">shipsafecli.com</a> &middot; <a href="https://shipsafecli.com/pricing" style="color:#38bdf8">Cloud Dashboard</a>
165
+ </div>
166
+ </div>
167
+ </body>
168
+ </html>`;
169
+ }
170
+
171
+ /**
172
+ * Generate and write HTML report to file.
173
+ */
174
+ generateToFile(scoreResult, findings, recon, rootPath, outputPath) {
175
+ const html = this.generate(scoreResult, findings, recon, rootPath);
176
+ fs.writeFileSync(outputPath, html);
177
+ return outputPath;
178
+ }
179
+
180
+ /**
181
+ * Generate a full interactive audit report including deps and remediation plan.
182
+ *
183
+ * Interactive features:
184
+ * - Severity filter toolbar
185
+ * - Category deduction bar chart
186
+ * - Collapsible finding rows with code context
187
+ * - Click-to-copy ship-safe-ignore annotations
188
+ * - Text search across findings
189
+ */
190
+ generateFullReport(scoreResult, findings, depVulns, recon, remediationPlan, rootPath, outputPath) {
191
+ const projectName = path.basename(rootPath);
192
+ const date = new Date().toLocaleDateString('en-US', {
193
+ year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit',
194
+ });
195
+
196
+ const gradeColors = { A: '#22c55e', B: '#06b6d4', C: '#eab308', D: '#ef4444', F: '#dc2626' };
197
+ const sevColors = { critical: '#dc2626', high: '#f97316', medium: '#eab308', low: '#3b82f6' };
198
+
199
+ const bySeverity = { critical: 0, high: 0, medium: 0, low: 0 };
200
+ for (const f of findings) bySeverity[f.severity] = (bySeverity[f.severity] || 0) + 1;
201
+
202
+ // Category chart data
203
+ const catEntries = Object.entries(scoreResult.categories);
204
+ const maxDeduction = Math.max(...catEntries.map(([, c]) => c.deduction), 1);
205
+ const categoryBars = catEntries.map(([key, cat]) => {
206
+ const count = Object.values(cat.counts).reduce((a, b) => a + b, 0);
207
+ const pct = Math.round((cat.deduction / maxDeduction) * 100);
208
+ const color = cat.deduction > 5 ? '#ef4444' : cat.deduction > 0 ? '#f97316' : '#22c55e';
209
+ return `<div class="bar-row">
210
+ <span class="bar-label">${this.esc(cat.label)}</span>
211
+ <div class="bar-track"><div class="bar-fill" style="width:${pct}%;background:${color}"></div></div>
212
+ <span class="bar-value" style="color:${color}">${cat.deduction > 0 ? '-' + Math.round(cat.deduction * 10) / 10 : '0'} pts</span>
213
+ <span class="bar-count">${count} findings</span>
214
+ </div>`;
215
+ }).join('\n');
216
+
217
+ // Finding rows with collapsible detail
218
+ const findingRows = findings.slice(0, 500).map((f, i) => {
219
+ const relFile = path.relative(rootPath, f.file).replace(/\\/g, '/');
220
+ let codeBlock = '';
221
+ if (f.codeContext && f.codeContext.length > 0) {
222
+ const codeLines = f.codeContext.map(c =>
223
+ `<span style="${c.highlight ? 'background:#dc262633;display:block;' : ''}">${String(c.line).padStart(4)} ${this.esc(c.text)}</span>`
224
+ ).join('');
225
+ codeBlock = `<pre class="code-block"><code>${codeLines}</code></pre>`;
226
+ }
227
+ const ignoreAnnotation = `ship-safe-ignore ${f.rule || ''}`.trim();
228
+ 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()}">
229
+ <td><span class="sev sev-${f.severity}">${f.severity.toUpperCase()}</span></td>
230
+ <td><code>${this.esc(relFile)}:${f.line}</code></td>
231
+ <td>
232
+ <strong class="finding-title" onclick="toggleDetail(${i})">${this.esc(f.title || f.rule)}</strong>
233
+ <div id="detail-${i}" class="finding-detail" style="display:none">
234
+ <p>${this.esc((f.description || '').slice(0, 300))}</p>
235
+ ${f.cwe ? `<p class="finding-meta">CWE: ${this.esc(f.cwe)}${f.owasp ? ` | OWASP: ${this.esc(f.owasp)}` : ''}</p>` : ''}
236
+ ${codeBlock}
237
+ ${f.fix ? `<p class="finding-fix">Fix: ${this.esc(f.fix.slice(0, 200))}</p>` : ''}
238
+ <button class="copy-btn" onclick="copyIgnore('${this.esc(ignoreAnnotation)}',this);event.stopPropagation()">Copy ignore annotation</button>
239
+ </div>
240
+ </td>
241
+ <td><code>${this.esc((f.matched || '').slice(0, 60))}</code></td>
242
+ <td>${f.fix ? `<small>${this.esc(f.fix.slice(0, 100))}</small>` : ''}</td>
243
+ </tr>`;
244
+ }).join('\n');
245
+
246
+ // Dep vuln rows
247
+ const depRows = (depVulns || []).slice(0, 100).map(d => {
248
+ const sev = d.severity === 'moderate' ? 'medium' : d.severity;
249
+ return `<tr>
250
+ <td><span class="sev sev-${sev}">${(d.severity || 'unknown').toUpperCase()}</span></td>
251
+ <td><code>${this.esc(d.package || d.id || 'unknown')}</code></td>
252
+ <td>${this.esc((d.description || '').slice(0, 150))}</td>
253
+ </tr>`;
254
+ }).join('\n');
255
+
256
+ // Remediation plan rows
257
+ const sevIcons = { critical: '&#x1F534;', high: '&#x1F7E0;', medium: '&#x1F7E1;', low: '&#x1F535;' };
258
+ let currentSev = null;
259
+ let planHTML = '';
260
+ for (const item of (remediationPlan || []).slice(0, 100)) {
261
+ if (item.severity !== currentSev) {
262
+ currentSev = item.severity;
263
+ const label = { critical: 'CRITICAL — fix immediately', high: 'HIGH — fix before deploy', medium: 'MEDIUM — fix soon', low: 'LOW — review when possible' };
264
+ 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`;
265
+ }
266
+ planHTML += `<tr>
267
+ <td>${item.priority}</td>
268
+ <td><span class="sev sev-${item.severity}">${this.esc(item.categoryLabel)}</span></td>
269
+ <td><strong>${this.esc(item.title)}</strong></td>
270
+ <td><code>${this.esc(item.file)}</code></td>
271
+ <td><small>${this.esc((item.action || '').slice(0, 120))}</small></td>
272
+ </tr>\n`;
273
+ }
274
+
275
+ const html = `<!DOCTYPE html>
276
+ <html lang="en">
277
+ <head>
278
+ <meta charset="utf-8">
279
+ <meta name="viewport" content="width=device-width,initial-scale=1">
280
+ <title>Ship Safe Full Audit Report — ${this.esc(projectName)}</title>
281
+ <style>
282
+ *{margin:0;padding:0;box-sizing:border-box}
283
+ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#0f172a;color:#e2e8f0;padding:2rem}
284
+ .container{max-width:1200px;margin:0 auto}
285
+ h1{font-size:2rem;margin-bottom:0.5rem;color:#38bdf8}
286
+ h2{font-size:1.3rem;margin:2rem 0 1rem;color:#94a3b8;border-bottom:1px solid #1e293b;padding-bottom:0.5rem}
287
+ .meta{color:#64748b;margin-bottom:2rem}
288
+ .score-card{display:flex;align-items:center;gap:2rem;background:#1e293b;padding:2rem;border-radius:12px;margin-bottom:2rem}
289
+ .score-number{font-size:4rem;font-weight:bold}
290
+ .grade{font-size:3rem;font-weight:bold;width:80px;height:80px;display:flex;align-items:center;justify-content:center;border-radius:12px}
291
+ .stats{display:grid;grid-template-columns:repeat(4,1fr);gap:1rem;margin-bottom:2rem}
292
+ .stat{background:#1e293b;padding:1.5rem;border-radius:8px;text-align:center;cursor:pointer;transition:transform .15s,box-shadow .15s}
293
+ .stat:hover{transform:translateY(-2px);box-shadow:0 4px 12px rgba(0,0,0,.3)}
294
+ .stat.active{outline:2px solid #38bdf8}
295
+ .stat-number{font-size:2rem;font-weight:bold}
296
+ .stat-label{color:#64748b;font-size:0.85rem}
297
+ .summary-grid{display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin-bottom:2rem}
298
+ .summary-card{background:#1e293b;padding:1.5rem;border-radius:8px}
299
+ .summary-card h3{color:#38bdf8;font-size:1rem;margin-bottom:0.5rem}
300
+ .summary-card .big{font-size:2.5rem;font-weight:bold}
301
+ /* Bar chart */
302
+ .chart{background:#1e293b;border-radius:8px;padding:1.5rem;margin-bottom:2rem}
303
+ .bar-row{display:flex;align-items:center;gap:0.75rem;padding:0.4rem 0}
304
+ .bar-label{width:160px;font-size:0.8rem;color:#94a3b8;text-align:right;flex-shrink:0}
305
+ .bar-track{flex:1;height:20px;background:#0f172a;border-radius:4px;overflow:hidden}
306
+ .bar-fill{height:100%;border-radius:4px;transition:width .4s ease}
307
+ .bar-value{width:70px;font-size:0.8rem;font-weight:bold;flex-shrink:0}
308
+ .bar-count{width:80px;font-size:0.75rem;color:#64748b;flex-shrink:0}
309
+ /* Filter bar */
310
+ .filter-bar{display:flex;align-items:center;gap:1rem;background:#1e293b;padding:1rem 1.5rem;border-radius:8px;margin-bottom:1rem;flex-wrap:wrap}
311
+ .filter-bar label{font-size:0.8rem;color:#94a3b8}
312
+ .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}
313
+ .filter-btn.active{border-color:#38bdf8;background:#38bdf822}
314
+ .filter-btn:hover{background:#334155}
315
+ .search-input{background:#0f172a;border:1px solid #334155;border-radius:4px;padding:6px 12px;color:#e2e8f0;font-size:0.8rem;width:200px}
316
+ .search-input:focus{outline:none;border-color:#38bdf8}
317
+ .filter-bar .count-label{margin-left:auto;font-size:0.8rem;color:#64748b}
318
+ /* Table */
319
+ table{width:100%;border-collapse:collapse;background:#1e293b;border-radius:8px;overflow:hidden;margin-bottom:2rem}
320
+ th{background:#334155;text-align:left;padding:0.75rem 1rem;font-size:0.8rem;text-transform:uppercase;color:#94a3b8;cursor:pointer;user-select:none}
321
+ th:hover{color:#e2e8f0}
322
+ td{padding:0.75rem 1rem;border-top:1px solid #0f172a;font-size:0.85rem;vertical-align:top}
323
+ tr:hover{background:#334155}
324
+ code{background:#0f172a;padding:2px 6px;border-radius:4px;font-size:0.8rem;color:#38bdf8;word-break:break-all}
325
+ small{color:#94a3b8}
326
+ .sev{padding:2px 8px;border-radius:4px;font-size:0.7rem;font-weight:bold;text-transform:uppercase;white-space:nowrap}
327
+ .sev-critical{background:#dc262633;color:#fca5a5}
328
+ .sev-high{background:#f9731633;color:#fdba74}
329
+ .sev-medium,.sev-moderate{background:#eab30833;color:#fde047}
330
+ .sev-low{background:#3b82f633;color:#93c5fd}
331
+ /* Collapsible findings */
332
+ .finding-title{cursor:pointer;border-bottom:1px dashed #475569}
333
+ .finding-title:hover{color:#38bdf8}
334
+ .finding-detail{margin-top:0.5rem;padding:0.75rem;background:#0f172a;border-radius:6px;border-left:3px solid #38bdf8}
335
+ .finding-detail p{font-size:0.8rem;color:#94a3b8;margin-bottom:0.4rem}
336
+ .finding-meta{font-size:0.75rem;color:#64748b}
337
+ .finding-fix{color:#22c55e;font-size:0.8rem}
338
+ .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}
339
+ .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}
340
+ .copy-btn:hover{background:#475569}
341
+ .copy-btn.copied{background:#22c55e33;color:#22c55e;border-color:#22c55e}
342
+ /* TOC */
343
+ .toc{background:#1e293b;padding:1.5rem 2rem;border-radius:8px;margin-bottom:2rem}
344
+ .toc a{color:#38bdf8;text-decoration:none;display:block;padding:0.3rem 0}
345
+ .toc a:hover{text-decoration:underline}
346
+ .footer{text-align:center;color:#475569;margin-top:3rem;padding:2rem;border-top:1px solid #1e293b}
347
+ .footer a{color:#38bdf8}
348
+ .share-bar{display:flex;justify-content:center;gap:0.75rem;margin:1.5rem 0}
349
+ .share-btn{display:inline-flex;align-items:center;gap:6px;padding:8px 18px;border-radius:6px;font-size:0.85rem;font-weight:600;text-decoration:none;cursor:pointer;border:none;transition:opacity .15s}
350
+ .share-btn:hover{opacity:0.85}
351
+ .share-btn-x{background:#000;color:#fff}
352
+ .share-btn-li{background:#0a66c2;color:#fff}
353
+ .share-btn-copy{background:#334155;color:#38bdf8;border:1px solid #475569}
354
+ .share-btn-copy.copied{background:#22c55e33;color:#22c55e;border-color:#22c55e}
355
+ .badge-section{background:#1e293b;border-radius:8px;padding:1.5rem;margin-top:1.5rem;text-align:center}
356
+ .badge-section img{margin-bottom:0.75rem}
357
+ .badge-section code{display:block;background:#0f172a;padding:8px 12px;border-radius:4px;font-size:0.75rem;margin-top:0.5rem;word-break:break-all;user-select:all}
358
+ /* Hidden row */
359
+ .hidden-row{display:none}
360
+ /* Print */
361
+ @media print{
362
+ body{background:#fff;color:#1e293b}
363
+ table,th,td{border:1px solid #e2e8f0}
364
+ .score-card,.stat,.summary-card,.toc,.chart,.filter-bar{background:#f8fafc}
365
+ .copy-btn,.search-input{display:none}
366
+ .finding-detail{display:block!important}
367
+ }
368
+ </style>
369
+ </head>
370
+ <body>
371
+ <div class="container">
372
+ <h1>Ship Safe — Full Security Audit Report</h1>
373
+ <p class="meta">${this.esc(projectName)} — ${date}</p>
374
+
375
+ <div class="toc">
376
+ <strong>Contents</strong>
377
+ <a href="#score">1. Security Score</a>
378
+ <a href="#summary">2. Executive Summary</a>
379
+ <a href="#categories">3. Category Breakdown</a>
380
+ <a href="#plan">4. Remediation Plan (${(remediationPlan || []).length} items)</a>
381
+ <a href="#findings">5. All Findings (${findings.length})</a>
382
+ <a href="#deps">6. Dependency Vulnerabilities (${(depVulns || []).length})</a>
383
+ <a href="#surface">7. Attack Surface</a>
384
+ </div>
385
+
386
+ <h2 id="score">1. Security Score</h2>
387
+ <div class="score-card">
388
+ <div class="grade" style="background:${gradeColors[scoreResult.grade.letter]}22;color:${gradeColors[scoreResult.grade.letter]}">${scoreResult.grade.letter}</div>
389
+ <div>
390
+ <div class="score-number" style="color:${gradeColors[scoreResult.grade.letter]}">${scoreResult.score}/100</div>
391
+ <div style="color:#94a3b8">${scoreResult.grade.label}</div>
392
+ </div>
393
+ </div>
394
+
395
+ <div class="stats" id="severity-stats">
396
+ <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>
397
+ <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>
398
+ <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>
399
+ <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>
400
+ </div>
401
+
402
+ <h2 id="summary">2. Executive Summary</h2>
403
+ <div class="summary-grid">
404
+ <div class="summary-card">
405
+ <h3>Code Findings</h3>
406
+ <div class="big" style="color:${findings.length > 0 ? '#ef4444' : '#22c55e'}">${findings.length}</div>
407
+ <small>Across ${Object.keys(scoreResult.categories).length} categories</small>
408
+ </div>
409
+ <div class="summary-card">
410
+ <h3>Dependency CVEs</h3>
411
+ <div class="big" style="color:${(depVulns || []).length > 0 ? '#ef4444' : '#22c55e'}">${(depVulns || []).length}</div>
412
+ <small>From npm/pip/bundler audit</small>
413
+ </div>
414
+ </div>
415
+
416
+ <h2 id="categories">3. Category Breakdown</h2>
417
+ <div class="chart">
418
+ ${categoryBars}
419
+ </div>
420
+
421
+ <h2 id="plan">4. Remediation Plan</h2>
422
+ <p style="color:#94a3b8;margin-bottom:1rem">Prioritized list of fixes. Address critical items first.</p>
423
+ ${(remediationPlan || []).length > 0 ? `<table>
424
+ <thead><tr><th>#</th><th>Category</th><th>Issue</th><th>Location</th><th>Fix</th></tr></thead>
425
+ <tbody>${planHTML}</tbody>
426
+ </table>` : '<p style="color:#22c55e;font-weight:bold">No issues found — all clear!</p>'}
427
+
428
+ <h2 id="findings">5. All Findings (${findings.length})</h2>
429
+ <div class="filter-bar">
430
+ <label>Filter:</label>
431
+ <button class="filter-btn active" data-sev="all" onclick="filterSev('all',this)">All</button>
432
+ <button class="filter-btn" data-sev="critical" onclick="filterSev('critical',this)">Critical (${bySeverity.critical})</button>
433
+ <button class="filter-btn" data-sev="high" onclick="filterSev('high',this)">High (${bySeverity.high})</button>
434
+ <button class="filter-btn" data-sev="medium" onclick="filterSev('medium',this)">Medium (${bySeverity.medium})</button>
435
+ <button class="filter-btn" data-sev="low" onclick="filterSev('low',this)">Low (${bySeverity.low})</button>
436
+ <input class="search-input" type="text" placeholder="Search findings..." oninput="searchFindings(this.value)">
437
+ <span class="count-label" id="visible-count">${findings.length} shown</span>
438
+ </div>
439
+ <table id="findings-table">
440
+ <thead><tr><th>Severity</th><th>Location</th><th>Issue</th><th>Code</th><th>Fix</th></tr></thead>
441
+ <tbody>${findingRows || '<tr><td colspan="5" style="text-align:center;color:#22c55e">No findings — clean!</td></tr>'}</tbody>
442
+ </table>
443
+
444
+ <h2 id="deps">6. Dependency Vulnerabilities (${(depVulns || []).length})</h2>
445
+ ${(depVulns || []).length > 0 ? `<table>
446
+ <thead><tr><th>Severity</th><th>Package</th><th>Description</th></tr></thead>
447
+ <tbody>${depRows}</tbody>
448
+ </table>` : '<p style="color:#22c55e;font-weight:bold">No vulnerable dependencies found.</p>'}
449
+
450
+ ${recon ? `<h2 id="surface">7. Attack Surface</h2>
451
+ <table>
452
+ <tbody>
453
+ <tr><td>Frameworks</td><td>${(recon.frameworks || []).join(', ') || 'None detected'}</td></tr>
454
+ <tr><td>Languages</td><td>${(recon.languages || []).join(', ') || 'None detected'}</td></tr>
455
+ <tr><td>Databases</td><td>${(recon.databases || []).join(', ') || 'None detected'}</td></tr>
456
+ <tr><td>Cloud Providers</td><td>${(recon.cloudProviders || []).join(', ') || 'None detected'}</td></tr>
457
+ <tr><td>Auth Patterns</td><td>${(recon.authPatterns || []).join(', ') || 'None detected'}</td></tr>
458
+ <tr><td>CI/CD</td><td>${(recon.cicd || []).map(c => c.platform).join(', ') || 'None detected'}</td></tr>
459
+ <tr><td>API Routes</td><td>${(recon.apiRoutes || []).length} discovered</td></tr>
460
+ </tbody>
461
+ </table>` : ''}
462
+
463
+ <div class="share-bar">
464
+ <a class="share-btn share-btn-x" href="https://twitter.com/intent/tweet?text=${encodeURIComponent(`My project scored ${scoreResult.score}/100 (Grade ${scoreResult.grade.letter}) on Ship Safe security audit! 18 AI agents, 80+ attack classes.\n\nScan yours: npx ship-safe audit .\n\nhttps://shipsafecli.com`)}" target="_blank" rel="noopener">Share on X</a>
465
+ <a class="share-btn share-btn-li" href="https://www.linkedin.com/sharing/share-offsite/?url=https://shipsafecli.com" target="_blank" rel="noopener">Share on LinkedIn</a>
466
+ <button class="share-btn share-btn-copy" onclick="copyShareText(this)">Copy Score Summary</button>
467
+ </div>
468
+
469
+ <div class="badge-section">
470
+ <p style="color:#94a3b8;margin-bottom:0.75rem"><strong>Add a security badge to your README:</strong></p>
471
+ <img src="https://img.shields.io/badge/Ship_Safe-${scoreResult.grade.letter}-${gradeColors[scoreResult.grade.letter].replace('#','')}" alt="Ship Safe Grade ${scoreResult.grade.letter}" />
472
+ <code>[![Ship Safe](https://img.shields.io/badge/Ship_Safe-${scoreResult.grade.letter}-${gradeColors[scoreResult.grade.letter].replace('#','')})](https://shipsafecli.com)</code>
473
+ </div>
474
+
475
+ <div class="footer">
476
+ Generated by <strong>Ship Safe v6.1</strong> — Full Security Audit<br>
477
+ <a href="https://shipsafecli.com">shipsafecli.com</a> &middot; <a href="https://shipsafecli.com/pricing">Cloud Dashboard</a>
478
+ </div>
479
+ </div>
480
+
481
+ <script>
482
+ function copyShareText(btn) {
483
+ const text = 'My project scored ${scoreResult.score}/100 (Grade ${scoreResult.grade.letter}) on Ship Safe security audit!\\nScan yours: npx ship-safe audit .\\nhttps://shipsafecli.com';
484
+ navigator.clipboard.writeText(text).then(() => {
485
+ btn.textContent = 'Copied!';
486
+ btn.classList.add('copied');
487
+ setTimeout(() => { btn.textContent = 'Copy Score Summary'; btn.classList.remove('copied'); }, 2000);
488
+ });
489
+ }
490
+
491
+ // ── Severity filter ────────────────────────────────────────────────────────
492
+ let activeSev = 'all';
493
+ let searchTerm = '';
494
+
495
+ function filterSev(sev, btn) {
496
+ activeSev = sev;
497
+ document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
498
+ btn.classList.add('active');
499
+ applyFilters();
500
+ }
501
+
502
+ function toggleSevFilter(sev) {
503
+ const btn = document.querySelector('.filter-btn[data-sev="' + sev + '"]');
504
+ if (activeSev === sev) {
505
+ filterSev('all', document.querySelector('.filter-btn[data-sev="all"]'));
506
+ } else if (btn) {
507
+ filterSev(sev, btn);
508
+ }
509
+ }
510
+
511
+ function searchFindings(term) {
512
+ searchTerm = term.toLowerCase();
513
+ applyFilters();
514
+ }
515
+
516
+ function applyFilters() {
517
+ const rows = document.querySelectorAll('.finding-row');
518
+ let visible = 0;
519
+ rows.forEach(row => {
520
+ const matchSev = activeSev === 'all' || row.dataset.sev === activeSev;
521
+ const matchSearch = !searchTerm || row.dataset.text.includes(searchTerm);
522
+ if (matchSev && matchSearch) {
523
+ row.classList.remove('hidden-row');
524
+ visible++;
525
+ } else {
526
+ row.classList.add('hidden-row');
527
+ }
528
+ });
529
+ document.getElementById('visible-count').textContent = visible + ' shown';
530
+
531
+ // Highlight active stat card
532
+ document.querySelectorAll('.stat').forEach(s => s.classList.remove('active'));
533
+ if (activeSev !== 'all') {
534
+ const el = document.getElementById('stat-' + activeSev);
535
+ if (el) el.classList.add('active');
536
+ }
537
+ }
538
+
539
+ // ── Collapsible detail ─────────────────────────────────────────────────────
540
+ function toggleDetail(idx) {
541
+ const el = document.getElementById('detail-' + idx);
542
+ if (el) el.style.display = el.style.display === 'none' ? 'block' : 'none';
543
+ }
544
+
545
+ // ── Copy ignore annotation ─────────────────────────────────────────────────
546
+ function copyIgnore(text, btn) {
547
+ navigator.clipboard.writeText('// ' + text).then(() => {
548
+ btn.textContent = 'Copied!';
549
+ btn.classList.add('copied');
550
+ setTimeout(() => { btn.textContent = 'Copy ignore annotation'; btn.classList.remove('copied'); }, 2000);
551
+ });
552
+ }
553
+ </script>
554
+ </body>
555
+ </html>`;
556
+
557
+ fs.writeFileSync(outputPath, html);
558
+ return outputPath;
559
+ }
560
+
561
+ /** Escape HTML entities */
562
+ esc(str) {
563
+ if (!str) return '';
564
+ return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
565
+ }
566
+ }
567
+
568
+ export default HTMLReporter;