@extenshi/cli 0.0.9 → 0.3.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/dist/cli.d.ts +2 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +32 -4
- package/dist/cli.js.map +1 -1
- package/dist/html-report.d.ts +44 -0
- package/dist/html-report.d.ts.map +1 -0
- package/dist/html-report.js +726 -0
- package/dist/html-report.js.map +1 -0
- package/dist/render.d.ts +9 -0
- package/dist/render.d.ts.map +1 -1
- package/dist/render.js +13 -3
- package/dist/render.js.map +1 -1
- package/dist/report.d.ts +20 -0
- package/dist/report.d.ts.map +1 -1
- package/dist/report.js +9 -1
- package/dist/report.js.map +1 -1
- package/dist/scan.d.ts +72 -7
- package/dist/scan.d.ts.map +1 -1
- package/dist/scan.js +130 -24
- package/dist/scan.js.map +1 -1
- package/dist/scanner-names.d.ts +22 -0
- package/dist/scanner-names.d.ts.map +1 -0
- package/dist/scanner-names.js +37 -0
- package/dist/scanner-names.js.map +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,726 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Branded, self-contained HTML report renderer for `extenshi scan --format html`.
|
|
3
|
+
*
|
|
4
|
+
* Pure (no I/O) so it is snapshot-testable — returns a complete single-file
|
|
5
|
+
* HTML document with inline CSS, inline JS, and the inline Extenshi logo. No
|
|
6
|
+
* network access, no external assets: the file opens straight from disk
|
|
7
|
+
* (file://) and works fully offline.
|
|
8
|
+
*
|
|
9
|
+
* Why this exists
|
|
10
|
+
* ---------------
|
|
11
|
+
* The terminal report repeats the same rule across dozens of files, producing a
|
|
12
|
+
* wall of near-identical lines. The HTML report DEDUPLICATES: security findings
|
|
13
|
+
* are grouped by (severity, scanner, rule) into one collapsible row carrying a
|
|
14
|
+
* count and the list of locations. On top of that it adds client-side filtering
|
|
15
|
+
* (by severity, by scanner, free-text), a sticky table of contents, and a
|
|
16
|
+
* light/dark theme toggle in the real extenshi.io palette.
|
|
17
|
+
*
|
|
18
|
+
* Schema tolerance is inherited wholesale from `summarizeReport` /
|
|
19
|
+
* `summarizeCompliance` in render.ts — this module never reads the raw scan
|
|
20
|
+
* shape directly, so it stays correct across scanner-output drift.
|
|
21
|
+
*/
|
|
22
|
+
import { STORE_IMPACT_SHORT, severityRank, summarizeCompliance, summarizeReport, } from './render.js';
|
|
23
|
+
import { computeVerdict } from './report.js';
|
|
24
|
+
import { scannerDisplayName } from './scanner-names.js';
|
|
25
|
+
// ── HTML escaping ───────────────────────────────────────────────────────────
|
|
26
|
+
const ESCAPE_MAP = {
|
|
27
|
+
'&': '&',
|
|
28
|
+
'<': '<',
|
|
29
|
+
'>': '>',
|
|
30
|
+
'"': '"',
|
|
31
|
+
"'": ''',
|
|
32
|
+
};
|
|
33
|
+
/** Escape a string for safe interpolation into text or double-quoted attributes. */
|
|
34
|
+
function esc(s) {
|
|
35
|
+
return s.replace(/[&<>"']/g, (c) => ESCAPE_MAP[c] ?? c);
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Sanitise an untrusted value used as a CSS class-name fragment (e.g.
|
|
39
|
+
* `sev-${...}`). `esc()` neutralises HTML but not whitespace, which would split
|
|
40
|
+
* one class into several tokens; this keeps only class-safe characters so a
|
|
41
|
+
* future severity/storeImpact value from the API can never inject extra classes.
|
|
42
|
+
*/
|
|
43
|
+
function cssToken(s) {
|
|
44
|
+
return s.replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* JSON-encode a value for safe inlining inside a `<script>` tag. Escapes `<` (so
|
|
48
|
+
* a `</script>` in the data can't break out) and the U+2028/U+2029 line
|
|
49
|
+
* separators that are valid in JSON strings but illegal in JS string literals.
|
|
50
|
+
*/
|
|
51
|
+
function jsonForScript(value) {
|
|
52
|
+
return JSON.stringify(value)
|
|
53
|
+
.replace(/</g, '\\u003c')
|
|
54
|
+
.replace(/\u2028/g, '\\u2028')
|
|
55
|
+
.replace(/\u2029/g, '\\u2029');
|
|
56
|
+
}
|
|
57
|
+
/** Hard cap on locations rendered per group — a noisy rule can fire thousands of times. */
|
|
58
|
+
const MAX_LOCATIONS_PER_GROUP = 200;
|
|
59
|
+
/**
|
|
60
|
+
* Collapse identical findings (same severity + scanner + rule headline) into one
|
|
61
|
+
* group with a count and the de-duplicated list of locations. Sorted by
|
|
62
|
+
* severity (critical first), then by count (loudest first), then headline.
|
|
63
|
+
*
|
|
64
|
+
* Dedup uses a `Set` (insertion-ordered) rather than `Array.includes` so a rule
|
|
65
|
+
* that fires thousands of times stays O(n) instead of O(n²).
|
|
66
|
+
*/
|
|
67
|
+
export function groupFindings(findings) {
|
|
68
|
+
const map = new Map();
|
|
69
|
+
for (const f of findings) {
|
|
70
|
+
const key = `${f.severity}\n${f.scanner}\n${f.headline}`;
|
|
71
|
+
let g = map.get(key);
|
|
72
|
+
if (!g) {
|
|
73
|
+
g = {
|
|
74
|
+
severity: f.severity,
|
|
75
|
+
scanner: f.scanner,
|
|
76
|
+
headline: f.headline,
|
|
77
|
+
message: f.message,
|
|
78
|
+
count: 0,
|
|
79
|
+
locations: new Set(),
|
|
80
|
+
extras: new Set(),
|
|
81
|
+
};
|
|
82
|
+
map.set(key, g);
|
|
83
|
+
}
|
|
84
|
+
g.count++;
|
|
85
|
+
if (!g.message && f.message)
|
|
86
|
+
g.message = f.message;
|
|
87
|
+
if (f.location)
|
|
88
|
+
g.locations.add(f.location);
|
|
89
|
+
if (f.extras)
|
|
90
|
+
g.extras.add(f.extras);
|
|
91
|
+
}
|
|
92
|
+
// Materialise the Sets to arrays (Set preserves first-seen order).
|
|
93
|
+
return [...map.values()]
|
|
94
|
+
.map((g) => ({
|
|
95
|
+
severity: g.severity,
|
|
96
|
+
scanner: g.scanner,
|
|
97
|
+
headline: g.headline,
|
|
98
|
+
message: g.message,
|
|
99
|
+
count: g.count,
|
|
100
|
+
locations: [...g.locations],
|
|
101
|
+
extras: [...g.extras],
|
|
102
|
+
}))
|
|
103
|
+
.sort((a, b) => severityRank(a.severity) - severityRank(b.severity) ||
|
|
104
|
+
b.count - a.count ||
|
|
105
|
+
a.headline.localeCompare(b.headline));
|
|
106
|
+
}
|
|
107
|
+
// ── Static brand assets ──────────────────────────────────────────────────────
|
|
108
|
+
/**
|
|
109
|
+
* The Extenshi mark, lifted verbatim from catalog-frontend/src/components/logo.tsx.
|
|
110
|
+
* `fill="currentColor"` lets it pick up the brand colour from CSS in either theme.
|
|
111
|
+
*/
|
|
112
|
+
const LOGO_SVG = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400" width="28" height="28" aria-hidden="true">' +
|
|
113
|
+
'<g transform="translate(200, 200) scale(1.1) translate(-219, -169)">' +
|
|
114
|
+
'<path fill="currentColor" fill-rule="evenodd" d="M 43 29.961 C 43 32.246, 47.125 48.247, 49.888 56.680 C 55.762 74.610, 62.198 83.944, 72.096 88.890 C 78.191 91.936, 78.614 92, 92.660 92 L 107 92 106.994 96.250 C 106.991 98.588, 106.697 104.233, 106.342 108.795 L 105.697 117.091 92.098 116.996 C 84.619 116.943, 77.938 116.923, 77.250 116.950 C 76.300 116.988, 76 121.193, 76 134.500 L 76 152 90.487 152 L 104.974 152 104.449 159.250 C 104.161 163.237, 103.503 174.150, 102.987 183.500 C 102.472 192.850, 101.387 211.300, 100.578 224.500 C 98.475 258.794, 96.671 304.700, 97.356 306.487 C 97.875 307.839, 111.424 307.980, 219.220 307.754 L 340.500 307.500 340.255 287.609 C 340.096 274.661, 339.617 267.243, 338.883 266.359 C 338.004 265.299, 333.491 265, 318.397 265 L 299.038 265 298.411 267.500 C 298.066 268.875, 298.066 271.125, 298.411 272.500 L 299.038 275 222.519 275 L 146 275 146 213.500 L 146 152 218.300 152 C 273.200 152, 290.889 152.289, 291.800 153.200 C 293.565 154.965, 293.269 163.776, 291.257 169.391 C 289.026 175.614, 283.255 182.192, 277.770 184.762 C 265.607 190.461, 266.274 190.383, 224.500 190.961 L 185.500 191.500 185.229 211.226 L 184.958 230.951 187.533 231.598 C 191.687 232.640, 262.673 231.320, 272.308 230.021 C 306.918 225.355, 331.983 202.503, 332.006 175.593 C 332.009 171.692, 332.300 164.799, 332.652 160.277 L 333.292 152.053 347.396 151.777 L 361.500 151.500 361.500 134.500 L 361.500 117.500 347.219 117.222 L 332.939 116.945 333.219 104.722 L 333.500 92.500 347.500 91.947 C 357.308 91.560, 362.626 90.900, 365.261 89.744 C 371.234 87.122, 379.834 78.279, 383.489 71 C 388.795 60.434, 397.405 31.072, 395.699 29.365 C 395.464 29.130, 391.723 30.083, 387.386 31.483 C 379.737 33.952, 356.811 38.101, 340.500 39.969 C 320.691 42.238, 283.099 43.185, 214.975 43.131 C 145.730 43.077, 109.789 42.106, 94.500 39.878 C 90.650 39.316, 84.575 38.430, 81 37.907 C 65.459 35.634, 56.790 33.817, 50.373 31.490 C 42.436 28.611, 43 28.728, 43 29.961 M 146.673 92.660 C 146.303 93.030, 146 98.658, 146 105.167 L 146 117 219.531 117 L 293.062 117 292.781 104.750 L 292.500 92.500 219.923 92.244 C 180.006 92.102, 147.043 92.290, 146.673 92.660"/>' +
|
|
115
|
+
'</g></svg>';
|
|
116
|
+
/**
|
|
117
|
+
* Brand palette mirrors catalog-frontend globals.css (the real site tokens):
|
|
118
|
+
* a warm terracotta accent on a paper background in light mode, a green accent
|
|
119
|
+
* on near-black in dark mode. Fonts reference the site faces by name with a
|
|
120
|
+
* graceful system fallback (no webfont is bundled — keeps the file offline).
|
|
121
|
+
*/
|
|
122
|
+
const STYLE = `
|
|
123
|
+
:root {
|
|
124
|
+
--bg: oklch(0.9711 0.0074 80.7211);
|
|
125
|
+
--surface: oklch(0.99 0.005 80);
|
|
126
|
+
--surface-2: oklch(0.937 0.0142 74.4218);
|
|
127
|
+
--fg: oklch(0.3 0.0358 30.2042);
|
|
128
|
+
--muted-fg: oklch(0.4495 0.0486 39.211);
|
|
129
|
+
--border: oklch(0.8805 0.0208 74.6428);
|
|
130
|
+
--primary: oklch(0.7234 0.1783 41.1468);
|
|
131
|
+
--primary-fg: oklch(1 0 0);
|
|
132
|
+
--shadow: 0 1px 3px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10);
|
|
133
|
+
--font-sans: 'Space Grotesk', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
|
134
|
+
--font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
|
|
135
|
+
--sev-critical: #b42318;
|
|
136
|
+
--sev-high: #d92d20;
|
|
137
|
+
--sev-medium: #dc6803;
|
|
138
|
+
--sev-low: #1570ef;
|
|
139
|
+
--sev-info: #667085;
|
|
140
|
+
--sev-unknown: #98a2b3;
|
|
141
|
+
--pass: #157f3d;
|
|
142
|
+
--review: #dc6803;
|
|
143
|
+
--fail: #b42318;
|
|
144
|
+
}
|
|
145
|
+
html.dark {
|
|
146
|
+
--bg: oklch(0.1377 0.0167 150.7681);
|
|
147
|
+
--surface: oklch(0.1961 0.0163 146.9867);
|
|
148
|
+
--surface-2: oklch(0.2335 0.0184 146.9867);
|
|
149
|
+
--fg: oklch(0.9423 0.0097 72.6595);
|
|
150
|
+
--muted-fg: oklch(0.79 0.0174 76.0955);
|
|
151
|
+
--border: oklch(0.2644 0.0159 142.9926);
|
|
152
|
+
--primary: oklch(0.6731 0.1624 144.2083);
|
|
153
|
+
--primary-fg: oklch(0.2157 0.0453 145.7256);
|
|
154
|
+
--shadow: 0 1px 3px hsl(0 0% 0% / 0.4), 0 1px 2px -1px hsl(0 0% 0% / 0.4);
|
|
155
|
+
--sev-critical: #f97066;
|
|
156
|
+
--sev-high: #fda29b;
|
|
157
|
+
--sev-medium: #fdb022;
|
|
158
|
+
--sev-low: #53b1fd;
|
|
159
|
+
--sev-info: #98a2b3;
|
|
160
|
+
--sev-unknown: #667085;
|
|
161
|
+
--pass: #47cd89;
|
|
162
|
+
--review: #fdb022;
|
|
163
|
+
--fail: #f97066;
|
|
164
|
+
}
|
|
165
|
+
* { box-sizing: border-box; }
|
|
166
|
+
html, body { margin: 0; padding: 0; }
|
|
167
|
+
body {
|
|
168
|
+
background: var(--bg);
|
|
169
|
+
color: var(--fg);
|
|
170
|
+
font-family: var(--font-sans);
|
|
171
|
+
font-feature-settings: "ss01", "ss02", "cv11";
|
|
172
|
+
line-height: 1.5;
|
|
173
|
+
-webkit-font-smoothing: antialiased;
|
|
174
|
+
}
|
|
175
|
+
a { color: var(--primary); }
|
|
176
|
+
code, .mono { font-family: var(--font-mono); font-size: 0.86em; }
|
|
177
|
+
|
|
178
|
+
.topbar {
|
|
179
|
+
position: sticky; top: 0; z-index: 20;
|
|
180
|
+
display: flex; align-items: center; gap: 12px;
|
|
181
|
+
padding: 14px 24px;
|
|
182
|
+
background: color-mix(in oklab, var(--bg) 85%, transparent);
|
|
183
|
+
backdrop-filter: blur(8px);
|
|
184
|
+
border-bottom: 1px solid var(--border);
|
|
185
|
+
}
|
|
186
|
+
.topbar .logo { color: var(--primary); display: inline-flex; }
|
|
187
|
+
.topbar .brand { font-weight: 700; font-size: 1.05rem; letter-spacing: -0.01em; }
|
|
188
|
+
.topbar .sep { color: var(--muted-fg); }
|
|
189
|
+
.topbar .subtitle { color: var(--muted-fg); font-size: 0.9rem; }
|
|
190
|
+
.topbar .spacer { flex: 1; }
|
|
191
|
+
.toggle {
|
|
192
|
+
display: inline-flex; align-items: center; gap: 6px;
|
|
193
|
+
border: 1px solid var(--border); background: var(--surface);
|
|
194
|
+
color: var(--fg); border-radius: 999px; padding: 6px 12px;
|
|
195
|
+
font: inherit; font-size: 0.85rem; cursor: pointer;
|
|
196
|
+
}
|
|
197
|
+
.toggle:hover { border-color: var(--primary); }
|
|
198
|
+
|
|
199
|
+
.layout { display: grid; grid-template-columns: 230px minmax(0, 1fr); gap: 32px; max-width: 1180px; margin: 0 auto; padding: 28px 24px 80px; }
|
|
200
|
+
@media (max-width: 860px) { .layout { grid-template-columns: 1fr; } .nav { display: none; } }
|
|
201
|
+
|
|
202
|
+
.nav { position: sticky; top: 76px; align-self: start; font-size: 0.9rem; }
|
|
203
|
+
.nav a { display: flex; justify-content: space-between; gap: 8px; padding: 7px 10px; border-radius: 8px; color: var(--fg); text-decoration: none; }
|
|
204
|
+
.nav a:hover { background: var(--surface-2); }
|
|
205
|
+
.nav a .n { color: var(--muted-fg); font-variant-numeric: tabular-nums; }
|
|
206
|
+
|
|
207
|
+
.meta { color: var(--muted-fg); font-size: 0.85rem; margin-bottom: 18px; }
|
|
208
|
+
.meta .mono { color: var(--fg); }
|
|
209
|
+
|
|
210
|
+
section.card { background: var(--surface); border: 1px solid var(--border); border-radius: 14px; padding: 20px 22px; margin-bottom: 22px; box-shadow: var(--shadow); }
|
|
211
|
+
h2 { font-size: 1.15rem; margin: 0 0 14px; letter-spacing: -0.01em; }
|
|
212
|
+
h2 .count { color: var(--muted-fg); font-weight: 500; font-size: 0.9rem; }
|
|
213
|
+
|
|
214
|
+
.verdict { border-left: 5px solid var(--review); }
|
|
215
|
+
.verdict.fail { border-left-color: var(--fail); }
|
|
216
|
+
.verdict.review { border-left-color: var(--review); }
|
|
217
|
+
.verdict.pass { border-left-color: var(--pass); }
|
|
218
|
+
.verdict .headline { font-size: 1.3rem; font-weight: 700; margin: 0 0 6px; }
|
|
219
|
+
.verdict.fail .headline { color: var(--fail); }
|
|
220
|
+
.verdict.review .headline { color: var(--review); }
|
|
221
|
+
.verdict.pass .headline { color: var(--pass); }
|
|
222
|
+
.verdict .detail { color: var(--muted-fg); margin: 0; max-width: 70ch; }
|
|
223
|
+
|
|
224
|
+
.stats { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 16px; }
|
|
225
|
+
.stat { background: var(--surface-2); border-radius: 10px; padding: 10px 14px; min-width: 92px; }
|
|
226
|
+
.stat .v { font-size: 1.4rem; font-weight: 700; font-variant-numeric: tabular-nums; }
|
|
227
|
+
.stat .l { color: var(--muted-fg); font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.04em; }
|
|
228
|
+
|
|
229
|
+
.gauge { margin-top: 16px; }
|
|
230
|
+
.gauge .track { height: 10px; border-radius: 999px; background: var(--surface-2); overflow: hidden; }
|
|
231
|
+
.gauge .fill { height: 100%; border-radius: 999px; }
|
|
232
|
+
.gauge .label { display: flex; justify-content: space-between; font-size: 0.82rem; color: var(--muted-fg); margin-bottom: 6px; }
|
|
233
|
+
|
|
234
|
+
.badge { display: inline-block; padding: 2px 9px; border-radius: 999px; font-size: 0.72rem; font-weight: 700; letter-spacing: 0.03em; color: #fff; white-space: nowrap; }
|
|
235
|
+
.badge.sev-CRITICAL { background: var(--sev-critical); }
|
|
236
|
+
.badge.sev-HIGH { background: var(--sev-high); }
|
|
237
|
+
.badge.sev-MEDIUM { background: var(--sev-medium); }
|
|
238
|
+
.badge.sev-LOW { background: var(--sev-low); }
|
|
239
|
+
.badge.sev-INFO { background: var(--sev-info); }
|
|
240
|
+
.badge.sev-UNKNOWN { background: var(--sev-unknown); }
|
|
241
|
+
html.dark .badge { color: #0b0f0c; }
|
|
242
|
+
.badge.impact { background: var(--surface-2); color: var(--fg); border: 1px solid var(--border); }
|
|
243
|
+
.badge.impact.REJECTION_LIKELY { background: var(--fail); color: #fff; border: none; }
|
|
244
|
+
.badge.impact.REVIEW_FLAG { background: var(--review); color: #fff; border: none; }
|
|
245
|
+
html.dark .badge.impact.REJECTION_LIKELY, html.dark .badge.impact.REVIEW_FLAG { color: #0b0f0c; }
|
|
246
|
+
|
|
247
|
+
.compliance-item { border: 1px solid var(--border); border-radius: 12px; padding: 14px 16px; margin-bottom: 12px; }
|
|
248
|
+
.compliance-item .head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
|
249
|
+
.compliance-item .title { font-weight: 700; }
|
|
250
|
+
.compliance-item .stores { color: var(--muted-fg); font-size: 0.8rem; }
|
|
251
|
+
.compliance-item .why { color: var(--muted-fg); margin: 8px 0 0; }
|
|
252
|
+
.compliance-item .fix { margin: 8px 0 0; }
|
|
253
|
+
.compliance-item .evidence { margin: 10px 0 0; background: var(--surface-2); border-radius: 8px; padding: 8px 10px; overflow-x: auto; }
|
|
254
|
+
.compliance-item .evidence pre { margin: 0; white-space: pre-wrap; word-break: break-word; }
|
|
255
|
+
|
|
256
|
+
.filters { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin-bottom: 16px; }
|
|
257
|
+
.sev-chip { cursor: pointer; border: 1px solid transparent; opacity: 1; user-select: none; }
|
|
258
|
+
.sev-chip.off { opacity: 0.32; filter: grayscale(0.6); }
|
|
259
|
+
.filters input[type=search], .filters select {
|
|
260
|
+
font: inherit; font-size: 0.88rem; padding: 7px 11px; border-radius: 8px;
|
|
261
|
+
border: 1px solid var(--border); background: var(--surface); color: var(--fg);
|
|
262
|
+
}
|
|
263
|
+
.filters input[type=search] { flex: 1; min-width: 160px; }
|
|
264
|
+
.filters .spacer { flex: 1; }
|
|
265
|
+
.filters .lnk { background: none; border: none; color: var(--primary); cursor: pointer; font: inherit; font-size: 0.85rem; padding: 4px; }
|
|
266
|
+
|
|
267
|
+
.group { border: 1px solid var(--border); border-radius: 12px; margin-bottom: 10px; overflow: hidden; }
|
|
268
|
+
.group[hidden] { display: none; }
|
|
269
|
+
.group > summary { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; padding: 12px 14px; cursor: pointer; list-style: none; }
|
|
270
|
+
.group > summary::-webkit-details-marker { display: none; }
|
|
271
|
+
.group > summary::before { content: "▸"; color: var(--muted-fg); transition: transform 0.15s; }
|
|
272
|
+
.group[open] > summary::before { transform: rotate(90deg); }
|
|
273
|
+
.group .rule { font-family: var(--font-mono); font-size: 0.9rem; font-weight: 600; }
|
|
274
|
+
.group .scanner { color: var(--muted-fg); font-size: 0.8rem; }
|
|
275
|
+
.group .times { margin-left: auto; background: var(--surface-2); border-radius: 999px; padding: 2px 10px; font-size: 0.78rem; font-variant-numeric: tabular-nums; color: var(--muted-fg); }
|
|
276
|
+
.group .body { padding: 0 14px 14px 36px; }
|
|
277
|
+
.group .msg { color: var(--muted-fg); margin: 0 0 10px; max-width: 80ch; }
|
|
278
|
+
.group .locs { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 3px; }
|
|
279
|
+
.group .locs li { font-family: var(--font-mono); font-size: 0.82rem; color: var(--fg); word-break: break-all; }
|
|
280
|
+
.group .more { color: var(--muted-fg); font-size: 0.8rem; margin-top: 6px; }
|
|
281
|
+
|
|
282
|
+
.no-matches { color: var(--muted-fg); text-align: center; padding: 24px; }
|
|
283
|
+
|
|
284
|
+
table.scanners { width: 100%; border-collapse: collapse; font-size: 0.88rem; }
|
|
285
|
+
table.scanners th, table.scanners td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--border); }
|
|
286
|
+
table.scanners th { color: var(--muted-fg); font-weight: 600; font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.04em; }
|
|
287
|
+
table.scanners .ok { color: var(--pass); font-weight: 600; }
|
|
288
|
+
table.scanners .bad { color: var(--fail); font-weight: 600; }
|
|
289
|
+
table.scanners .warn { color: var(--review); font-weight: 600; }
|
|
290
|
+
|
|
291
|
+
.disclaimer { color: var(--muted-fg); font-size: 0.82rem; max-width: 80ch; }
|
|
292
|
+
.disclaimer a { color: var(--primary); }
|
|
293
|
+
.empty-good { color: var(--pass); font-weight: 600; }
|
|
294
|
+
|
|
295
|
+
/* Feedback — per-finding bug button + modal */
|
|
296
|
+
.bug { margin-left: 4px; border: 1px solid transparent; background: transparent; cursor: pointer; font-size: 0.95rem; line-height: 1; padding: 2px 5px; border-radius: 7px; opacity: 0.55; }
|
|
297
|
+
.bug:hover { opacity: 1; background: var(--surface-2); border-color: var(--border); }
|
|
298
|
+
.fb-open { gap: 6px; }
|
|
299
|
+
.fb-overlay { position: fixed; inset: 0; z-index: 50; display: flex; align-items: center; justify-content: center; background: hsl(0 0% 0% / 0.5); padding: 20px; }
|
|
300
|
+
.fb-overlay[hidden] { display: none; }
|
|
301
|
+
.fb-modal { background: var(--surface); color: var(--fg); border: 1px solid var(--border); border-radius: 14px; box-shadow: var(--shadow); width: 100%; max-width: 480px; padding: 22px; }
|
|
302
|
+
.fb-modal h3 { margin: 0 0 6px; font-size: 1.1rem; }
|
|
303
|
+
.fb-context { color: var(--muted-fg); font-size: 0.85rem; margin: 0 0 14px; }
|
|
304
|
+
.fb-context code { font-family: var(--font-mono); }
|
|
305
|
+
.fb-label { display: block; font-size: 0.82rem; font-weight: 600; margin: 10px 0 4px; }
|
|
306
|
+
.fb-req { color: var(--fail); }
|
|
307
|
+
.fb-modal textarea, .fb-modal input { width: 100%; font: inherit; font-size: 0.9rem; padding: 9px 11px; border-radius: 8px; border: 1px solid var(--border); background: var(--bg); color: var(--fg); box-sizing: border-box; resize: vertical; }
|
|
308
|
+
.fb-modal textarea:focus, .fb-modal input:focus { outline: 2px solid var(--primary); outline-offset: 0; border-color: var(--primary); }
|
|
309
|
+
.fb-status { font-size: 0.85rem; margin: 12px 0 0; }
|
|
310
|
+
.fb-status.err { color: var(--fail); }
|
|
311
|
+
.fb-status.ok { color: var(--pass); }
|
|
312
|
+
.fb-status a { color: var(--primary); }
|
|
313
|
+
.fb-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 16px; }
|
|
314
|
+
.fb-actions button { font: inherit; font-size: 0.88rem; padding: 8px 14px; border-radius: 8px; cursor: pointer; border: 1px solid var(--border); }
|
|
315
|
+
.fb-cancel { background: var(--surface); color: var(--fg); }
|
|
316
|
+
.fb-submit { background: var(--primary); color: var(--primary-fg); border-color: var(--primary); font-weight: 600; }
|
|
317
|
+
.fb-submit[disabled] { opacity: 0.6; cursor: progress; }
|
|
318
|
+
`;
|
|
319
|
+
/** Client-side behaviour: theme persistence, filtering, expand/collapse. No template literals / no `${` so it survives interpolation verbatim. */
|
|
320
|
+
const SCRIPT = `
|
|
321
|
+
(function () {
|
|
322
|
+
var root = document.documentElement;
|
|
323
|
+
var btn = document.getElementById('theme-toggle');
|
|
324
|
+
function syncLabel() {
|
|
325
|
+
if (!btn) return;
|
|
326
|
+
btn.textContent = root.classList.contains('dark') ? '☀ Light' : '☾ Dark';
|
|
327
|
+
}
|
|
328
|
+
syncLabel();
|
|
329
|
+
if (btn) btn.addEventListener('click', function () {
|
|
330
|
+
var dark = root.classList.toggle('dark');
|
|
331
|
+
try { localStorage.setItem('extenshi-report-theme', dark ? 'dark' : 'light'); } catch (e) {}
|
|
332
|
+
syncLabel();
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
var groups = Array.prototype.slice.call(document.querySelectorAll('.group'));
|
|
336
|
+
var chips = Array.prototype.slice.call(document.querySelectorAll('.sev-chip'));
|
|
337
|
+
var scannerSel = document.getElementById('scanner-filter');
|
|
338
|
+
var search = document.getElementById('search');
|
|
339
|
+
var empty = document.getElementById('no-matches');
|
|
340
|
+
var counter = document.getElementById('visible-count');
|
|
341
|
+
var active = {};
|
|
342
|
+
chips.forEach(function (c) { active[c.getAttribute('data-sev')] = true; });
|
|
343
|
+
|
|
344
|
+
function refresh() {
|
|
345
|
+
var scanner = scannerSel ? scannerSel.value : 'all';
|
|
346
|
+
var q = (search ? search.value : '').trim().toLowerCase();
|
|
347
|
+
var shown = 0;
|
|
348
|
+
groups.forEach(function (g) {
|
|
349
|
+
var sev = g.getAttribute('data-severity');
|
|
350
|
+
var sc = g.getAttribute('data-scanner');
|
|
351
|
+
var text = g.getAttribute('data-text') || '';
|
|
352
|
+
var ok = active[sev] && (scanner === 'all' || scanner === sc) && (q === '' || text.indexOf(q) > -1);
|
|
353
|
+
if (ok) { g.removeAttribute('hidden'); shown++; } else { g.setAttribute('hidden', ''); }
|
|
354
|
+
});
|
|
355
|
+
if (counter) counter.textContent = String(shown);
|
|
356
|
+
if (empty) { if (shown === 0) empty.removeAttribute('hidden'); else empty.setAttribute('hidden', ''); }
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
chips.forEach(function (c) {
|
|
360
|
+
c.addEventListener('click', function () {
|
|
361
|
+
var s = c.getAttribute('data-sev');
|
|
362
|
+
active[s] = !active[s];
|
|
363
|
+
c.classList.toggle('off', !active[s]);
|
|
364
|
+
refresh();
|
|
365
|
+
});
|
|
366
|
+
});
|
|
367
|
+
if (scannerSel) scannerSel.addEventListener('change', refresh);
|
|
368
|
+
if (search) search.addEventListener('input', refresh);
|
|
369
|
+
|
|
370
|
+
var expandBtn = document.getElementById('expand-all');
|
|
371
|
+
var collapseBtn = document.getElementById('collapse-all');
|
|
372
|
+
if (expandBtn) expandBtn.addEventListener('click', function () {
|
|
373
|
+
groups.forEach(function (g) { if (!g.hasAttribute('hidden')) g.setAttribute('open', ''); });
|
|
374
|
+
});
|
|
375
|
+
if (collapseBtn) collapseBtn.addEventListener('click', function () {
|
|
376
|
+
groups.forEach(function (g) { g.removeAttribute('open'); });
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
refresh();
|
|
380
|
+
})();
|
|
381
|
+
|
|
382
|
+
// Feedback — open a modal from the per-finding bug button or the header button,
|
|
383
|
+
// then POST the report to the scan API. text/plain dodges the CORS preflight a
|
|
384
|
+
// file:// page would otherwise trigger; failures fall back to a mailto link.
|
|
385
|
+
(function () {
|
|
386
|
+
var cfg = window.__EXTENSHI_FB__;
|
|
387
|
+
var overlay = document.getElementById('fb-overlay');
|
|
388
|
+
if (!cfg || !cfg.endpoint || !overlay) return;
|
|
389
|
+
|
|
390
|
+
var ctx = document.getElementById('fb-context');
|
|
391
|
+
var msg = document.getElementById('fb-message');
|
|
392
|
+
var contact = document.getElementById('fb-contact');
|
|
393
|
+
var statusEl = document.getElementById('fb-status');
|
|
394
|
+
var submitBtn = document.getElementById('fb-submit');
|
|
395
|
+
var cancelBtn = document.getElementById('fb-cancel');
|
|
396
|
+
var current = { kind: 'general' };
|
|
397
|
+
|
|
398
|
+
function escHtml(s) {
|
|
399
|
+
return String(s).replace(/[&<>"']/g, function (c) {
|
|
400
|
+
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
// innerHTML here is safe by contract: callers pass only static strings or
|
|
404
|
+
// values already neutralised — escHtml() for the finding context, and
|
|
405
|
+
// encodeURIComponent() for the mailto href (which cannot carry raw <,>,").
|
|
406
|
+
function setStatus(html, cls) {
|
|
407
|
+
if (!statusEl) return;
|
|
408
|
+
statusEl.className = 'fb-status ' + (cls || '');
|
|
409
|
+
statusEl.innerHTML = html;
|
|
410
|
+
statusEl.hidden = !html;
|
|
411
|
+
}
|
|
412
|
+
function openModal(prefill) {
|
|
413
|
+
current = prefill || { kind: 'general' };
|
|
414
|
+
if (ctx) {
|
|
415
|
+
if (current.kind === 'finding') {
|
|
416
|
+
ctx.innerHTML = 'Reporting <code>' + escHtml(current.rule || '') + '</code> from <strong>' + escHtml(current.label || current.scanner || '') + '</strong> — tell us what looks wrong (e.g. false positive).';
|
|
417
|
+
} else {
|
|
418
|
+
ctx.textContent = 'Tell us about a wrong result, a bug, or anything else about this scan.';
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
setStatus('', '');
|
|
422
|
+
if (submitBtn) { submitBtn.disabled = false; submitBtn.textContent = 'Send feedback'; }
|
|
423
|
+
overlay.hidden = false;
|
|
424
|
+
if (msg) msg.focus();
|
|
425
|
+
}
|
|
426
|
+
function closeModal() { overlay.hidden = true; }
|
|
427
|
+
|
|
428
|
+
Array.prototype.slice.call(document.querySelectorAll('.bug')).forEach(function (b) {
|
|
429
|
+
b.addEventListener('click', function (e) {
|
|
430
|
+
e.preventDefault(); e.stopPropagation();
|
|
431
|
+
openModal({ kind: 'finding', scanner: b.getAttribute('data-fb-scanner') || '', label: b.getAttribute('data-fb-label') || '', rule: b.getAttribute('data-fb-rule') || '', severity: b.getAttribute('data-fb-severity') || '' });
|
|
432
|
+
});
|
|
433
|
+
});
|
|
434
|
+
var generalBtn = document.getElementById('fb-open-general');
|
|
435
|
+
if (generalBtn) generalBtn.addEventListener('click', function () { openModal({ kind: 'general' }); });
|
|
436
|
+
if (cancelBtn) cancelBtn.addEventListener('click', closeModal);
|
|
437
|
+
overlay.addEventListener('click', function (e) { if (e.target === overlay) closeModal(); });
|
|
438
|
+
document.addEventListener('keydown', function (e) { if (e.key === 'Escape' && !overlay.hidden) closeModal(); });
|
|
439
|
+
|
|
440
|
+
function mailtoFallback(p) {
|
|
441
|
+
var subject = 'Extenshi scan feedback' + (p.jobId ? ' (' + p.jobId + ')' : '');
|
|
442
|
+
var lines = ['Kind: ' + p.kind];
|
|
443
|
+
if (p.rule) lines.push('Rule: ' + p.rule);
|
|
444
|
+
if (p.scannerLabel) lines.push('Scanner: ' + p.scannerLabel);
|
|
445
|
+
if (p.severity) lines.push('Severity: ' + p.severity);
|
|
446
|
+
if (p.jobId) lines.push('Job: ' + p.jobId);
|
|
447
|
+
lines.push('', p.message || '');
|
|
448
|
+
return 'mailto:feedback@extenshi.io?subject=' + encodeURIComponent(subject) + '&body=' + encodeURIComponent(lines.join('\\n'));
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
if (submitBtn) submitBtn.addEventListener('click', function () {
|
|
452
|
+
var text = ((msg && msg.value) || '').trim();
|
|
453
|
+
if (!text) { setStatus('Please describe the problem first.', 'err'); if (msg) msg.focus(); return; }
|
|
454
|
+
var payload = {
|
|
455
|
+
kind: current.kind,
|
|
456
|
+
jobId: cfg.jobId || null,
|
|
457
|
+
cliVersion: cfg.cliVersion || null,
|
|
458
|
+
message: text,
|
|
459
|
+
contact: ((contact && contact.value) || '').trim() || null,
|
|
460
|
+
scanner: current.scanner || null,
|
|
461
|
+
scannerLabel: current.label || null,
|
|
462
|
+
rule: current.rule || null,
|
|
463
|
+
severity: current.severity || null,
|
|
464
|
+
userAgent: navigator.userAgent
|
|
465
|
+
};
|
|
466
|
+
submitBtn.disabled = true; submitBtn.textContent = 'Sending…';
|
|
467
|
+
setStatus('', '');
|
|
468
|
+
fetch(cfg.endpoint, { method: 'POST', headers: { 'content-type': 'text/plain;charset=UTF-8' }, body: JSON.stringify(payload) })
|
|
469
|
+
.then(function (r) {
|
|
470
|
+
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
471
|
+
setStatus('✓ Thanks — your feedback was sent. We review every report.', 'ok');
|
|
472
|
+
submitBtn.textContent = 'Sent';
|
|
473
|
+
setTimeout(closeModal, 1800);
|
|
474
|
+
})
|
|
475
|
+
.catch(function () {
|
|
476
|
+
submitBtn.disabled = false; submitBtn.textContent = 'Send feedback';
|
|
477
|
+
setStatus('Could not reach the server. <a href="' + mailtoFallback(payload) + '">Email it to us instead</a>.', 'err');
|
|
478
|
+
});
|
|
479
|
+
});
|
|
480
|
+
})();
|
|
481
|
+
`;
|
|
482
|
+
// ── Section renderers ────────────────────────────────────────────────────────
|
|
483
|
+
const SEVERITIES = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW', 'INFO', 'UNKNOWN'];
|
|
484
|
+
function renderStats(summary, groupCount) {
|
|
485
|
+
const stat = (v, l) => `<div class="stat"><div class="v">${esc(String(v))}</div><div class="l">${esc(l)}</div></div>`;
|
|
486
|
+
return ('<div class="stats">' +
|
|
487
|
+
stat(summary.total, 'Findings') +
|
|
488
|
+
stat(summary.critHigh, 'Critical / high') +
|
|
489
|
+
stat(groupCount, 'Distinct rules') +
|
|
490
|
+
stat(`${summary.scannersOk}/${summary.scannersRun}`, 'Scanners OK') +
|
|
491
|
+
'</div>');
|
|
492
|
+
}
|
|
493
|
+
function renderGauge(score) {
|
|
494
|
+
const clamped = Math.max(0, Math.min(100, Math.round(score)));
|
|
495
|
+
const color = clamped >= 70 ? 'var(--fail)' : clamped >= 35 ? 'var(--review)' : 'var(--pass)';
|
|
496
|
+
return ('<div class="gauge">' +
|
|
497
|
+
`<div class="label"><span>Manifest risk score</span><span>${clamped}/100</span></div>` +
|
|
498
|
+
`<div class="track"><div class="fill" style="width:${clamped}%;background:${color}"></div></div>` +
|
|
499
|
+
'</div>');
|
|
500
|
+
}
|
|
501
|
+
function renderComplianceSection(compliance) {
|
|
502
|
+
const parts = [`<section class="card" id="compliance"><h2>Store compliance</h2>`];
|
|
503
|
+
if (!compliance.present) {
|
|
504
|
+
parts.push(`<p class="disclaimer">Store-compliance analysis is unavailable — the scan server has not been updated yet. Re-run after the next server rollout (no CLI action needed).</p>`);
|
|
505
|
+
return `${parts.join('')}</section>`;
|
|
506
|
+
}
|
|
507
|
+
if (compliance.findings.length === 0) {
|
|
508
|
+
parts.push(`<p class="empty-good">✓ No store-compliance signals detected in the manifest.</p>`);
|
|
509
|
+
parts.push(renderGauge(compliance.manifestRiskScore));
|
|
510
|
+
return `${parts.join('')}</section>`;
|
|
511
|
+
}
|
|
512
|
+
for (const f of compliance.findings) {
|
|
513
|
+
const impactShort = STORE_IMPACT_SHORT[f.storeImpact] ?? f.storeImpact;
|
|
514
|
+
const stores = f.stores.length ? `<span class="stores">${esc(f.stores.join(', '))}</span>` : '';
|
|
515
|
+
const evidence = f.evidence.length
|
|
516
|
+
? `<div class="evidence"><pre>${esc(f.evidence.slice(0, 10).join('\n'))}</pre></div>`
|
|
517
|
+
: '';
|
|
518
|
+
parts.push('<div class="compliance-item">' +
|
|
519
|
+
'<div class="head">' +
|
|
520
|
+
`<span class="badge impact ${cssToken(f.storeImpact)}">${esc(impactShort)}</span>` +
|
|
521
|
+
`<span class="badge sev-${cssToken(f.severity)}">${esc(f.severity)}</span>` +
|
|
522
|
+
`<span class="title">${esc(f.label)}</span>` +
|
|
523
|
+
stores +
|
|
524
|
+
'</div>' +
|
|
525
|
+
(f.summary ? `<p class="why"><strong>Why stores care:</strong> ${esc(f.summary)}</p>` : '') +
|
|
526
|
+
evidence +
|
|
527
|
+
(f.remediation ? `<p class="fix"><strong>How to fix:</strong> ${esc(f.remediation)}</p>` : '') +
|
|
528
|
+
'</div>');
|
|
529
|
+
}
|
|
530
|
+
parts.push(renderGauge(compliance.manifestRiskScore));
|
|
531
|
+
if (compliance.flags.length > 0) {
|
|
532
|
+
parts.push(`<p class="meta">Flags set: ${compliance.flags.map((fl) => `<code>${esc(fl)}</code>`).join(', ')}</p>`);
|
|
533
|
+
}
|
|
534
|
+
return `${parts.join('')}</section>`;
|
|
535
|
+
}
|
|
536
|
+
function renderSecuritySection(groups, feedbackEnabled) {
|
|
537
|
+
const parts = [
|
|
538
|
+
`<section class="card" id="security"><h2>Security findings <span class="count">(<span id="visible-count">${groups.length}</span> shown)</span></h2>`,
|
|
539
|
+
];
|
|
540
|
+
if (groups.length === 0) {
|
|
541
|
+
parts.push(`<p class="empty-good">✓ No code-analysis findings raised by the automated scanners.</p>`);
|
|
542
|
+
return `${parts.join('')}</section>`;
|
|
543
|
+
}
|
|
544
|
+
// Severity filter chips (only the severities actually present).
|
|
545
|
+
const presentSevs = SEVERITIES.filter((s) => groups.some((g) => g.severity === s));
|
|
546
|
+
const chips = presentSevs
|
|
547
|
+
.map((s) => {
|
|
548
|
+
const n = groups.filter((g) => g.severity === s).reduce((acc, g) => acc + g.count, 0);
|
|
549
|
+
return `<span class="badge sev-${s} sev-chip" data-sev="${s}" role="button" tabindex="0">${s} ${n}</span>`;
|
|
550
|
+
})
|
|
551
|
+
.join('');
|
|
552
|
+
// Scanner dropdown (unique scanners across groups). The option VALUE stays the
|
|
553
|
+
// raw codename (so it matches each group's data-scanner), but the LABEL shows
|
|
554
|
+
// the human capability phrase.
|
|
555
|
+
const scanners = [...new Set(groups.map((g) => g.scanner))].sort((a, b) => scannerDisplayName(a).localeCompare(scannerDisplayName(b)));
|
|
556
|
+
const scannerOpts = ['<option value="all">All scanners</option>']
|
|
557
|
+
.concat(scanners.map((s) => `<option value="${esc(s)}">${esc(scannerDisplayName(s))}</option>`))
|
|
558
|
+
.join('');
|
|
559
|
+
parts.push('<div class="filters">' +
|
|
560
|
+
chips +
|
|
561
|
+
'<span class="spacer"></span>' +
|
|
562
|
+
`<select id="scanner-filter" aria-label="Filter by scanner">${scannerOpts}</select>` +
|
|
563
|
+
'<input id="search" type="search" placeholder="Filter findings…" aria-label="Filter findings by text">' +
|
|
564
|
+
'<button class="lnk" id="expand-all" type="button">Expand all</button>' +
|
|
565
|
+
'<button class="lnk" id="collapse-all" type="button">Collapse all</button>' +
|
|
566
|
+
'</div>');
|
|
567
|
+
for (const g of groups) {
|
|
568
|
+
const searchText = esc(`${g.headline} ${g.scanner} ${scannerDisplayName(g.scanner)} ${g.message} ${g.locations.join(' ')} ${g.extras.join(' ')}`.toLowerCase());
|
|
569
|
+
const shownLocs = g.locations.slice(0, MAX_LOCATIONS_PER_GROUP);
|
|
570
|
+
const locsHtml = shownLocs.length
|
|
571
|
+
? `<ul class="locs">${shownLocs.map((l) => `<li>${esc(l)}</li>`).join('')}</ul>`
|
|
572
|
+
: '';
|
|
573
|
+
const moreLocs = g.locations.length > MAX_LOCATIONS_PER_GROUP
|
|
574
|
+
? `<p class="more">…and ${g.locations.length - MAX_LOCATIONS_PER_GROUP} more location(s)</p>`
|
|
575
|
+
: '';
|
|
576
|
+
const extrasHtml = g.extras.length ? `<p class="more">${esc(g.extras.slice(0, 5).join(' • '))}</p>` : '';
|
|
577
|
+
const times = g.count > 1 ? `<span class="times">×${g.count}</span>` : '';
|
|
578
|
+
// 🐞 reports a wrong scanner result for this exact finding (scanner + rule
|
|
579
|
+
// + severity travel with it). Inside <summary>, so the JS stops the click
|
|
580
|
+
// from toggling the <details>.
|
|
581
|
+
const bug = feedbackEnabled
|
|
582
|
+
? `<button type="button" class="bug" title="Report wrong scanner result" data-fb-scanner="${esc(g.scanner)}" data-fb-label="${esc(scannerDisplayName(g.scanner))}" data-fb-rule="${esc(g.headline)}" data-fb-severity="${esc(g.severity)}">🐞</button>`
|
|
583
|
+
: '';
|
|
584
|
+
parts.push(`<details class="group" data-severity="${esc(g.severity)}" data-scanner="${esc(g.scanner)}" data-text="${searchText}">` +
|
|
585
|
+
'<summary>' +
|
|
586
|
+
`<span class="badge sev-${cssToken(g.severity)}">${esc(g.severity)}</span>` +
|
|
587
|
+
`<span class="rule">${esc(g.headline)}</span>` +
|
|
588
|
+
`<span class="scanner">${esc(scannerDisplayName(g.scanner))}</span>` +
|
|
589
|
+
times +
|
|
590
|
+
bug +
|
|
591
|
+
'</summary>' +
|
|
592
|
+
'<div class="body">' +
|
|
593
|
+
(g.message ? `<p class="msg">${esc(g.message)}</p>` : '') +
|
|
594
|
+
locsHtml +
|
|
595
|
+
moreLocs +
|
|
596
|
+
extrasHtml +
|
|
597
|
+
'</div>' +
|
|
598
|
+
'</details>');
|
|
599
|
+
}
|
|
600
|
+
parts.push(`<p class="no-matches" id="no-matches" hidden>No findings match the current filters.</p>`);
|
|
601
|
+
return `${parts.join('')}</section>`;
|
|
602
|
+
}
|
|
603
|
+
/**
|
|
604
|
+
* Coverage roster — EVERY scanner that ran, not just the failures. This is what
|
|
605
|
+
* makes a clean check visible: e.g. "Known-vulnerable dependency detection →
|
|
606
|
+
* ✓ clean", so the user can see their libraries were inspected even when nothing
|
|
607
|
+
* was flagged. Scanner names are the public capability phrases (see scanner-names).
|
|
608
|
+
*/
|
|
609
|
+
function renderCoverageSection(summary) {
|
|
610
|
+
const parts = [
|
|
611
|
+
`<section class="card" id="scanners"><h2>Coverage — what we checked <span class="count">(${summary.scannersOk}/${summary.scannersRun} scanners OK)</span></h2>`,
|
|
612
|
+
];
|
|
613
|
+
if (summary.roster.length === 0) {
|
|
614
|
+
parts.push(`<p class="disclaimer">No scanner results were returned.</p>`);
|
|
615
|
+
return `${parts.join('')}</section>`;
|
|
616
|
+
}
|
|
617
|
+
const errorByScanner = new Map(summary.issues.map((i) => [i.scanner, i.error ?? '']));
|
|
618
|
+
// Order: failures first (surface problems), then noisiest, then by name.
|
|
619
|
+
const rows = [...summary.roster].sort((a, b) => Number(a.ok) - Number(b.ok) ||
|
|
620
|
+
b.findingCount - a.findingCount ||
|
|
621
|
+
scannerDisplayName(a.scanner).localeCompare(scannerDisplayName(b.scanner)));
|
|
622
|
+
parts.push('<table class="scanners"><thead><tr><th>Check</th><th>Result</th><th>Detail</th></tr></thead><tbody>');
|
|
623
|
+
for (const r of rows) {
|
|
624
|
+
let result;
|
|
625
|
+
let detail;
|
|
626
|
+
if (!r.ok) {
|
|
627
|
+
result = `<span class="bad">✕ ${esc(r.status)}</span>`;
|
|
628
|
+
detail = esc(errorByScanner.get(r.scanner) ?? '');
|
|
629
|
+
}
|
|
630
|
+
else if (r.findingCount === 0) {
|
|
631
|
+
result = '<span class="ok">✓ clean</span>';
|
|
632
|
+
detail = 'no findings';
|
|
633
|
+
}
|
|
634
|
+
else {
|
|
635
|
+
result = `<span class="warn">${r.findingCount} finding${r.findingCount !== 1 ? 's' : ''}</span>`;
|
|
636
|
+
detail = '';
|
|
637
|
+
}
|
|
638
|
+
parts.push(`<tr><td>${esc(scannerDisplayName(r.scanner))}</td><td>${result}</td><td>${detail}</td></tr>`);
|
|
639
|
+
}
|
|
640
|
+
parts.push('</tbody></table>');
|
|
641
|
+
return `${parts.join('')}</section>`;
|
|
642
|
+
}
|
|
643
|
+
// ── Document assembly ─────────────────────────────────────────────────────────
|
|
644
|
+
export function renderHtmlReport(body, meta) {
|
|
645
|
+
const summary = summarizeReport(body);
|
|
646
|
+
const compliance = summarizeCompliance(body);
|
|
647
|
+
const verdict = computeVerdict(compliance, summary);
|
|
648
|
+
const groups = groupFindings(summary.findings);
|
|
649
|
+
const title = `Extenshi scan report — ${meta.artifactName}`;
|
|
650
|
+
const job = summary.jobId ? ` · Job <span class="mono">${esc(summary.jobId)}</span>` : '';
|
|
651
|
+
// Feedback UI is rendered only when the CLI provided an endpoint to POST to.
|
|
652
|
+
const feedbackEnabled = typeof meta.feedbackEndpoint === 'string' && meta.feedbackEndpoint.length > 0;
|
|
653
|
+
const fbModal = feedbackEnabled
|
|
654
|
+
? '<div class="fb-overlay" id="fb-overlay" hidden>' +
|
|
655
|
+
'<div class="fb-modal" role="dialog" aria-modal="true" aria-labelledby="fb-title">' +
|
|
656
|
+
'<h3 id="fb-title">Report an issue</h3>' +
|
|
657
|
+
'<p class="fb-context" id="fb-context"></p>' +
|
|
658
|
+
'<label class="fb-label" for="fb-message">What looks wrong? <span class="fb-req">*</span></label>' +
|
|
659
|
+
'<textarea id="fb-message" rows="5" placeholder="e.g. this finding is a false positive because…"></textarea>' +
|
|
660
|
+
'<label class="fb-label" for="fb-contact">Email (optional — so we can follow up)</label>' +
|
|
661
|
+
'<input id="fb-contact" type="email" placeholder="you@example.com" autocomplete="email">' +
|
|
662
|
+
'<p class="fb-status" id="fb-status" hidden></p>' +
|
|
663
|
+
'<div class="fb-actions">' +
|
|
664
|
+
'<button class="fb-cancel" id="fb-cancel" type="button">Cancel</button>' +
|
|
665
|
+
'<button class="fb-submit" id="fb-submit" type="button">Send feedback</button>' +
|
|
666
|
+
'</div></div></div>'
|
|
667
|
+
: '';
|
|
668
|
+
// Inject the endpoint + scan context for the feedback script (safely encoded).
|
|
669
|
+
const fbBootstrap = feedbackEnabled
|
|
670
|
+
? `<script>window.__EXTENSHI_FB__=${jsonForScript({
|
|
671
|
+
endpoint: meta.feedbackEndpoint,
|
|
672
|
+
jobId: summary.jobId ?? null,
|
|
673
|
+
cliVersion: meta.cliVersion,
|
|
674
|
+
})};</script>`
|
|
675
|
+
: '';
|
|
676
|
+
const nav = '<nav class="nav">' +
|
|
677
|
+
'<a href="#verdict"><span>Verdict</span></a>' +
|
|
678
|
+
`<a href="#compliance"><span>Store compliance</span><span class="n">${compliance.present ? compliance.findings.length : '–'}</span></a>` +
|
|
679
|
+
`<a href="#security"><span>Security findings</span><span class="n">${summary.total}</span></a>` +
|
|
680
|
+
`<a href="#scanners"><span>Coverage</span><span class="n">${summary.scannersOk}/${summary.scannersRun}</span></a>` +
|
|
681
|
+
'</nav>';
|
|
682
|
+
const verdictSection = `<section class="card verdict ${verdict.level}" id="verdict">` +
|
|
683
|
+
`<p class="headline">${esc(verdict.headline)}</p>` +
|
|
684
|
+
`<p class="detail">${esc(verdict.detail)}</p>` +
|
|
685
|
+
renderStats(summary, groups.length) +
|
|
686
|
+
'</section>';
|
|
687
|
+
const metaLine = '<p class="meta">' +
|
|
688
|
+
`<span class="mono">${esc(meta.artifactName)}</span> · ` +
|
|
689
|
+
`@extenshi/cli v${esc(meta.cliVersion)} · ${esc(meta.generatedAt.toISOString())}${job}` +
|
|
690
|
+
'</p>';
|
|
691
|
+
const footer = '<section class="card"><p class="disclaimer">' +
|
|
692
|
+
'This scan is automated. The scanners are heuristics that may miss real issues or over-flag benign ' +
|
|
693
|
+
'code — a clean result is not a guarantee of safety or store approval. ' +
|
|
694
|
+
'See the <a href="https://catalog.extenshi.io/disclaimers/security-risk" target="_blank" rel="noreferrer">security-risk disclaimer</a>. ' +
|
|
695
|
+
'Manage claims and respond to findings at <a href="https://dojo.extenshi.io" target="_blank" rel="noreferrer">dojo.extenshi.io</a>.' +
|
|
696
|
+
'</p></section>';
|
|
697
|
+
const head = '<!doctype html><html lang="en"><head><meta charset="utf-8">' +
|
|
698
|
+
'<meta name="viewport" content="width=device-width, initial-scale=1">' +
|
|
699
|
+
`<title>${esc(title)}</title>` +
|
|
700
|
+
// Set the theme before paint to avoid a flash of the wrong palette.
|
|
701
|
+
"<script>(function(){try{var t=localStorage.getItem('extenshi-report-theme');if(!t)t=(window.matchMedia&&window.matchMedia('(prefers-color-scheme: dark)').matches)?'dark':'light';if(t==='dark')document.documentElement.classList.add('dark');}catch(e){}})();</script>" +
|
|
702
|
+
`<style>${STYLE}</style></head>`;
|
|
703
|
+
const topbar = '<header class="topbar">' +
|
|
704
|
+
`<span class="logo">${LOGO_SVG}</span>` +
|
|
705
|
+
'<span class="brand">extenshi</span>' +
|
|
706
|
+
'<span class="sep">·</span>' +
|
|
707
|
+
'<span class="subtitle">Security scan report</span>' +
|
|
708
|
+
'<span class="spacer"></span>' +
|
|
709
|
+
(feedbackEnabled
|
|
710
|
+
? '<button class="toggle fb-open" id="fb-open-general" type="button">🐞 Leave feedback</button>'
|
|
711
|
+
: '') +
|
|
712
|
+
'<button class="toggle" id="theme-toggle" type="button" aria-label="Toggle colour theme"></button>' +
|
|
713
|
+
'</header>';
|
|
714
|
+
const main = '<div class="layout">' +
|
|
715
|
+
nav +
|
|
716
|
+
'<div class="content">' +
|
|
717
|
+
metaLine +
|
|
718
|
+
verdictSection +
|
|
719
|
+
renderComplianceSection(compliance) +
|
|
720
|
+
renderSecuritySection(groups, feedbackEnabled) +
|
|
721
|
+
renderCoverageSection(summary) +
|
|
722
|
+
footer +
|
|
723
|
+
'</div></div>';
|
|
724
|
+
return `${head}<body>${topbar}${main}${fbModal}${fbBootstrap}<script>${SCRIPT}</script></body></html>\n`;
|
|
725
|
+
}
|
|
726
|
+
//# sourceMappingURL=html-report.js.map
|