@activemind/scd 1.4.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/LICENSE.md +35 -0
- package/README.md +417 -0
- package/bin/scd.js +140 -0
- package/lib/audit-report.js +93 -0
- package/lib/audit-sync.js +172 -0
- package/lib/audit.js +356 -0
- package/lib/cli-helpers.js +108 -0
- package/lib/commands/accept.js +28 -0
- package/lib/commands/audit.js +17 -0
- package/lib/commands/configure.js +200 -0
- package/lib/commands/doctor.js +14 -0
- package/lib/commands/exceptions.js +19 -0
- package/lib/commands/export-findings.js +46 -0
- package/lib/commands/findings.js +306 -0
- package/lib/commands/ignore.js +28 -0
- package/lib/commands/init.js +16 -0
- package/lib/commands/insights.js +24 -0
- package/lib/commands/install.js +15 -0
- package/lib/commands/list.js +109 -0
- package/lib/commands/remove.js +16 -0
- package/lib/commands/repo.js +862 -0
- package/lib/commands/report.js +234 -0
- package/lib/commands/resolve.js +25 -0
- package/lib/commands/rules.js +185 -0
- package/lib/commands/scan.js +519 -0
- package/lib/commands/scope.js +341 -0
- package/lib/commands/sync.js +40 -0
- package/lib/commands/uninstall.js +15 -0
- package/lib/commands/version.js +33 -0
- package/lib/comment-map.js +388 -0
- package/lib/config.js +325 -0
- package/lib/context-modifiers.js +211 -0
- package/lib/deep-analyzer.js +225 -0
- package/lib/doctor.js +236 -0
- package/lib/exception-manager.js +675 -0
- package/lib/export-findings.js +376 -0
- package/lib/file-context.js +380 -0
- package/lib/file-filter.js +204 -0
- package/lib/file-manifest.js +145 -0
- package/lib/git-utils.js +102 -0
- package/lib/global-config.js +239 -0
- package/lib/hooks-manager.js +130 -0
- package/lib/init-repo.js +147 -0
- package/lib/insights-analyzer.js +416 -0
- package/lib/insights-output.js +160 -0
- package/lib/installer.js +128 -0
- package/lib/output-constants.js +32 -0
- package/lib/output-terminal.js +407 -0
- package/lib/push-queue.js +322 -0
- package/lib/remove-repo.js +108 -0
- package/lib/repo-context.js +187 -0
- package/lib/report-html.js +1154 -0
- package/lib/report-index.js +157 -0
- package/lib/report-json.js +136 -0
- package/lib/report-markdown.js +250 -0
- package/lib/resolve-manager.js +148 -0
- package/lib/rule-registry.js +205 -0
- package/lib/scan-cache.js +171 -0
- package/lib/scan-context.js +312 -0
- package/lib/scan-schema.js +67 -0
- package/lib/scanner-full.js +681 -0
- package/lib/scanner-manual.js +348 -0
- package/lib/scanner-secrets.js +83 -0
- package/lib/scope.js +331 -0
- package/lib/store-verify.js +395 -0
- package/lib/store.js +310 -0
- package/lib/taint-register.js +196 -0
- package/lib/version-check.js +46 -0
- package/package.json +37 -0
- package/rules/rule-loader.js +324 -0
- package/rules/rules-aspx-cs.json +399 -0
- package/rules/rules-aspx.json +222 -0
- package/rules/rules-infra-leakage.json +434 -0
- package/rules/rules-js.json +664 -0
- package/rules/rules-php.json +521 -0
- package/rules/rules-python.json +466 -0
- package/rules/rules-secrets.json +99 -0
- package/rules/rules-sensitive-files.json +475 -0
- package/rules/rules-ts.json +76 -0
|
@@ -0,0 +1,1154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* report-html.js
|
|
3
|
+
* Generates a self-contained HTML security report from scan findings.
|
|
4
|
+
* Targeted at two audiences: technical leads (full detail) and management (executive summary).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
'use strict';
|
|
8
|
+
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
|
|
12
|
+
// ── OWASP Top 10 2021 mapping ──────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
const OWASP_CATEGORIES = {
|
|
15
|
+
'Broken Access Control (OWASP A01)': { id: 'A01', name: 'Broken Access Control' },
|
|
16
|
+
'Cryptographic Failures (OWASP A02)': { id: 'A02', name: 'Cryptographic Failures' },
|
|
17
|
+
'Injection (OWASP A03)': { id: 'A03', name: 'Injection' },
|
|
18
|
+
'Insecure Design (OWASP A04)': { id: 'A04', name: 'Insecure Design' },
|
|
19
|
+
'Security Misconfiguration (OWASP A05)': { id: 'A05', name: 'Security Misconfiguration' },
|
|
20
|
+
'Vulnerable and Outdated Components (OWASP A06)': { id: 'A06', name: 'Vulnerable Components' },
|
|
21
|
+
'Identification and Authentication Failures (OWASP A07)': { id: 'A07', name: 'Auth Failures' },
|
|
22
|
+
'Software and Data Integrity Failures (OWASP A08)': { id: 'A08', name: 'Integrity Failures' },
|
|
23
|
+
'Security Logging and Monitoring Failures (OWASP A09)': { id: 'A09', name: 'Logging Failures' },
|
|
24
|
+
'Server-Side Request Forgery (OWASP A10)': { id: 'A10', name: 'SSRF' },
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// ── Risk scoring ───────────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
const SEV_WEIGHT = { CRITICAL: 10, HIGH: 5, MEDIUM: 2, EXPOSURE: 1, INFO: 0 };
|
|
30
|
+
const SEV_COLOR = {
|
|
31
|
+
CRITICAL: '#ef4444',
|
|
32
|
+
HIGH: '#f97316',
|
|
33
|
+
MEDIUM: '#eab308',
|
|
34
|
+
EXPOSURE: '#3b82f6',
|
|
35
|
+
INFO: '#6b7280',
|
|
36
|
+
};
|
|
37
|
+
const SEV_BG = {
|
|
38
|
+
CRITICAL: 'rgba(239,68,68,0.12)',
|
|
39
|
+
HIGH: 'rgba(249,115,22,0.12)',
|
|
40
|
+
MEDIUM: 'rgba(234,179,8,0.12)',
|
|
41
|
+
EXPOSURE: 'rgba(59,130,246,0.12)',
|
|
42
|
+
INFO: 'rgba(107,114,128,0.12)',
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
function computeRiskScore(findings) {
|
|
46
|
+
if (!findings.length) return 0;
|
|
47
|
+
const raw = findings.reduce((sum, f) => sum + (SEV_WEIGHT[f.severity] || 0), 0);
|
|
48
|
+
// Normalize to 0-100, cap at 100
|
|
49
|
+
return Math.min(100, Math.round(raw / Math.max(findings.length, 1) * 5));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function riskLabel(score) {
|
|
53
|
+
if (score >= 80) return { label: 'CRITICAL RISK', color: '#ef4444' };
|
|
54
|
+
if (score >= 55) return { label: 'HIGH RISK', color: '#f97316' };
|
|
55
|
+
if (score >= 30) return { label: 'MEDIUM RISK', color: '#eab308' };
|
|
56
|
+
if (score >= 10) return { label: 'LOW RISK', color: '#22c55e' };
|
|
57
|
+
return { label: 'MINIMAL RISK', color: '#6b7280' };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
function escHtml(str) {
|
|
63
|
+
return String(str || '')
|
|
64
|
+
.replace(/&/g, '&')
|
|
65
|
+
.replace(/</g, '<')
|
|
66
|
+
.replace(/>/g, '>')
|
|
67
|
+
.replace(/"/g, '"');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function groupBy(arr, key) {
|
|
71
|
+
return arr.reduce((acc, item) => {
|
|
72
|
+
const k = item[key] || 'Unknown';
|
|
73
|
+
(acc[k] = acc[k] || []).push(item);
|
|
74
|
+
return acc;
|
|
75
|
+
}, {});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function countBySeverity(findings) {
|
|
79
|
+
const counts = { CRITICAL: 0, HIGH: 0, MEDIUM: 0, EXPOSURE: 0, INFO: 0 };
|
|
80
|
+
findings.forEach(f => { if (counts[f.severity] !== undefined) counts[f.severity]++; });
|
|
81
|
+
return counts;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function getOwaspCoverage(findings) {
|
|
85
|
+
const hit = new Set();
|
|
86
|
+
findings.forEach(f => {
|
|
87
|
+
const match = Object.entries(OWASP_CATEGORIES).find(([k]) => f.category && f.category.includes(k.split('(')[1]?.replace(')', '') || ''));
|
|
88
|
+
if (match) hit.add(match[1].id);
|
|
89
|
+
// Also match by category string directly
|
|
90
|
+
Object.entries(OWASP_CATEGORIES).forEach(([cat, info]) => {
|
|
91
|
+
if (f.category === cat) hit.add(info.id);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
return hit;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function formatDate(d) {
|
|
98
|
+
return d.toLocaleDateString('en-GB', { year: 'numeric', month: 'long', day: 'numeric' });
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function formatTime(d) {
|
|
102
|
+
return d.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' });
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ── Remediation priority list ──────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
function buildRemediationPlan(findings) {
|
|
108
|
+
const groups = groupBy(findings.filter(f => !f.excepted && !f.resolved), 'ruleId');
|
|
109
|
+
return Object.entries(groups)
|
|
110
|
+
.map(([ruleId, items]) => ({
|
|
111
|
+
ruleId,
|
|
112
|
+
name: items[0].name,
|
|
113
|
+
severity: items[0].severity,
|
|
114
|
+
count: items.length,
|
|
115
|
+
category: items[0].category || '',
|
|
116
|
+
fix: items[0].fix || '',
|
|
117
|
+
files: [...new Set(items.map(f => f.filePath))],
|
|
118
|
+
}))
|
|
119
|
+
.sort((a, b) => {
|
|
120
|
+
const sw = { CRITICAL: 0, HIGH: 1, MEDIUM: 2, EXPOSURE: 3, INFO: 4 };
|
|
121
|
+
return (sw[a.severity] ?? 9) - (sw[b.severity] ?? 9) || b.count - a.count;
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ── HTML sections ──────────────────────────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
function renderGauge(score, risk) {
|
|
128
|
+
const angle = (score / 100) * 180;
|
|
129
|
+
const rad = (angle - 90) * Math.PI / 180;
|
|
130
|
+
const nx = 100 + 70 * Math.cos(rad);
|
|
131
|
+
const ny = 100 + 70 * Math.sin(rad);
|
|
132
|
+
|
|
133
|
+
return `
|
|
134
|
+
<div class="gauge-wrap">
|
|
135
|
+
<svg viewBox="0 0 200 110" class="gauge-svg">
|
|
136
|
+
<!-- Track -->
|
|
137
|
+
<path d="M 20 100 A 80 80 0 0 1 180 100" fill="none" stroke="#1e293b" stroke-width="18" stroke-linecap="round"/>
|
|
138
|
+
<!-- Colored arc segments -->
|
|
139
|
+
<path d="M 20 100 A 80 80 0 0 1 60 31" fill="none" stroke="#22c55e" stroke-width="18" stroke-linecap="butt" opacity="0.5"/>
|
|
140
|
+
<path d="M 60 31 A 80 80 0 0 1 100 20" fill="none" stroke="#eab308" stroke-width="18" stroke-linecap="butt" opacity="0.5"/>
|
|
141
|
+
<path d="M 100 20 A 80 80 0 0 1 150 35" fill="none" stroke="#f97316" stroke-width="18" stroke-linecap="butt" opacity="0.5"/>
|
|
142
|
+
<path d="M 150 35 A 80 80 0 0 1 180 100" fill="none" stroke="#ef4444" stroke-width="18" stroke-linecap="round" opacity="0.5"/>
|
|
143
|
+
<!-- Needle -->
|
|
144
|
+
<line x1="100" y1="100" x2="${nx.toFixed(1)}" y2="${ny.toFixed(1)}"
|
|
145
|
+
stroke="${risk.color}" stroke-width="3" stroke-linecap="round"/>
|
|
146
|
+
<circle cx="100" cy="100" r="6" fill="${risk.color}"/>
|
|
147
|
+
<!-- Score -->
|
|
148
|
+
<text x="100" y="88" text-anchor="middle" font-size="22" font-weight="700"
|
|
149
|
+
fill="${risk.color}" font-family="'JetBrains Mono', monospace">${score}</text>
|
|
150
|
+
</svg>
|
|
151
|
+
<div class="gauge-label" style="color:${risk.color}">${risk.label}</div>
|
|
152
|
+
</div>`;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function renderSeverityBadge(sev) {
|
|
156
|
+
return `<span class="badge" style="background:${SEV_BG[sev]};color:${SEV_COLOR[sev]};border-color:${SEV_COLOR[sev]}30">${sev}</span>`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function renderOwaspGrid(hitCategories) {
|
|
160
|
+
return Object.entries(OWASP_CATEGORIES).map(([, info]) => {
|
|
161
|
+
const hit = hitCategories.has(info.id);
|
|
162
|
+
return `
|
|
163
|
+
<div class="owasp-cell ${hit ? 'owasp-hit' : 'owasp-miss'}">
|
|
164
|
+
<span class="owasp-id">${info.id}</span>
|
|
165
|
+
<span class="owasp-name">${info.name}</span>
|
|
166
|
+
${hit ? '<span class="owasp-dot">●</span>' : ''}
|
|
167
|
+
</div>`;
|
|
168
|
+
}).join('');
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function renderRemediationTable(plan) {
|
|
172
|
+
if (!plan.length) return '<p class="muted">No open findings requiring remediation.</p>';
|
|
173
|
+
return `
|
|
174
|
+
<table class="remed-table">
|
|
175
|
+
<thead>
|
|
176
|
+
<tr>
|
|
177
|
+
<th>#</th>
|
|
178
|
+
<th>Priority</th>
|
|
179
|
+
<th>Rule</th>
|
|
180
|
+
<th>Finding</th>
|
|
181
|
+
<th>Occurrences</th>
|
|
182
|
+
<th>OWASP</th>
|
|
183
|
+
<th>Affected Files</th>
|
|
184
|
+
</tr>
|
|
185
|
+
</thead>
|
|
186
|
+
<tbody>
|
|
187
|
+
${plan.slice(0, 20).map((item, i) => `
|
|
188
|
+
<tr>
|
|
189
|
+
<td class="mono muted">${i + 1}</td>
|
|
190
|
+
<td>${renderSeverityBadge(item.severity)}</td>
|
|
191
|
+
<td class="mono rule-id">${escHtml(item.ruleId)}</td>
|
|
192
|
+
<td class="finding-name">${escHtml(item.name)}</td>
|
|
193
|
+
<td class="count-cell">${item.count}</td>
|
|
194
|
+
<td class="mono muted">${escHtml(item.category.match(/A\d+/)?.[0] || '—')}</td>
|
|
195
|
+
<td class="file-list">${item.files.slice(0, 3).map(f => `<code>${escHtml(path.basename(f))}</code>`).join(' ') + (item.files.length > 3 ? ` <span class="muted">+${item.files.length - 3} more</span>` : '')}</td>
|
|
196
|
+
</tr>
|
|
197
|
+
`).join('')}
|
|
198
|
+
</tbody>
|
|
199
|
+
</table>`;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function renderFindingsDetail(findings, repoRoot = '') {
|
|
203
|
+
const byFile = groupBy(findings, 'filePath');
|
|
204
|
+
|
|
205
|
+
return Object.entries(byFile).sort(([a], [b]) => a.localeCompare(b)).map(([filePath, items]) => {
|
|
206
|
+
const bySeverity = groupBy(items, 'severity');
|
|
207
|
+
const severities = ['CRITICAL', 'HIGH', 'MEDIUM', 'EXPOSURE', 'INFO'];
|
|
208
|
+
|
|
209
|
+
// Absolute path for vscode:// links
|
|
210
|
+
const absPath = repoRoot ? path.join(repoRoot, filePath) : filePath;
|
|
211
|
+
const vsodeUrl = `vscode://file/${absPath}`;
|
|
212
|
+
|
|
213
|
+
const findingRows = severities.flatMap(sev =>
|
|
214
|
+
(bySeverity[sev] || []).map(f => {
|
|
215
|
+
const vscodeLine = `vscode://file/${absPath}:${f.line}`;
|
|
216
|
+
const clipData = `${absPath}:${f.line}`;
|
|
217
|
+
return `
|
|
218
|
+
<div class="finding-row" data-sev="${f.severity}">
|
|
219
|
+
<div class="finding-header">
|
|
220
|
+
${renderSeverityBadge(f.severity)}
|
|
221
|
+
<span class="finding-title">${escHtml(f.name)}</span>
|
|
222
|
+
<a class="file-link mono muted finding-meta"
|
|
223
|
+
href="${escHtml(vscodeLine)}"
|
|
224
|
+
data-clip="${escHtml(clipData)}"
|
|
225
|
+
title="Open in VS Code (click) · Copy path (right-click)">Line ${f.line} · ${escHtml(f.ruleId)}</a>
|
|
226
|
+
${f.findingId ? `<span class="badge badge-finding-id mono" title="Finding ID — use with scd accept/ignore">${escHtml(f.findingId)}</span>` : ''}
|
|
227
|
+
${f.excepted ? '<span class="badge badge-excepted">EXCEPTED</span>' : ''}
|
|
228
|
+
${f.resolved ? '<span class="badge badge-resolved">RESOLVED</span>' : ''}
|
|
229
|
+
</div>
|
|
230
|
+
${f.snippet ? `<pre class="code-snippet"><code>${escHtml(f.snippet)}${f.taintSource ? `\n<span class="taint-trace">↳ ${escHtml(f.taintSource.variable)} assigned from ${escHtml(f.taintSource.source)} on line ${f.taintSource.line}</span>` : ''}</code></pre>` : ''}
|
|
231
|
+
<div class="finding-detail">
|
|
232
|
+
<div class="detail-row"><span class="detail-label">Why</span><span>${escHtml(f.why || '')}</span></div>
|
|
233
|
+
<div class="detail-row"><span class="detail-label">Scenario</span><span>${escHtml(f.scenario || '')}</span></div>
|
|
234
|
+
<div class="detail-row"><span class="detail-label">Fix</span><pre class="inline-fix">${escHtml(f.fix || '')}</pre></div>
|
|
235
|
+
</div>
|
|
236
|
+
</div>`;
|
|
237
|
+
})
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
const critCount = (bySeverity['CRITICAL'] || []).length;
|
|
241
|
+
const highCount = (bySeverity['HIGH'] || []).length;
|
|
242
|
+
|
|
243
|
+
return `
|
|
244
|
+
<div class="file-block">
|
|
245
|
+
<div class="file-header">
|
|
246
|
+
<span class="file-icon">📄</span>
|
|
247
|
+
<a class="file-path mono file-link"
|
|
248
|
+
href="${escHtml(vsodeUrl)}"
|
|
249
|
+
data-clip="${escHtml(absPath)}"
|
|
250
|
+
title="Open in VS Code · Right-click to copy path">${escHtml(filePath)}</a>
|
|
251
|
+
<div class="file-counts">
|
|
252
|
+
${critCount ? `<span class="count-chip" style="color:${SEV_COLOR.CRITICAL}">${critCount} CRITICAL</span>` : ''}
|
|
253
|
+
${highCount ? `<span class="count-chip" style="color:${SEV_COLOR.HIGH}">${highCount} HIGH</span>` : ''}
|
|
254
|
+
<span class="count-chip muted">${items.length} total</span>
|
|
255
|
+
</div>
|
|
256
|
+
</div>
|
|
257
|
+
<div class="findings-list">
|
|
258
|
+
${findingRows.join('')}
|
|
259
|
+
</div>
|
|
260
|
+
</div>`;
|
|
261
|
+
}).join('');
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ── CSS ────────────────────────────────────────────────────────────────────
|
|
265
|
+
|
|
266
|
+
function buildCSS() {
|
|
267
|
+
return `
|
|
268
|
+
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&family=Syne:wght@400;600;700;800&display=swap');
|
|
269
|
+
|
|
270
|
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
271
|
+
|
|
272
|
+
:root {
|
|
273
|
+
--bg: #0a0f1a;
|
|
274
|
+
--surface: #0f172a;
|
|
275
|
+
--surface2: #1e293b;
|
|
276
|
+
--border: #1e293b;
|
|
277
|
+
--text: #e2e8f0;
|
|
278
|
+
--muted: #64748b;
|
|
279
|
+
--accent: #38bdf8;
|
|
280
|
+
--crit: #ef4444;
|
|
281
|
+
--high: #f97316;
|
|
282
|
+
--med: #eab308;
|
|
283
|
+
--exp: #3b82f6;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
html { font-size: 15px; }
|
|
287
|
+
|
|
288
|
+
body {
|
|
289
|
+
background: var(--bg);
|
|
290
|
+
color: var(--text);
|
|
291
|
+
font-family: 'Syne', sans-serif;
|
|
292
|
+
line-height: 1.6;
|
|
293
|
+
min-height: 100vh;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/* ── Layout ── */
|
|
297
|
+
.page { max-width: 1200px; margin: 0 auto; padding: 0 2rem 4rem; }
|
|
298
|
+
|
|
299
|
+
/* ── Header ── */
|
|
300
|
+
.report-header {
|
|
301
|
+
border-bottom: 1px solid var(--border);
|
|
302
|
+
padding: 3rem 0 2rem;
|
|
303
|
+
margin-bottom: 3rem;
|
|
304
|
+
display: grid;
|
|
305
|
+
grid-template-columns: 1fr auto;
|
|
306
|
+
align-items: end;
|
|
307
|
+
gap: 2rem;
|
|
308
|
+
}
|
|
309
|
+
.brand { display: flex; align-items: center; gap: 1rem; margin-bottom: 1rem; }
|
|
310
|
+
.brand-icon {
|
|
311
|
+
width: 42px; height: 42px;
|
|
312
|
+
background: var(--accent);
|
|
313
|
+
border-radius: 8px;
|
|
314
|
+
display: flex; align-items: center; justify-content: center;
|
|
315
|
+
font-size: 1.4rem;
|
|
316
|
+
}
|
|
317
|
+
.brand-name {
|
|
318
|
+
font-size: 0.75rem;
|
|
319
|
+
font-weight: 700;
|
|
320
|
+
letter-spacing: 0.2em;
|
|
321
|
+
text-transform: uppercase;
|
|
322
|
+
color: var(--accent);
|
|
323
|
+
}
|
|
324
|
+
.report-title { font-size: 2.2rem; font-weight: 800; line-height: 1.2; }
|
|
325
|
+
.report-target { font-family: 'JetBrains Mono', monospace; color: var(--muted); font-size: 0.85rem; margin-top: 0.5rem; }
|
|
326
|
+
.report-meta { text-align: right; }
|
|
327
|
+
.meta-date { font-size: 0.85rem; color: var(--muted); }
|
|
328
|
+
.meta-date strong { color: var(--text); display: block; font-size: 1rem; }
|
|
329
|
+
|
|
330
|
+
/* ── Section titles ── */
|
|
331
|
+
.section { margin-bottom: 3rem; }
|
|
332
|
+
.section-title {
|
|
333
|
+
font-size: 0.7rem;
|
|
334
|
+
font-weight: 700;
|
|
335
|
+
letter-spacing: 0.25em;
|
|
336
|
+
text-transform: uppercase;
|
|
337
|
+
color: var(--accent);
|
|
338
|
+
margin-bottom: 1.25rem;
|
|
339
|
+
display: flex;
|
|
340
|
+
align-items: center;
|
|
341
|
+
gap: 0.75rem;
|
|
342
|
+
}
|
|
343
|
+
.section-title::after {
|
|
344
|
+
content: '';
|
|
345
|
+
flex: 1;
|
|
346
|
+
height: 1px;
|
|
347
|
+
background: var(--border);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/* ── Executive summary grid ── */
|
|
351
|
+
.exec-grid {
|
|
352
|
+
display: grid;
|
|
353
|
+
grid-template-columns: auto 1fr 1fr;
|
|
354
|
+
gap: 1.5rem;
|
|
355
|
+
align-items: start;
|
|
356
|
+
}
|
|
357
|
+
@media (max-width: 900px) { .exec-grid { grid-template-columns: 1fr; } }
|
|
358
|
+
|
|
359
|
+
/* ── Gauge ── */
|
|
360
|
+
.gauge-wrap { text-align: center; }
|
|
361
|
+
.gauge-svg { width: 180px; }
|
|
362
|
+
.gauge-label { font-weight: 700; font-size: 0.8rem; letter-spacing: 0.1em; text-transform: uppercase; margin-top: -0.5rem; }
|
|
363
|
+
|
|
364
|
+
/* ── Severity breakdown ── */
|
|
365
|
+
.sev-breakdown { display: flex; flex-direction: column; gap: 0.5rem; }
|
|
366
|
+
.sev-row { display: flex; align-items: center; gap: 0.75rem; }
|
|
367
|
+
.sev-bar-wrap { flex: 1; height: 6px; background: var(--surface2); border-radius: 3px; overflow: hidden; }
|
|
368
|
+
.sev-bar { height: 100%; border-radius: 3px; transition: width 1s ease; }
|
|
369
|
+
.sev-label { font-size: 0.75rem; font-weight: 600; letter-spacing: 0.08em; width: 70px; }
|
|
370
|
+
.sev-count { font-family: 'JetBrains Mono', monospace; font-size: 0.85rem; font-weight: 700; width: 30px; text-align: right; }
|
|
371
|
+
|
|
372
|
+
/* ── OWASP grid ── */
|
|
373
|
+
.owasp-grid {
|
|
374
|
+
display: grid;
|
|
375
|
+
grid-template-columns: repeat(5, 1fr);
|
|
376
|
+
gap: 0.5rem;
|
|
377
|
+
}
|
|
378
|
+
@media (max-width: 700px) { .owasp-grid { grid-template-columns: repeat(2, 1fr); } }
|
|
379
|
+
.owasp-cell {
|
|
380
|
+
border: 1px solid var(--border);
|
|
381
|
+
border-radius: 6px;
|
|
382
|
+
padding: 0.6rem 0.75rem;
|
|
383
|
+
position: relative;
|
|
384
|
+
transition: border-color 0.2s;
|
|
385
|
+
}
|
|
386
|
+
.owasp-hit { border-color: var(--crit); background: rgba(239,68,68,0.05); }
|
|
387
|
+
.owasp-id { font-family: 'JetBrains Mono', monospace; font-size: 0.7rem; font-weight: 700; color: var(--muted); display: block; }
|
|
388
|
+
.owasp-hit .owasp-id { color: var(--crit); }
|
|
389
|
+
.owasp-name { font-size: 0.72rem; color: var(--text); display: block; margin-top: 0.15rem; line-height: 1.3; }
|
|
390
|
+
.owasp-dot { position: absolute; top: 0.5rem; right: 0.6rem; color: var(--crit); font-size: 0.6rem; }
|
|
391
|
+
|
|
392
|
+
/* ── Stats cards ── */
|
|
393
|
+
.stats-row { display: flex; gap: 1rem; flex-wrap: wrap; margin-bottom: 2rem; }
|
|
394
|
+
.stat-card {
|
|
395
|
+
background: var(--surface);
|
|
396
|
+
border: 1px solid var(--border);
|
|
397
|
+
border-radius: 8px;
|
|
398
|
+
padding: 1rem 1.5rem;
|
|
399
|
+
min-width: 130px;
|
|
400
|
+
flex: 1;
|
|
401
|
+
}
|
|
402
|
+
.stat-value { font-size: 2rem; font-weight: 800; line-height: 1; font-family: 'JetBrains Mono', monospace; }
|
|
403
|
+
.stat-label { font-size: 0.72rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.1em; margin-top: 0.25rem; }
|
|
404
|
+
|
|
405
|
+
/* ── Remediation table ── */
|
|
406
|
+
.remed-table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
|
|
407
|
+
.remed-table th {
|
|
408
|
+
text-align: left;
|
|
409
|
+
padding: 0.6rem 1rem;
|
|
410
|
+
font-size: 0.65rem;
|
|
411
|
+
letter-spacing: 0.15em;
|
|
412
|
+
text-transform: uppercase;
|
|
413
|
+
color: var(--muted);
|
|
414
|
+
border-bottom: 1px solid var(--border);
|
|
415
|
+
}
|
|
416
|
+
.remed-table td { padding: 0.75rem 1rem; border-bottom: 1px solid var(--border); vertical-align: middle; }
|
|
417
|
+
.remed-table tr:hover td { background: var(--surface); }
|
|
418
|
+
.remed-table tr:last-child td { border-bottom: none; }
|
|
419
|
+
.rule-id { color: var(--accent); }
|
|
420
|
+
.finding-name { font-weight: 600; }
|
|
421
|
+
.count-cell { font-family: 'JetBrains Mono', monospace; font-weight: 700; text-align: center; }
|
|
422
|
+
.file-list code { font-family: 'JetBrains Mono', monospace; font-size: 0.75rem; color: var(--muted); background: var(--surface2); padding: 0.1rem 0.35rem; border-radius: 3px; margin-right: 0.25rem; }
|
|
423
|
+
|
|
424
|
+
/* ── Findings detail ── */
|
|
425
|
+
.file-block {
|
|
426
|
+
background: var(--surface);
|
|
427
|
+
border: 1px solid var(--border);
|
|
428
|
+
border-radius: 8px;
|
|
429
|
+
margin-bottom: 1rem;
|
|
430
|
+
overflow: hidden;
|
|
431
|
+
}
|
|
432
|
+
.file-header {
|
|
433
|
+
display: flex;
|
|
434
|
+
align-items: center;
|
|
435
|
+
gap: 0.75rem;
|
|
436
|
+
padding: 0.85rem 1.25rem;
|
|
437
|
+
background: var(--surface2);
|
|
438
|
+
border-bottom: 1px solid var(--border);
|
|
439
|
+
flex-wrap: wrap;
|
|
440
|
+
}
|
|
441
|
+
.file-icon { font-size: 0.9rem; }
|
|
442
|
+
.file-path { font-size: 0.8rem; color: var(--text); flex: 1; }
|
|
443
|
+
.file-counts { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; }
|
|
444
|
+
.count-chip { font-size: 0.72rem; font-weight: 700; }
|
|
445
|
+
|
|
446
|
+
.findings-list { padding: 0; }
|
|
447
|
+
.finding-row {
|
|
448
|
+
padding: 1rem 1.25rem;
|
|
449
|
+
border-bottom: 1px solid var(--border);
|
|
450
|
+
}
|
|
451
|
+
.finding-row:last-child { border-bottom: none; }
|
|
452
|
+
|
|
453
|
+
.finding-header {
|
|
454
|
+
display: flex;
|
|
455
|
+
align-items: center;
|
|
456
|
+
gap: 0.6rem;
|
|
457
|
+
flex-wrap: wrap;
|
|
458
|
+
margin-bottom: 0.6rem;
|
|
459
|
+
}
|
|
460
|
+
.finding-title { font-weight: 600; font-size: 0.9rem; }
|
|
461
|
+
.finding-meta { font-size: 0.75rem; }
|
|
462
|
+
|
|
463
|
+
.code-snippet {
|
|
464
|
+
background: #020817;
|
|
465
|
+
border: 1px solid var(--border);
|
|
466
|
+
border-radius: 4px;
|
|
467
|
+
padding: 0.6rem 0.9rem;
|
|
468
|
+
font-family: 'JetBrains Mono', monospace;
|
|
469
|
+
font-size: 0.78rem;
|
|
470
|
+
color: #94a3b8;
|
|
471
|
+
overflow-x: auto;
|
|
472
|
+
margin-bottom: 0.75rem;
|
|
473
|
+
.taint-trace {
|
|
474
|
+
display: block;
|
|
475
|
+
color: #475569;
|
|
476
|
+
margin-top: 0.3rem;
|
|
477
|
+
font-style: italic;
|
|
478
|
+
}
|
|
479
|
+
white-space: pre-wrap;
|
|
480
|
+
word-break: break-all;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
.finding-detail { display: flex; flex-direction: column; gap: 0.4rem; }
|
|
484
|
+
.detail-row { display: flex; gap: 0.75rem; font-size: 0.82rem; }
|
|
485
|
+
.detail-label {
|
|
486
|
+
font-size: 0.65rem;
|
|
487
|
+
font-weight: 700;
|
|
488
|
+
letter-spacing: 0.12em;
|
|
489
|
+
text-transform: uppercase;
|
|
490
|
+
color: var(--muted);
|
|
491
|
+
min-width: 65px;
|
|
492
|
+
padding-top: 0.15rem;
|
|
493
|
+
}
|
|
494
|
+
.inline-fix {
|
|
495
|
+
font-family: 'JetBrains Mono', monospace;
|
|
496
|
+
font-size: 0.78rem;
|
|
497
|
+
color: #86efac;
|
|
498
|
+
white-space: pre-wrap;
|
|
499
|
+
word-break: break-all;
|
|
500
|
+
margin: 0;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/* ── Badges ── */
|
|
504
|
+
.badge {
|
|
505
|
+
display: inline-block;
|
|
506
|
+
font-family: 'JetBrains Mono', monospace;
|
|
507
|
+
font-size: 0.65rem;
|
|
508
|
+
font-weight: 700;
|
|
509
|
+
letter-spacing: 0.08em;
|
|
510
|
+
padding: 0.15rem 0.45rem;
|
|
511
|
+
border-radius: 3px;
|
|
512
|
+
border: 1px solid transparent;
|
|
513
|
+
text-transform: uppercase;
|
|
514
|
+
}
|
|
515
|
+
.badge-excepted { background: rgba(100,116,139,0.15); color: #64748b; border-color: #64748b40; }
|
|
516
|
+
.badge-finding-id { background: rgba(99,102,241,0.08); color: #6366f1; border-color: #6366f140; font-size: 0.72rem; cursor: default; user-select: all; }
|
|
517
|
+
.badge-resolved { background: rgba(34,197,94,0.12); color: #22c55e; border-color: #22c55e40; }
|
|
518
|
+
|
|
519
|
+
/* ── Misc ── */
|
|
520
|
+
.mono { font-family: 'JetBrains Mono', monospace; }
|
|
521
|
+
.muted { color: var(--muted); }
|
|
522
|
+
.card {
|
|
523
|
+
background: var(--surface);
|
|
524
|
+
border: 1px solid var(--border);
|
|
525
|
+
border-radius: 8px;
|
|
526
|
+
padding: 1.5rem;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/* ── Navigation tabs ── */
|
|
530
|
+
.tabs { display: flex; gap: 0; border-bottom: 1px solid var(--border); margin-bottom: 2rem; }
|
|
531
|
+
.tab {
|
|
532
|
+
padding: 0.75rem 1.5rem;
|
|
533
|
+
font-size: 0.8rem;
|
|
534
|
+
font-weight: 600;
|
|
535
|
+
letter-spacing: 0.05em;
|
|
536
|
+
color: var(--muted);
|
|
537
|
+
cursor: pointer;
|
|
538
|
+
border-bottom: 2px solid transparent;
|
|
539
|
+
transition: all 0.15s;
|
|
540
|
+
user-select: none;
|
|
541
|
+
}
|
|
542
|
+
.tab:hover { color: var(--text); }
|
|
543
|
+
.tab.active { color: var(--accent); border-bottom-color: var(--accent); }
|
|
544
|
+
.tab-panel { display: none; }
|
|
545
|
+
.tab-panel.active { display: block; }
|
|
546
|
+
.tab-badge { display: inline-block; background: rgba(56,189,248,0.2); color: var(--accent);
|
|
547
|
+
border-radius: 10px; font-size: 0.7rem; font-weight: 700;
|
|
548
|
+
padding: 0.05rem 0.45rem; margin-left: 0.4rem; vertical-align: middle; }
|
|
549
|
+
.tab-deep { color: var(--accent) !important; }
|
|
550
|
+
.deep-file { background: var(--surface); border: 1px solid var(--border); border-radius: 8px;
|
|
551
|
+
padding: 1.5rem; margin-bottom: 1.5rem; }
|
|
552
|
+
.deep-file-header { font-family: 'JetBrains Mono', monospace; font-size: 0.82rem;
|
|
553
|
+
color: var(--accent); margin-bottom: 1rem; display: flex;
|
|
554
|
+
align-items: center; gap: 0.75rem; }
|
|
555
|
+
.deep-finding { border-left: 3px solid var(--border); padding: 1rem 1.25rem;
|
|
556
|
+
margin-bottom: 1rem; background: var(--surface2); border-radius: 0 6px 6px 0; }
|
|
557
|
+
.deep-finding.sev-critical { border-left-color: var(--crit); }
|
|
558
|
+
.deep-finding.sev-high { border-left-color: var(--high); }
|
|
559
|
+
.deep-finding-title { font-weight: 700; font-size: 0.9rem; margin-bottom: 0.5rem; }
|
|
560
|
+
.deep-section { margin-top: 0.75rem; }
|
|
561
|
+
.deep-section-label { font-size: 0.7rem; font-weight: 700; letter-spacing: 0.1em;
|
|
562
|
+
text-transform: uppercase; color: var(--muted); margin-bottom: 0.3rem; }
|
|
563
|
+
.deep-section-body { font-size: 0.85rem; color: var(--text); line-height: 1.6; }
|
|
564
|
+
.deep-code { background: var(--bg); border: 1px solid var(--border); border-radius: 6px;
|
|
565
|
+
padding: 0.75rem 1rem; font-family: 'JetBrains Mono', monospace;
|
|
566
|
+
font-size: 0.78rem; overflow-x: auto; white-space: pre; margin-top: 0.4rem; }
|
|
567
|
+
.deep-empty { color: var(--muted); padding: 3rem 0; text-align: center; }
|
|
568
|
+
.deep-item { background: var(--surface); border: 1px solid var(--border); border-radius: 10px;
|
|
569
|
+
padding: 0; margin-bottom: 1.5rem; overflow: hidden; }
|
|
570
|
+
.deep-item-header { display: flex; align-items: center; gap: 0.75rem; padding: 0.75rem 1.25rem;
|
|
571
|
+
background: var(--surface2); border-bottom: 1px solid var(--border); }
|
|
572
|
+
.deep-item-sev { font-size: 0.72rem; font-weight: 800; letter-spacing: 0.08em;
|
|
573
|
+
text-transform: uppercase; min-width: 70px; }
|
|
574
|
+
.deep-item-file { font-family: 'JetBrains Mono', monospace; font-size: 0.78rem;
|
|
575
|
+
color: var(--accent); flex: 1; overflow: hidden;
|
|
576
|
+
text-overflow: ellipsis; white-space: nowrap; }
|
|
577
|
+
.deep-orig-box { padding: 1rem 1.25rem; border-bottom: 1px solid var(--border);
|
|
578
|
+
background: rgba(30,41,59,0.4); }
|
|
579
|
+
.deep-orig-header { display: flex; align-items: center; gap: 0.75rem; margin-bottom: 0.5rem; }
|
|
580
|
+
.deep-orig-label { font-size: 0.65rem; font-weight: 700; letter-spacing: 0.1em;
|
|
581
|
+
text-transform: uppercase; color: var(--muted);
|
|
582
|
+
background: var(--surface2); border: 1px solid var(--border);
|
|
583
|
+
border-radius: 4px; padding: 0.15rem 0.5rem; }
|
|
584
|
+
.deep-orig-rule { font-family: 'JetBrains Mono', monospace; font-size: 0.78rem;
|
|
585
|
+
color: var(--muted); }
|
|
586
|
+
.deep-orig-name { font-weight: 700; font-size: 0.9rem; color: var(--text); margin-bottom: 0.25rem; }
|
|
587
|
+
.deep-orig-section { margin-top: 0.75rem; }
|
|
588
|
+
.deep-claude-box { padding: 1rem 1.25rem; }
|
|
589
|
+
.deep-claude-label { font-size: 0.65rem; font-weight: 700; letter-spacing: 0.1em;
|
|
590
|
+
text-transform: uppercase; color: var(--accent);
|
|
591
|
+
margin-bottom: 0.75rem; }
|
|
592
|
+
.deep-confidence { font-size: 0.72rem; font-weight: 600; letter-spacing: 0.06em;
|
|
593
|
+
text-transform: uppercase; color: var(--muted);
|
|
594
|
+
background: var(--surface2); border: 1px solid var(--border);
|
|
595
|
+
border-radius: 4px; padding: 0.1rem 0.45rem; margin-left: 0.5rem; }
|
|
596
|
+
.deep-filter-btn.active { opacity: 1 !important; font-weight: 700; }
|
|
597
|
+
.deep-item.hidden { display: none; }
|
|
598
|
+
|
|
599
|
+
/* ── Filters ── */
|
|
600
|
+
.filter-bar { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-bottom: 1.5rem; }
|
|
601
|
+
.filter-btn {
|
|
602
|
+
font-family: 'JetBrains Mono', monospace;
|
|
603
|
+
font-size: 0.7rem;
|
|
604
|
+
font-weight: 700;
|
|
605
|
+
padding: 0.3rem 0.75rem;
|
|
606
|
+
border-radius: 4px;
|
|
607
|
+
border: 1px solid var(--border);
|
|
608
|
+
background: var(--surface2);
|
|
609
|
+
color: var(--muted);
|
|
610
|
+
cursor: pointer;
|
|
611
|
+
transition: all 0.15s;
|
|
612
|
+
}
|
|
613
|
+
.filter-btn:hover, .filter-btn.active { border-color: var(--accent); color: var(--accent); background: rgba(56,189,248,0.08); }
|
|
614
|
+
|
|
615
|
+
/* ── File links ── */
|
|
616
|
+
.file-link {
|
|
617
|
+
color: inherit;
|
|
618
|
+
text-decoration: none;
|
|
619
|
+
cursor: pointer;
|
|
620
|
+
position: relative;
|
|
621
|
+
transition: color 0.15s;
|
|
622
|
+
}
|
|
623
|
+
.file-link:hover { color: var(--accent); text-decoration: underline; text-underline-offset: 3px; }
|
|
624
|
+
.file-path.file-link:hover { color: var(--accent); }
|
|
625
|
+
.finding-meta.file-link:hover { color: var(--accent); }
|
|
626
|
+
|
|
627
|
+
/* Clipboard toast */
|
|
628
|
+
#clip-toast {
|
|
629
|
+
position: fixed;
|
|
630
|
+
bottom: 1.5rem;
|
|
631
|
+
right: 1.5rem;
|
|
632
|
+
background: var(--surface2);
|
|
633
|
+
border: 1px solid var(--accent);
|
|
634
|
+
color: var(--accent);
|
|
635
|
+
font-family: 'JetBrains Mono', monospace;
|
|
636
|
+
font-size: 0.75rem;
|
|
637
|
+
padding: 0.5rem 1rem;
|
|
638
|
+
border-radius: 6px;
|
|
639
|
+
opacity: 0;
|
|
640
|
+
transform: translateY(8px);
|
|
641
|
+
transition: opacity 0.2s, transform 0.2s;
|
|
642
|
+
pointer-events: none;
|
|
643
|
+
z-index: 999;
|
|
644
|
+
max-width: 400px;
|
|
645
|
+
word-break: break-all;
|
|
646
|
+
}
|
|
647
|
+
#clip-toast.show { opacity: 1; transform: translateY(0); }
|
|
648
|
+
|
|
649
|
+
/* ── Footer ── */
|
|
650
|
+
.report-footer {
|
|
651
|
+
margin-top: 4rem;
|
|
652
|
+
padding-top: 1.5rem;
|
|
653
|
+
border-top: 1px solid var(--border);
|
|
654
|
+
display: flex;
|
|
655
|
+
justify-content: space-between;
|
|
656
|
+
align-items: center;
|
|
657
|
+
font-size: 0.75rem;
|
|
658
|
+
color: var(--muted);
|
|
659
|
+
}
|
|
660
|
+
`;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// ── JS (interactive) ───────────────────────────────────────────────────────
|
|
664
|
+
|
|
665
|
+
function buildJS() {
|
|
666
|
+
return `
|
|
667
|
+
// Tab switching
|
|
668
|
+
document.querySelectorAll('.tab').forEach(tab => {
|
|
669
|
+
tab.addEventListener('click', () => {
|
|
670
|
+
const panel = tab.dataset.tab;
|
|
671
|
+
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
|
672
|
+
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
|
|
673
|
+
tab.classList.add('active');
|
|
674
|
+
document.getElementById(panel).classList.add('active');
|
|
675
|
+
});
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
// Severity filter
|
|
679
|
+
document.querySelectorAll('.filter-btn[data-sev]').forEach(btn => {
|
|
680
|
+
btn.addEventListener('click', () => {
|
|
681
|
+
btn.classList.toggle('active');
|
|
682
|
+
const activeSevs = [...document.querySelectorAll('.filter-btn[data-sev].active')].map(b => b.dataset.sev);
|
|
683
|
+
document.querySelectorAll('.finding-row').forEach(row => {
|
|
684
|
+
if (activeSevs.length === 0 || activeSevs.includes(row.dataset.sev)) {
|
|
685
|
+
row.style.display = '';
|
|
686
|
+
} else {
|
|
687
|
+
row.style.display = 'none';
|
|
688
|
+
}
|
|
689
|
+
});
|
|
690
|
+
document.querySelectorAll('.file-block').forEach(block => {
|
|
691
|
+
const visible = [...block.querySelectorAll('.finding-row')].some(r => r.style.display !== 'none');
|
|
692
|
+
block.style.display = visible ? '' : 'none';
|
|
693
|
+
});
|
|
694
|
+
});
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
// ── Deep analysis sort & filter ─────────────────────────────────────────
|
|
698
|
+
const SEV_SORT = { CRITICAL: 0, HIGH: 1, MEDIUM: 2, EXPOSURE: 3 };
|
|
699
|
+
|
|
700
|
+
function deepSortItems(sortKey) {
|
|
701
|
+
const container = document.getElementById('deep-items-container');
|
|
702
|
+
if (!container) return;
|
|
703
|
+
const items = [...container.querySelectorAll('.deep-item')];
|
|
704
|
+
items.sort((a, b) => {
|
|
705
|
+
const aFP = a.dataset.fp === '1' ? 1 : 0;
|
|
706
|
+
const bFP = b.dataset.fp === '1' ? 1 : 0;
|
|
707
|
+
if (aFP !== bFP) return aFP - bFP; // confirmed first, FP last always
|
|
708
|
+
if (sortKey === 'file') {
|
|
709
|
+
const fa = a.querySelector('.deep-item-file')?.textContent || '';
|
|
710
|
+
const fb = b.querySelector('.deep-item-file')?.textContent || '';
|
|
711
|
+
return fa.localeCompare(fb);
|
|
712
|
+
}
|
|
713
|
+
// severity sort (default)
|
|
714
|
+
const sa = SEV_SORT[a.dataset.sev] ?? 9;
|
|
715
|
+
const sb = SEV_SORT[b.dataset.sev] ?? 9;
|
|
716
|
+
return sa - sb;
|
|
717
|
+
});
|
|
718
|
+
items.forEach(item => container.appendChild(item));
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
document.querySelectorAll('.deep-sort-btn').forEach(btn => {
|
|
722
|
+
btn.addEventListener('click', () => {
|
|
723
|
+
document.querySelectorAll('.deep-sort-btn').forEach(b => b.classList.remove('active'));
|
|
724
|
+
btn.classList.add('active');
|
|
725
|
+
deepSortItems(btn.dataset.deepSort);
|
|
726
|
+
});
|
|
727
|
+
});
|
|
728
|
+
|
|
729
|
+
// ── Deep analysis filter ─────────────────────────────────────────────────
|
|
730
|
+
document.querySelectorAll('.deep-filter-btn').forEach(btn => {
|
|
731
|
+
btn.addEventListener('click', () => {
|
|
732
|
+
const sev = btn.dataset.deepSev;
|
|
733
|
+
|
|
734
|
+
// Toggle active state – but 'ALL' is exclusive
|
|
735
|
+
if (sev === 'ALL') {
|
|
736
|
+
document.querySelectorAll('.deep-filter-btn').forEach(b => b.classList.remove('active'));
|
|
737
|
+
btn.classList.add('active');
|
|
738
|
+
} else {
|
|
739
|
+
document.querySelector('.deep-filter-btn[data-deep-sev="ALL"]').classList.remove('active');
|
|
740
|
+
btn.classList.toggle('active');
|
|
741
|
+
// If nothing active, reset to ALL
|
|
742
|
+
const anyActive = [...document.querySelectorAll('.deep-filter-btn:not([data-deep-sev="ALL"])')].some(b => b.classList.contains('active'));
|
|
743
|
+
if (!anyActive) {
|
|
744
|
+
document.querySelector('.deep-filter-btn[data-deep-sev="ALL"]').classList.add('active');
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
// Collect active filters
|
|
749
|
+
const active = [...document.querySelectorAll('.deep-filter-btn.active')].map(b => b.dataset.deepSev);
|
|
750
|
+
const showAll = active.includes('ALL');
|
|
751
|
+
|
|
752
|
+
let visible = 0;
|
|
753
|
+
document.querySelectorAll('.deep-item').forEach(item => {
|
|
754
|
+
const itemSev = item.dataset.sev;
|
|
755
|
+
const itemFP = item.dataset.fp === '1';
|
|
756
|
+
const match = showAll
|
|
757
|
+
|| active.includes(itemSev)
|
|
758
|
+
|| (active.includes('FP') && itemFP);
|
|
759
|
+
item.classList.toggle('hidden', !match);
|
|
760
|
+
if (match) visible++;
|
|
761
|
+
});
|
|
762
|
+
|
|
763
|
+
const emptyMsg = document.getElementById('deep-empty-msg');
|
|
764
|
+
if (emptyMsg) emptyMsg.style.display = visible === 0 ? '' : 'none';
|
|
765
|
+
});
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
// ── Clipboard toast ──────────────────────────────────────────────────────
|
|
769
|
+
const toast = document.createElement('div');
|
|
770
|
+
toast.id = 'clip-toast';
|
|
771
|
+
document.body.appendChild(toast);
|
|
772
|
+
|
|
773
|
+
let toastTimer;
|
|
774
|
+
function showToast(msg) {
|
|
775
|
+
toast.textContent = msg;
|
|
776
|
+
toast.classList.add('show');
|
|
777
|
+
clearTimeout(toastTimer);
|
|
778
|
+
toastTimer = setTimeout(() => toast.classList.remove('show'), 2200);
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
function copyToClip(text) {
|
|
782
|
+
if (navigator.clipboard && window.isSecureContext) {
|
|
783
|
+
navigator.clipboard.writeText(text).then(() => showToast('📋 Copied: ' + text));
|
|
784
|
+
} else {
|
|
785
|
+
// Fallback for file:// context
|
|
786
|
+
const ta = document.createElement('textarea');
|
|
787
|
+
ta.value = text;
|
|
788
|
+
ta.style.position = 'fixed';
|
|
789
|
+
ta.style.opacity = '0';
|
|
790
|
+
document.body.appendChild(ta);
|
|
791
|
+
ta.select();
|
|
792
|
+
try { document.execCommand('copy'); showToast('📋 Copied: ' + text); } catch(e) {}
|
|
793
|
+
document.body.removeChild(ta);
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
// ── File links: vscode:// primary, clipboard on right-click ─────────────
|
|
798
|
+
document.querySelectorAll('.file-link').forEach(link => {
|
|
799
|
+
const clip = link.dataset.clip;
|
|
800
|
+
if (!clip) return;
|
|
801
|
+
|
|
802
|
+
// Left click: attempt vscode://, show toast that it was triggered
|
|
803
|
+
link.addEventListener('click', (e) => {
|
|
804
|
+
// vscode:// href handles the navigation; we just show feedback
|
|
805
|
+
setTimeout(() => showToast('↗ Opening in VS Code…'), 80);
|
|
806
|
+
// If vscode:// fails (no VS Code), browser does nothing – clipboard as fallback
|
|
807
|
+
});
|
|
808
|
+
|
|
809
|
+
// Right-click: always copy path to clipboard
|
|
810
|
+
link.addEventListener('contextmenu', (e) => {
|
|
811
|
+
e.preventDefault();
|
|
812
|
+
copyToClip(clip);
|
|
813
|
+
});
|
|
814
|
+
});
|
|
815
|
+
`;
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
// ── Main render ────────────────────────────────────────────────────────────
|
|
819
|
+
|
|
820
|
+
// ── Deep Analysis tab renderer ────────────────────────────────────────────
|
|
821
|
+
|
|
822
|
+
function renderDeepTab(deepResults, allFindings) {
|
|
823
|
+
if (!deepResults || deepResults.size === 0) return '';
|
|
824
|
+
|
|
825
|
+
const SEV_ORDER = { CRITICAL: 0, HIGH: 1, MEDIUM: 2, EXPOSURE: 3 };
|
|
826
|
+
const SEV_COLOR = { CRITICAL: '#ef4444', HIGH: '#f97316', MEDIUM: '#eab308', EXPOSURE: '#3b82f6' };
|
|
827
|
+
|
|
828
|
+
// Build a flat list of all deep items enriched with original finding data
|
|
829
|
+
const items = [];
|
|
830
|
+
for (const [filePath, analyses] of deepResults.entries()) {
|
|
831
|
+
if (!analyses || analyses.length === 0) continue;
|
|
832
|
+
for (const a of analyses) {
|
|
833
|
+
// Find matching original finding for context
|
|
834
|
+
const orig = allFindings
|
|
835
|
+
? allFindings.find(f => f.ruleId === a.ruleId && f.line === a.line && f.filePath === filePath)
|
|
836
|
+
: null;
|
|
837
|
+
items.push({ ...a, filePath, orig });
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
if (items.length === 0) {
|
|
842
|
+
return '<div id="tab-deep" class="tab-panel"><div class="deep-empty">No deep analysis results available.</div></div>';
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
// Collect severities present for filter buttons
|
|
846
|
+
const sevPresent = [...new Set(items.map(i => i.severity).filter(Boolean))];
|
|
847
|
+
sevPresent.sort((a, b) => (SEV_ORDER[a] ?? 9) - (SEV_ORDER[b] ?? 9));
|
|
848
|
+
const confirmedCount = items.filter(i => i.confirmed !== false).length;
|
|
849
|
+
const fpCount = items.filter(i => i.confirmed === false).length;
|
|
850
|
+
|
|
851
|
+
// Render a single deep item
|
|
852
|
+
function renderItem(a) {
|
|
853
|
+
const sev = a.severity || '';
|
|
854
|
+
const sevCls = 'sev-' + sev.toLowerCase();
|
|
855
|
+
const sevCol = SEV_COLOR[sev] || '#64748b';
|
|
856
|
+
const isFP = a.confirmed === false;
|
|
857
|
+
|
|
858
|
+
// ── Original finding box ───────────────────────────────────────────
|
|
859
|
+
let origBox = '';
|
|
860
|
+
if (a.orig) {
|
|
861
|
+
const o = a.orig;
|
|
862
|
+
origBox = [
|
|
863
|
+
'<div class="deep-orig-box">',
|
|
864
|
+
' <div class="deep-orig-header">',
|
|
865
|
+
' <span class="deep-orig-label">Original finding</span>',
|
|
866
|
+
' <span class="deep-orig-rule">' + escHtml(o.ruleId || '') + '</span>',
|
|
867
|
+
' <span class="muted" style="font-size:0.75rem">' + escHtml(o.category || '') + '</span>',
|
|
868
|
+
' </div>',
|
|
869
|
+
' <div class="deep-orig-name">' + escHtml(o.name || '') + '</div>',
|
|
870
|
+
o.snippet ? ' <div class="deep-code" style="margin-top:0.5rem">' + escHtml(o.snippet) + '</div>' : '',
|
|
871
|
+
o.why ? [
|
|
872
|
+
' <div class="deep-orig-section">',
|
|
873
|
+
' <span class="deep-section-label">Why this matters</span>',
|
|
874
|
+
' <div class="deep-section-body">' + escHtml(o.why) + '</div>',
|
|
875
|
+
' </div>',
|
|
876
|
+
].join('') : '',
|
|
877
|
+
'</div>',
|
|
878
|
+
].join('');
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
// ── Claude analysis ────────────────────────────────────────────────
|
|
882
|
+
const confirmedHtml = isFP
|
|
883
|
+
? '<span style="color:#22c55e;font-weight:700">✓ False positive</span>'
|
|
884
|
+
: '<span style="color:#ef4444;font-weight:700">⚠ Confirmed vulnerability</span>';
|
|
885
|
+
|
|
886
|
+
const confidenceHtml = a.confidence
|
|
887
|
+
? ' <span class="deep-confidence">' + escHtml(a.confidence) + '</span>'
|
|
888
|
+
: '';
|
|
889
|
+
|
|
890
|
+
const fpReasonHtml = isFP && a.false_positive_reason
|
|
891
|
+
? '<div class="deep-section"><div class="deep-section-label">Reason</div>' +
|
|
892
|
+
'<div class="deep-section-body">' + escHtml(a.false_positive_reason) + '</div></div>'
|
|
893
|
+
: '';
|
|
894
|
+
|
|
895
|
+
const scenarioHtml = !isFP && a.attack_scenario
|
|
896
|
+
? '<div class="deep-section"><div class="deep-section-label">Attack Scenario</div>' +
|
|
897
|
+
'<div class="deep-section-body">' + escHtml(a.attack_scenario) + '</div></div>'
|
|
898
|
+
: '';
|
|
899
|
+
|
|
900
|
+
const fixHtml = !isFP && a.fix_explanation
|
|
901
|
+
? '<div class="deep-section"><div class="deep-section-label">Recommended Fix</div>' +
|
|
902
|
+
'<div class="deep-section-body">' + escHtml(a.fix_explanation) + '</div></div>'
|
|
903
|
+
: '';
|
|
904
|
+
|
|
905
|
+
const codeHtml = !isFP && a.fix_code
|
|
906
|
+
? '<div class="deep-section"><div class="deep-section-label">Fix Example</div>' +
|
|
907
|
+
'<div class="deep-code">' + escHtml(a.fix_code) + '</div></div>'
|
|
908
|
+
: '';
|
|
909
|
+
|
|
910
|
+
return [
|
|
911
|
+
'<div class="deep-item ' + sevCls + '" data-sev="' + sev + '" data-fp="' + (isFP ? '1' : '0') + '">',
|
|
912
|
+
' <div class="deep-item-header">',
|
|
913
|
+
' <span class="deep-item-sev" style="color:' + sevCol + '">' + escHtml(sev) + '</span>',
|
|
914
|
+
' <span class="deep-item-file">' + escHtml(a.filePath) + '</span>',
|
|
915
|
+
(a.line ? ' <span class="muted" style="font-size:0.75rem">line ' + a.line + '</span>' : ''),
|
|
916
|
+
' </div>',
|
|
917
|
+
origBox,
|
|
918
|
+
' <div class="deep-claude-box">',
|
|
919
|
+
' <div class="deep-claude-label">Claude analysis</div>',
|
|
920
|
+
' <div style="margin-bottom:0.5rem">' + confirmedHtml + confidenceHtml + '</div>',
|
|
921
|
+
fpReasonHtml,
|
|
922
|
+
scenarioHtml,
|
|
923
|
+
fixHtml,
|
|
924
|
+
codeHtml,
|
|
925
|
+
' </div>',
|
|
926
|
+
'</div>',
|
|
927
|
+
].join('');
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
// Pre-sort by severity
|
|
931
|
+
items.sort((a, b) => {
|
|
932
|
+
const sd = (SEV_ORDER[a.severity] ?? 9) - (SEV_ORDER[b.severity] ?? 9);
|
|
933
|
+
if (sd !== 0) return sd;
|
|
934
|
+
return (a.filePath || '').localeCompare(b.filePath || '');
|
|
935
|
+
});
|
|
936
|
+
|
|
937
|
+
const itemsHtml = items.map(renderItem).join('');
|
|
938
|
+
|
|
939
|
+
// Filter buttons
|
|
940
|
+
const filterBtns = sevPresent.map(sev =>
|
|
941
|
+
'<button class="filter-btn deep-filter-btn" data-deep-sev="' + sev + '" style="border-color:' +
|
|
942
|
+
(SEV_COLOR[sev] || '#64748b') + '40;color:' + (SEV_COLOR[sev] || '#64748b') + '">' +
|
|
943
|
+
sev + ' (' + items.filter(i => i.severity === sev).length + ')</button>'
|
|
944
|
+
).join('');
|
|
945
|
+
|
|
946
|
+
const fpBtn = fpCount > 0
|
|
947
|
+
? '<button class="filter-btn deep-filter-btn" data-deep-sev="FP" style="border-color:#22c55e40;color:#22c55e">False positive (' + fpCount + ')</button>'
|
|
948
|
+
: '';
|
|
949
|
+
|
|
950
|
+
return [
|
|
951
|
+
'<div id="tab-deep" class="tab-panel">',
|
|
952
|
+
' <div class="section">',
|
|
953
|
+
' <div class="section-title">Claude AI Deep Analysis</div>',
|
|
954
|
+
' <p class="muted" style="margin-bottom:1.5rem;font-size:0.85rem">',
|
|
955
|
+
' AI-powered analysis of CRITICAL and HIGH findings — ' + confirmedCount + ' confirmed, ' + fpCount + ' false positive' + (fpCount !== 1 ? 's' : '') + '.',
|
|
956
|
+
' Each confirmed finding shows the original detection context alongside Claude\'s assessment.',
|
|
957
|
+
' </p>',
|
|
958
|
+
' <div class="filter-bar" style="margin-bottom:1.5rem;flex-wrap:wrap">',
|
|
959
|
+
' <span class="muted" style="font-size:0.75rem;padding:0.3rem 0;align-self:center">Filter:</span>',
|
|
960
|
+
' <button class="filter-btn deep-filter-btn active" data-deep-sev="ALL" style="border-color:#38bdf840;color:#38bdf8">All (' + items.length + ')</button>',
|
|
961
|
+
filterBtns,
|
|
962
|
+
fpBtn,
|
|
963
|
+
' <span style="flex:1;min-width:1rem"></span>',
|
|
964
|
+
' <span class="muted" style="font-size:0.75rem;padding:0.3rem 0;align-self:center">Sort:</span>',
|
|
965
|
+
' <button class="filter-btn deep-sort-btn active" data-deep-sort="severity">Severity</button>',
|
|
966
|
+
' <button class="filter-btn deep-sort-btn" data-deep-sort="file">File name</button>',
|
|
967
|
+
' </div>',
|
|
968
|
+
' <div id="deep-items-container">',
|
|
969
|
+
itemsHtml,
|
|
970
|
+
' </div>',
|
|
971
|
+
' <div id="deep-empty-msg" style="display:none" class="deep-empty">No findings match the current filter.</div>',
|
|
972
|
+
' </div>',
|
|
973
|
+
'</div>',
|
|
974
|
+
].join('');
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
function generateReport(findings, opts = {}) {
|
|
978
|
+
const {
|
|
979
|
+
target = '.',
|
|
980
|
+
repoName = null,
|
|
981
|
+
scanDate = new Date(),
|
|
982
|
+
totalFiles = 0,
|
|
983
|
+
skipped = [],
|
|
984
|
+
repoRoot = process.cwd(),
|
|
985
|
+
deepResults = null,
|
|
986
|
+
} = opts;
|
|
987
|
+
|
|
988
|
+
const displayName = repoName || path.basename(path.resolve(repoRoot));
|
|
989
|
+
|
|
990
|
+
const counts = countBySeverity(findings);
|
|
991
|
+
const score = computeRiskScore(findings);
|
|
992
|
+
const risk = riskLabel(score);
|
|
993
|
+
const owaspHit = getOwaspCoverage(findings);
|
|
994
|
+
const remedPlan = buildRemediationPlan(findings);
|
|
995
|
+
const maxCount = Math.max(...Object.values(counts), 1);
|
|
996
|
+
|
|
997
|
+
const sevRows = ['CRITICAL', 'HIGH', 'MEDIUM', 'EXPOSURE'].map(sev => `
|
|
998
|
+
<div class="sev-row">
|
|
999
|
+
<span class="sev-label" style="color:${SEV_COLOR[sev]}">${sev}</span>
|
|
1000
|
+
<div class="sev-bar-wrap">
|
|
1001
|
+
<div class="sev-bar" style="width:${Math.round((counts[sev] / maxCount) * 100)}%;background:${SEV_COLOR[sev]}"></div>
|
|
1002
|
+
</div>
|
|
1003
|
+
<span class="sev-count" style="color:${SEV_COLOR[sev]}">${counts[sev]}</span>
|
|
1004
|
+
</div>`).join('');
|
|
1005
|
+
|
|
1006
|
+
const html = `<!DOCTYPE html>
|
|
1007
|
+
<html lang="en">
|
|
1008
|
+
<head>
|
|
1009
|
+
<meta charset="UTF-8">
|
|
1010
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
1011
|
+
<title>${escHtml(displayName)} – Security Report</title>
|
|
1012
|
+
<style>${buildCSS()}</style>
|
|
1013
|
+
</head>
|
|
1014
|
+
<body>
|
|
1015
|
+
<div class="page">
|
|
1016
|
+
|
|
1017
|
+
<!-- Header -->
|
|
1018
|
+
<header class="report-header">
|
|
1019
|
+
<div>
|
|
1020
|
+
<div class="brand">
|
|
1021
|
+
<div class="brand-icon">🛡️</div>
|
|
1022
|
+
<span class="brand-name">Secure Code by Design</span>
|
|
1023
|
+
</div>
|
|
1024
|
+
<h1 class="report-title">${escHtml(displayName)}</h1>
|
|
1025
|
+
<div class="report-target mono">${escHtml(target)}</div>
|
|
1026
|
+
</div>
|
|
1027
|
+
<div class="report-meta">
|
|
1028
|
+
<div class="meta-date">
|
|
1029
|
+
<strong>${formatDate(scanDate)}</strong>
|
|
1030
|
+
${formatTime(scanDate)} UTC
|
|
1031
|
+
</div>
|
|
1032
|
+
</div>
|
|
1033
|
+
</header>
|
|
1034
|
+
|
|
1035
|
+
<!-- Stats row -->
|
|
1036
|
+
<div class="stats-row">
|
|
1037
|
+
<div class="stat-card">
|
|
1038
|
+
<div class="stat-value">${findings.length}</div>
|
|
1039
|
+
<div class="stat-label">Total Findings</div>
|
|
1040
|
+
</div>
|
|
1041
|
+
<div class="stat-card">
|
|
1042
|
+
<div class="stat-value" style="color:${SEV_COLOR.CRITICAL}">${counts.CRITICAL}</div>
|
|
1043
|
+
<div class="stat-label">Critical</div>
|
|
1044
|
+
</div>
|
|
1045
|
+
<div class="stat-card">
|
|
1046
|
+
<div class="stat-value" style="color:${SEV_COLOR.HIGH}">${counts.HIGH}</div>
|
|
1047
|
+
<div class="stat-label">High</div>
|
|
1048
|
+
</div>
|
|
1049
|
+
<div class="stat-card">
|
|
1050
|
+
<div class="stat-value" style="color:${SEV_COLOR.MEDIUM}">${counts.MEDIUM}</div>
|
|
1051
|
+
<div class="stat-label">Medium</div>
|
|
1052
|
+
</div>
|
|
1053
|
+
<div class="stat-card">
|
|
1054
|
+
<div class="stat-value">${totalFiles}</div>
|
|
1055
|
+
<div class="stat-label">Files Scanned</div>
|
|
1056
|
+
</div>
|
|
1057
|
+
<div class="stat-card">
|
|
1058
|
+
<div class="stat-value">${owaspHit.size}/10</div>
|
|
1059
|
+
<div class="stat-label">OWASP Categories</div>
|
|
1060
|
+
</div>
|
|
1061
|
+
</div>
|
|
1062
|
+
|
|
1063
|
+
<!-- Tabs -->
|
|
1064
|
+
<div class="tabs">
|
|
1065
|
+
<div class="tab active" data-tab="tab-executive">Executive Summary</div>
|
|
1066
|
+
<div class="tab" data-tab="tab-remediation">Remediation Plan</div>
|
|
1067
|
+
<div class="tab" data-tab="tab-findings">All Findings</div>
|
|
1068
|
+
${deepResults && deepResults.size > 0 ? '<div class="tab tab-deep" data-tab="tab-deep">🔍 Deep Analysis <span class="tab-badge">' + deepResults.size + '</span></div>' : ''}
|
|
1069
|
+
</div>
|
|
1070
|
+
|
|
1071
|
+
<!-- Tab: Executive Summary -->
|
|
1072
|
+
<div id="tab-executive" class="tab-panel active">
|
|
1073
|
+
|
|
1074
|
+
<div class="section">
|
|
1075
|
+
<div class="section-title">Risk Assessment</div>
|
|
1076
|
+
<div class="exec-grid">
|
|
1077
|
+
${renderGauge(score, risk)}
|
|
1078
|
+
<div class="card">
|
|
1079
|
+
<div class="section-title" style="margin-bottom:1rem">Severity Breakdown</div>
|
|
1080
|
+
<div class="sev-breakdown">${sevRows}</div>
|
|
1081
|
+
</div>
|
|
1082
|
+
<div class="card">
|
|
1083
|
+
<div class="section-title" style="margin-bottom:1rem">Key Findings</div>
|
|
1084
|
+
${counts.CRITICAL > 0 ? `<p style="color:${SEV_COLOR.CRITICAL};font-weight:600;margin-bottom:0.5rem">⚠ ${counts.CRITICAL} critical vulnerabilities require immediate attention.</p>` : ''}
|
|
1085
|
+
${counts.HIGH > 0 ? `<p style="color:${SEV_COLOR.HIGH};font-weight:600;margin-bottom:0.5rem">⚠ ${counts.HIGH} high-severity issues should be resolved before next release.</p>` : ''}
|
|
1086
|
+
${counts.CRITICAL === 0 && counts.HIGH === 0 ? `<p style="color:#22c55e;font-weight:600">✓ No critical or high severity findings.</p>` : ''}
|
|
1087
|
+
<p class="muted" style="font-size:0.82rem;margin-top:0.75rem">${totalFiles} files scanned · ${skipped.length} skipped · ${findings.filter(f => f.excepted).length} excepted</p>
|
|
1088
|
+
</div>
|
|
1089
|
+
</div>
|
|
1090
|
+
</div>
|
|
1091
|
+
|
|
1092
|
+
<div class="section">
|
|
1093
|
+
<div class="section-title">OWASP Top 10 Coverage (2021)</div>
|
|
1094
|
+
<div class="owasp-grid">${renderOwaspGrid(owaspHit)}</div>
|
|
1095
|
+
<p class="muted" style="font-size:0.78rem;margin-top:0.75rem">
|
|
1096
|
+
Highlighted categories have findings in this scan. ${owaspHit.size} of 10 OWASP Top 10 categories detected.
|
|
1097
|
+
</p>
|
|
1098
|
+
</div>
|
|
1099
|
+
|
|
1100
|
+
</div>
|
|
1101
|
+
|
|
1102
|
+
<!-- Tab: Remediation Plan -->
|
|
1103
|
+
<div id="tab-remediation" class="tab-panel">
|
|
1104
|
+
<div class="section">
|
|
1105
|
+
<div class="section-title">Recommended Action Order</div>
|
|
1106
|
+
<p class="muted" style="margin-bottom:1.5rem;font-size:0.85rem">
|
|
1107
|
+
Findings are ordered by severity and occurrence count. Address critical issues first to reduce overall risk exposure.
|
|
1108
|
+
</p>
|
|
1109
|
+
${renderRemediationTable(remedPlan)}
|
|
1110
|
+
</div>
|
|
1111
|
+
</div>
|
|
1112
|
+
|
|
1113
|
+
<!-- Tab: All Findings -->
|
|
1114
|
+
<div id="tab-findings" class="tab-panel">
|
|
1115
|
+
<div class="section">
|
|
1116
|
+
<div class="section-title">Findings Detail</div>
|
|
1117
|
+
<div class="filter-bar">
|
|
1118
|
+
<span class="muted" style="font-size:0.75rem;padding:0.3rem 0;align-self:center">Filter:</span>
|
|
1119
|
+
${['CRITICAL', 'HIGH', 'MEDIUM', 'EXPOSURE'].map(sev =>
|
|
1120
|
+
counts[sev] > 0
|
|
1121
|
+
? `<button class="filter-btn" data-sev="${sev}" style="border-color:${SEV_COLOR[sev]}40;color:${SEV_COLOR[sev]}">${sev} (${counts[sev]})</button>`
|
|
1122
|
+
: ''
|
|
1123
|
+
).join('')}
|
|
1124
|
+
</div>
|
|
1125
|
+
${renderFindingsDetail(findings, repoRoot)}
|
|
1126
|
+
</div>
|
|
1127
|
+
</div>
|
|
1128
|
+
|
|
1129
|
+
<!-- Tab: Deep Analysis -->
|
|
1130
|
+
${deepResults && deepResults.size > 0 ? renderDeepTab(deepResults, findings) : ''}
|
|
1131
|
+
|
|
1132
|
+
<!-- Footer -->
|
|
1133
|
+
<footer class="report-footer">
|
|
1134
|
+
<span>Generated by <strong>Secure Code by Design</strong></span>
|
|
1135
|
+
<span>${formatDate(scanDate)} · ${findings.length} findings · ${totalFiles} files</span>
|
|
1136
|
+
</footer>
|
|
1137
|
+
|
|
1138
|
+
</div>
|
|
1139
|
+
<script>${buildJS()}</script>
|
|
1140
|
+
</body>
|
|
1141
|
+
</html>`;
|
|
1142
|
+
|
|
1143
|
+
return html;
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
// ── File output ────────────────────────────────────────────────────────────
|
|
1147
|
+
|
|
1148
|
+
function writeReport(html, outputPath) {
|
|
1149
|
+
const dir = path.dirname(outputPath);
|
|
1150
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
1151
|
+
fs.writeFileSync(outputPath, html, { encoding: 'utf8', mode: 0o644 });
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
module.exports = { generateReport, writeReport };
|