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.
- package/README.md +735 -641
- package/cli/agents/api-fuzzer.js +345 -345
- package/cli/agents/auth-bypass-agent.js +348 -348
- package/cli/agents/base-agent.js +272 -272
- package/cli/agents/cicd-scanner.js +236 -201
- package/cli/agents/config-auditor.js +521 -521
- package/cli/agents/deep-analyzer.js +6 -2
- package/cli/agents/git-history-scanner.js +170 -170
- package/cli/agents/html-reporter.js +568 -568
- package/cli/agents/index.js +84 -84
- package/cli/agents/injection-tester.js +500 -500
- package/cli/agents/llm-redteam.js +251 -251
- package/cli/agents/mobile-scanner.js +231 -231
- package/cli/agents/orchestrator.js +322 -322
- package/cli/agents/pii-compliance-agent.js +301 -301
- package/cli/agents/scoring-engine.js +248 -248
- package/cli/agents/supabase-rls-agent.js +154 -154
- package/cli/agents/supply-chain-agent.js +650 -507
- package/cli/bin/ship-safe.js +452 -426
- package/cli/commands/agent.js +608 -608
- package/cli/commands/audit.js +986 -980
- package/cli/commands/baseline.js +193 -193
- package/cli/commands/ci.js +342 -342
- package/cli/commands/deps.js +516 -516
- package/cli/commands/doctor.js +159 -159
- package/cli/commands/fix.js +218 -218
- package/cli/commands/hooks.js +268 -0
- package/cli/commands/init.js +407 -407
- package/cli/commands/mcp.js +304 -304
- package/cli/commands/red-team.js +7 -1
- package/cli/commands/remediate.js +798 -798
- package/cli/commands/rotate.js +571 -571
- package/cli/commands/scan.js +569 -569
- package/cli/commands/score.js +449 -449
- package/cli/commands/watch.js +281 -281
- package/cli/hooks/patterns.js +313 -0
- package/cli/hooks/post-tool-use.js +140 -0
- package/cli/hooks/pre-tool-use.js +186 -0
- package/cli/index.js +73 -69
- package/cli/providers/llm-provider.js +397 -287
- package/cli/utils/autofix-rules.js +74 -74
- package/cli/utils/cache-manager.js +311 -311
- package/cli/utils/output.js +230 -230
- package/cli/utils/patterns.js +1121 -1121
- package/cli/utils/pdf-generator.js +94 -94
- package/package.json +69 -69
- 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> · <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: '🔴', high: '🟠', medium: '🟡', low: '🔵' };
|
|
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>[})](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> · <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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
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> · <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: '🔴', high: '🟠', medium: '🟡', low: '🔵' };
|
|
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>[})](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> · <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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
export default HTMLReporter;
|