@slashgear/gdpr-cookie-scanner 1.4.0 → 2.0.1
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/.dockerignore +13 -0
- package/.github/workflows/ci.yml +2 -2
- package/.github/workflows/pages.yml +40 -0
- package/.github/workflows/release.yml +1 -1
- package/.nvmrc +1 -0
- package/CHANGELOG.md +73 -0
- package/Dockerfile +36 -0
- package/README.md +42 -15
- package/dist/cli.js +21 -3
- package/dist/cli.js.map +1 -1
- package/dist/report/generator.d.ts +1 -4
- package/dist/report/generator.d.ts.map +1 -1
- package/dist/report/generator.js +45 -23
- package/dist/report/generator.js.map +1 -1
- package/dist/report/html.d.ts +3 -0
- package/dist/report/html.d.ts.map +1 -0
- package/dist/report/html.js +766 -0
- package/dist/report/html.js.map +1 -0
- package/dist/types.d.ts +2 -0
- package/dist/types.d.ts.map +1 -1
- package/docs/index.html +314 -0
- package/docs/reports/github.com/after-accept.png +0 -0
- package/docs/reports/github.com/after-reject.png +0 -0
- package/docs/reports/github.com/gdpr-checklist-github.com-2026-02-22.md +44 -0
- package/docs/reports/github.com/gdpr-cookies-github.com-2026-02-22.md +29 -0
- package/docs/reports/github.com/gdpr-report-github.com-2026-02-22.md +102 -0
- package/docs/reports/github.com/gdpr-report-github.com-2026-02-22.pdf +0 -0
- package/docs/reports/gitlab.com/after-accept.png +0 -0
- package/docs/reports/gitlab.com/after-reject.png +0 -0
- package/docs/reports/gitlab.com/gdpr-checklist-gitlab.com-2026-02-22.md +44 -0
- package/docs/reports/gitlab.com/gdpr-cookies-gitlab.com-2026-02-22.md +55 -0
- package/docs/reports/gitlab.com/gdpr-report-gitlab.com-2026-02-22.md +200 -0
- package/docs/reports/gitlab.com/gdpr-report-gitlab.com-2026-02-22.pdf +0 -0
- package/docs/reports/gitlab.com/modal-initial.png +0 -0
- package/docs/reports/npmjs.com/after-accept.png +0 -0
- package/docs/reports/npmjs.com/after-reject.png +0 -0
- package/docs/reports/npmjs.com/gdpr-checklist-npmjs.com-2026-02-22.md +44 -0
- package/docs/reports/npmjs.com/gdpr-cookies-npmjs.com-2026-02-22.md +25 -0
- package/docs/reports/npmjs.com/gdpr-report-npmjs.com-2026-02-22.md +88 -0
- package/docs/reports/npmjs.com/gdpr-report-npmjs.com-2026-02-22.pdf +0 -0
- package/docs/reports/reddit.com/after-accept.png +0 -0
- package/docs/reports/reddit.com/after-reject.png +0 -0
- package/docs/reports/reddit.com/gdpr-checklist-reddit.com-2026-02-22.md +44 -0
- package/docs/reports/reddit.com/gdpr-cookies-reddit.com-2026-02-22.md +33 -0
- package/docs/reports/reddit.com/gdpr-report-reddit.com-2026-02-22.md +148 -0
- package/docs/reports/reddit.com/gdpr-report-reddit.com-2026-02-22.pdf +0 -0
- package/docs/reports/reddit.com/modal-initial.png +0 -0
- package/docs/reports/stackoverflow.com/after-accept.png +0 -0
- package/docs/reports/stackoverflow.com/after-reject.png +0 -0
- package/docs/reports/stackoverflow.com/gdpr-checklist-stackoverflow.com-2026-02-22.md +44 -0
- package/docs/reports/stackoverflow.com/gdpr-cookies-stackoverflow.com-2026-02-22.md +67 -0
- package/docs/reports/stackoverflow.com/gdpr-report-stackoverflow.com-2026-02-22.md +206 -0
- package/docs/reports/stackoverflow.com/gdpr-report-stackoverflow.com-2026-02-22.pdf +0 -0
- package/docs/reports/stackoverflow.com/modal-initial.png +0 -0
- package/docs/reports/www.afp.com/after-accept.png +0 -0
- package/docs/reports/www.afp.com/after-reject.png +0 -0
- package/docs/reports/www.afp.com/gdpr-checklist-afp.com-2026-02-22.md +44 -0
- package/docs/reports/www.afp.com/gdpr-cookies-afp.com-2026-02-22.md +42 -0
- package/docs/reports/www.afp.com/gdpr-report-afp.com-2026-02-22.md +202 -0
- package/docs/reports/www.afp.com/gdpr-report-afp.com-2026-02-22.pdf +0 -0
- package/docs/reports/www.afp.com/modal-initial.png +0 -0
- package/docs/style.css +439 -0
- package/package.json +6 -6
- package/src/cli.ts +28 -4
- package/src/report/generator.ts +54 -29
- package/src/report/html.ts +940 -0
- package/src/types.ts +3 -0
|
@@ -0,0 +1,940 @@
|
|
|
1
|
+
import type { ScanResult, ScannedCookie, DarkPatternIssue, ConsentButton } from "../types.js";
|
|
2
|
+
|
|
3
|
+
const GRADE_COLOR: Record<string, string> = {
|
|
4
|
+
A: "#16a34a",
|
|
5
|
+
B: "#65a30d",
|
|
6
|
+
C: "#ca8a04",
|
|
7
|
+
D: "#ea580c",
|
|
8
|
+
F: "#dc2626",
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const GRADE_BG: Record<string, string> = {
|
|
12
|
+
A: "#f0fdf4",
|
|
13
|
+
B: "#f7fee7",
|
|
14
|
+
C: "#fefce8",
|
|
15
|
+
D: "#fff7ed",
|
|
16
|
+
F: "#fef2f2",
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export function generateHtmlReport(result: ScanResult): string {
|
|
20
|
+
const hostname = new URL(result.url).hostname.replace(/^www\./, "");
|
|
21
|
+
const scanDate = new Date(result.scanDate).toLocaleString("en-GB", {
|
|
22
|
+
dateStyle: "long",
|
|
23
|
+
timeStyle: "short",
|
|
24
|
+
});
|
|
25
|
+
const durationSec = (result.duration / 1000).toFixed(1);
|
|
26
|
+
const { grade, total, breakdown, issues } = result.compliance;
|
|
27
|
+
const color = GRADE_COLOR[grade] ?? "#64748b";
|
|
28
|
+
const bg = GRADE_BG[grade] ?? "#f8fafc";
|
|
29
|
+
const criticalIssues = issues.filter((i) => i.severity === "critical");
|
|
30
|
+
const warningIssues = issues.filter((i) => i.severity === "warning");
|
|
31
|
+
|
|
32
|
+
return `<!DOCTYPE html>
|
|
33
|
+
<html lang="en">
|
|
34
|
+
<head>
|
|
35
|
+
<meta charset="UTF-8">
|
|
36
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
37
|
+
<title>GDPR Report — ${esc(hostname)}</title>
|
|
38
|
+
<style>
|
|
39
|
+
*, *::before, *::after { box-sizing: border-box; }
|
|
40
|
+
|
|
41
|
+
:root {
|
|
42
|
+
--grade: ${color};
|
|
43
|
+
--grade-bg: ${bg};
|
|
44
|
+
--surface: #ffffff;
|
|
45
|
+
--bg: #f1f5f9;
|
|
46
|
+
--border: #e2e8f0;
|
|
47
|
+
--text: #0f172a;
|
|
48
|
+
--text-muted: #64748b;
|
|
49
|
+
--critical: #dc2626;
|
|
50
|
+
--critical-bg: #fef2f2;
|
|
51
|
+
--critical-border: #fecaca;
|
|
52
|
+
--warning: #d97706;
|
|
53
|
+
--warning-bg: #fffbeb;
|
|
54
|
+
--warning-border: #fde68a;
|
|
55
|
+
--ok: #16a34a;
|
|
56
|
+
--ok-bg: #f0fdf4;
|
|
57
|
+
--ok-border: #bbf7d0;
|
|
58
|
+
--radius: 10px;
|
|
59
|
+
--shadow: 0 1px 3px rgba(0,0,0,.08), 0 1px 2px rgba(0,0,0,.06);
|
|
60
|
+
--shadow-md: 0 4px 6px rgba(0,0,0,.07), 0 2px 4px rgba(0,0,0,.06);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
body {
|
|
64
|
+
margin: 0;
|
|
65
|
+
background: var(--bg);
|
|
66
|
+
color: var(--text);
|
|
67
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
|
|
68
|
+
font-size: 14px;
|
|
69
|
+
line-height: 1.6;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/* ── Layout ── */
|
|
73
|
+
.page { max-width: 1000px; margin: 0 auto; padding: 24px 16px 64px; }
|
|
74
|
+
|
|
75
|
+
/* ── Hero ── */
|
|
76
|
+
.hero {
|
|
77
|
+
background: var(--surface);
|
|
78
|
+
border-radius: var(--radius);
|
|
79
|
+
box-shadow: var(--shadow-md);
|
|
80
|
+
padding: 32px 36px;
|
|
81
|
+
display: flex;
|
|
82
|
+
align-items: center;
|
|
83
|
+
gap: 32px;
|
|
84
|
+
margin-bottom: 20px;
|
|
85
|
+
border-top: 4px solid var(--grade);
|
|
86
|
+
}
|
|
87
|
+
.grade-badge {
|
|
88
|
+
flex-shrink: 0;
|
|
89
|
+
width: 80px; height: 80px;
|
|
90
|
+
border-radius: 16px;
|
|
91
|
+
background: var(--grade);
|
|
92
|
+
color: #fff;
|
|
93
|
+
font-size: 42px;
|
|
94
|
+
font-weight: 800;
|
|
95
|
+
display: flex;
|
|
96
|
+
align-items: center;
|
|
97
|
+
justify-content: center;
|
|
98
|
+
letter-spacing: -2px;
|
|
99
|
+
}
|
|
100
|
+
.hero-info { flex: 1; min-width: 0; }
|
|
101
|
+
.hero-info h1 {
|
|
102
|
+
margin: 0 0 4px;
|
|
103
|
+
font-size: 22px;
|
|
104
|
+
font-weight: 700;
|
|
105
|
+
color: var(--text);
|
|
106
|
+
white-space: nowrap;
|
|
107
|
+
overflow: hidden;
|
|
108
|
+
text-overflow: ellipsis;
|
|
109
|
+
}
|
|
110
|
+
.hero-meta {
|
|
111
|
+
font-size: 13px;
|
|
112
|
+
color: var(--text-muted);
|
|
113
|
+
margin: 0;
|
|
114
|
+
}
|
|
115
|
+
.hero-score {
|
|
116
|
+
flex-shrink: 0;
|
|
117
|
+
text-align: right;
|
|
118
|
+
}
|
|
119
|
+
.hero-score .score-num {
|
|
120
|
+
font-size: 40px;
|
|
121
|
+
font-weight: 800;
|
|
122
|
+
color: var(--grade);
|
|
123
|
+
line-height: 1;
|
|
124
|
+
}
|
|
125
|
+
.hero-score .score-den { font-size: 18px; color: var(--text-muted); font-weight: 400; }
|
|
126
|
+
.hero-score .score-label { font-size: 12px; color: var(--text-muted); margin-top: 4px; }
|
|
127
|
+
|
|
128
|
+
/* ── Score grid ── */
|
|
129
|
+
.score-grid {
|
|
130
|
+
display: grid;
|
|
131
|
+
grid-template-columns: repeat(4, 1fr);
|
|
132
|
+
gap: 12px;
|
|
133
|
+
margin-bottom: 20px;
|
|
134
|
+
}
|
|
135
|
+
@media (max-width: 640px) {
|
|
136
|
+
.score-grid { grid-template-columns: repeat(2, 1fr); }
|
|
137
|
+
}
|
|
138
|
+
.score-card {
|
|
139
|
+
background: var(--surface);
|
|
140
|
+
border-radius: var(--radius);
|
|
141
|
+
padding: 16px 18px;
|
|
142
|
+
box-shadow: var(--shadow);
|
|
143
|
+
}
|
|
144
|
+
.score-card-label {
|
|
145
|
+
font-size: 11px;
|
|
146
|
+
font-weight: 600;
|
|
147
|
+
text-transform: uppercase;
|
|
148
|
+
letter-spacing: .05em;
|
|
149
|
+
color: var(--text-muted);
|
|
150
|
+
margin-bottom: 8px;
|
|
151
|
+
}
|
|
152
|
+
.score-card-value {
|
|
153
|
+
font-size: 22px;
|
|
154
|
+
font-weight: 700;
|
|
155
|
+
color: var(--text);
|
|
156
|
+
margin-bottom: 8px;
|
|
157
|
+
}
|
|
158
|
+
.score-card-value span { font-size: 14px; font-weight: 400; color: var(--text-muted); }
|
|
159
|
+
.progress-track {
|
|
160
|
+
height: 6px;
|
|
161
|
+
background: var(--border);
|
|
162
|
+
border-radius: 3px;
|
|
163
|
+
overflow: hidden;
|
|
164
|
+
}
|
|
165
|
+
.progress-fill {
|
|
166
|
+
height: 100%;
|
|
167
|
+
border-radius: 3px;
|
|
168
|
+
background: var(--grade-color, #64748b);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/* ── Section ── */
|
|
172
|
+
.section {
|
|
173
|
+
background: var(--surface);
|
|
174
|
+
border-radius: var(--radius);
|
|
175
|
+
box-shadow: var(--shadow);
|
|
176
|
+
margin-bottom: 16px;
|
|
177
|
+
overflow: hidden;
|
|
178
|
+
}
|
|
179
|
+
.section-header {
|
|
180
|
+
padding: 16px 20px;
|
|
181
|
+
border-bottom: 1px solid var(--border);
|
|
182
|
+
display: flex;
|
|
183
|
+
align-items: center;
|
|
184
|
+
gap: 10px;
|
|
185
|
+
}
|
|
186
|
+
.section-header h2 {
|
|
187
|
+
margin: 0;
|
|
188
|
+
font-size: 15px;
|
|
189
|
+
font-weight: 600;
|
|
190
|
+
color: var(--text);
|
|
191
|
+
}
|
|
192
|
+
.section-body { padding: 20px; }
|
|
193
|
+
.section-body.no-pad { padding: 0; }
|
|
194
|
+
|
|
195
|
+
/* ── Badges ── */
|
|
196
|
+
.badge {
|
|
197
|
+
display: inline-flex;
|
|
198
|
+
align-items: center;
|
|
199
|
+
gap: 4px;
|
|
200
|
+
padding: 2px 8px;
|
|
201
|
+
border-radius: 99px;
|
|
202
|
+
font-size: 11px;
|
|
203
|
+
font-weight: 600;
|
|
204
|
+
}
|
|
205
|
+
.badge-critical { background: var(--critical-bg); color: var(--critical); border: 1px solid var(--critical-border); }
|
|
206
|
+
.badge-warning { background: var(--warning-bg); color: var(--warning); border: 1px solid var(--warning-border); }
|
|
207
|
+
.badge-ok { background: var(--ok-bg); color: var(--ok); border: 1px solid var(--ok-border); }
|
|
208
|
+
.badge-muted { background: var(--bg); color: var(--text-muted); border: 1px solid var(--border); }
|
|
209
|
+
.count-badge {
|
|
210
|
+
background: var(--bg);
|
|
211
|
+
color: var(--text-muted);
|
|
212
|
+
font-size: 12px;
|
|
213
|
+
font-weight: 600;
|
|
214
|
+
padding: 1px 8px;
|
|
215
|
+
border-radius: 99px;
|
|
216
|
+
margin-left: auto;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/* ── Issue cards ── */
|
|
220
|
+
.issue-list { display: flex; flex-direction: column; gap: 10px; }
|
|
221
|
+
.issue-card {
|
|
222
|
+
border-radius: 8px;
|
|
223
|
+
padding: 14px 16px;
|
|
224
|
+
border: 1px solid;
|
|
225
|
+
}
|
|
226
|
+
.issue-card.critical { background: var(--critical-bg); border-color: var(--critical-border); }
|
|
227
|
+
.issue-card.warning { background: var(--warning-bg); border-color: var(--warning-border); }
|
|
228
|
+
.issue-title {
|
|
229
|
+
font-size: 13px;
|
|
230
|
+
font-weight: 600;
|
|
231
|
+
margin-bottom: 4px;
|
|
232
|
+
}
|
|
233
|
+
.issue-card.critical .issue-title { color: var(--critical); }
|
|
234
|
+
.issue-card.warning .issue-title { color: var(--warning); }
|
|
235
|
+
.issue-evidence {
|
|
236
|
+
font-size: 12px;
|
|
237
|
+
color: var(--text-muted);
|
|
238
|
+
margin: 0;
|
|
239
|
+
}
|
|
240
|
+
.no-issues {
|
|
241
|
+
display: flex;
|
|
242
|
+
align-items: center;
|
|
243
|
+
gap: 8px;
|
|
244
|
+
color: var(--ok);
|
|
245
|
+
font-weight: 500;
|
|
246
|
+
font-size: 14px;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/* ── Tables ── */
|
|
250
|
+
.data-table {
|
|
251
|
+
width: 100%;
|
|
252
|
+
border-collapse: collapse;
|
|
253
|
+
font-size: 13px;
|
|
254
|
+
}
|
|
255
|
+
.data-table th {
|
|
256
|
+
background: var(--bg);
|
|
257
|
+
padding: 10px 14px;
|
|
258
|
+
text-align: left;
|
|
259
|
+
font-size: 11px;
|
|
260
|
+
font-weight: 600;
|
|
261
|
+
text-transform: uppercase;
|
|
262
|
+
letter-spacing: .04em;
|
|
263
|
+
color: var(--text-muted);
|
|
264
|
+
border-bottom: 1px solid var(--border);
|
|
265
|
+
white-space: nowrap;
|
|
266
|
+
}
|
|
267
|
+
.data-table td {
|
|
268
|
+
padding: 10px 14px;
|
|
269
|
+
border-bottom: 1px solid var(--border);
|
|
270
|
+
vertical-align: top;
|
|
271
|
+
}
|
|
272
|
+
.data-table tr:last-child td { border-bottom: none; }
|
|
273
|
+
.data-table tr:hover td { background: #fafafa; }
|
|
274
|
+
code {
|
|
275
|
+
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
|
|
276
|
+
font-size: 12px;
|
|
277
|
+
background: var(--bg);
|
|
278
|
+
padding: 1px 6px;
|
|
279
|
+
border-radius: 4px;
|
|
280
|
+
border: 1px solid var(--border);
|
|
281
|
+
}
|
|
282
|
+
.empty-state {
|
|
283
|
+
text-align: center;
|
|
284
|
+
padding: 32px;
|
|
285
|
+
color: var(--text-muted);
|
|
286
|
+
font-size: 13px;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/* ── Checklist status ── */
|
|
290
|
+
.status-ok { color: var(--ok); font-weight: 600; }
|
|
291
|
+
.status-ko { color: var(--critical); font-weight: 600; }
|
|
292
|
+
.status-warn { color: var(--warning); font-weight: 600; }
|
|
293
|
+
|
|
294
|
+
/* ── Info grid ── */
|
|
295
|
+
.info-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
|
296
|
+
@media (max-width: 640px) { .info-grid { grid-template-columns: 1fr; } }
|
|
297
|
+
.info-item { }
|
|
298
|
+
.info-label { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; color: var(--text-muted); margin-bottom: 2px; }
|
|
299
|
+
.info-value { font-size: 13px; color: var(--text); }
|
|
300
|
+
|
|
301
|
+
/* ── Buttons table ── */
|
|
302
|
+
.btn-chip {
|
|
303
|
+
display: inline-block;
|
|
304
|
+
padding: 2px 10px;
|
|
305
|
+
border-radius: 4px;
|
|
306
|
+
font-size: 12px;
|
|
307
|
+
font-weight: 600;
|
|
308
|
+
}
|
|
309
|
+
.btn-chip.accept { background: #dcfce7; color: #166534; }
|
|
310
|
+
.btn-chip.reject { background: #fee2e2; color: #991b1b; }
|
|
311
|
+
.btn-chip.preferences { background: #dbeafe; color: #1e40af; }
|
|
312
|
+
.btn-chip.unknown, .btn-chip.close { background: var(--bg); color: var(--text-muted); }
|
|
313
|
+
|
|
314
|
+
/* ── Recommendations ── */
|
|
315
|
+
.rec-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
|
|
316
|
+
.rec-item {
|
|
317
|
+
display: flex;
|
|
318
|
+
gap: 12px;
|
|
319
|
+
padding: 12px 14px;
|
|
320
|
+
background: #f8fafc;
|
|
321
|
+
border-radius: 8px;
|
|
322
|
+
border: 1px solid var(--border);
|
|
323
|
+
font-size: 13px;
|
|
324
|
+
}
|
|
325
|
+
.rec-num {
|
|
326
|
+
flex-shrink: 0;
|
|
327
|
+
width: 24px; height: 24px;
|
|
328
|
+
border-radius: 50%;
|
|
329
|
+
background: var(--text);
|
|
330
|
+
color: #fff;
|
|
331
|
+
font-size: 12px;
|
|
332
|
+
font-weight: 700;
|
|
333
|
+
display: flex;
|
|
334
|
+
align-items: center;
|
|
335
|
+
justify-content: center;
|
|
336
|
+
margin-top: 1px;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/* ── Footer ── */
|
|
340
|
+
.footer {
|
|
341
|
+
text-align: center;
|
|
342
|
+
margin-top: 40px;
|
|
343
|
+
font-size: 12px;
|
|
344
|
+
color: var(--text-muted);
|
|
345
|
+
}
|
|
346
|
+
.footer a { color: var(--text-muted); }
|
|
347
|
+
|
|
348
|
+
@media print {
|
|
349
|
+
body { background: #fff; }
|
|
350
|
+
.section { box-shadow: none; border: 1px solid var(--border); }
|
|
351
|
+
.page { padding: 0; }
|
|
352
|
+
}
|
|
353
|
+
</style>
|
|
354
|
+
</head>
|
|
355
|
+
<body>
|
|
356
|
+
<div class="page">
|
|
357
|
+
|
|
358
|
+
${buildHero(hostname, scanDate, durationSec, grade, total, color)}
|
|
359
|
+
|
|
360
|
+
${buildScoreGrid(breakdown)}
|
|
361
|
+
|
|
362
|
+
${buildIssuesSection(criticalIssues, warningIssues)}
|
|
363
|
+
|
|
364
|
+
${buildModalSection(result)}
|
|
365
|
+
|
|
366
|
+
${buildCookiesSection(result)}
|
|
367
|
+
|
|
368
|
+
${buildNetworkSection(result)}
|
|
369
|
+
|
|
370
|
+
${buildRecommendationsSection(result)}
|
|
371
|
+
|
|
372
|
+
${buildChecklistSection(result)}
|
|
373
|
+
|
|
374
|
+
<div class="footer">
|
|
375
|
+
Generated by <a href="https://github.com/Slashgear/gdpr-report">gdpr-cookie-scanner</a>
|
|
376
|
+
on ${esc(scanDate)}
|
|
377
|
+
</div>
|
|
378
|
+
|
|
379
|
+
</div>
|
|
380
|
+
</body>
|
|
381
|
+
</html>`;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// ── Hero ──────────────────────────────────────────────────────────────────────
|
|
385
|
+
|
|
386
|
+
function buildHero(
|
|
387
|
+
hostname: string,
|
|
388
|
+
scanDate: string,
|
|
389
|
+
durationSec: string,
|
|
390
|
+
grade: string,
|
|
391
|
+
total: number,
|
|
392
|
+
color: string,
|
|
393
|
+
): string {
|
|
394
|
+
return `<div class="hero">
|
|
395
|
+
<div class="grade-badge" style="background:${color}">${esc(grade)}</div>
|
|
396
|
+
<div class="hero-info">
|
|
397
|
+
<h1>${esc(hostname)}</h1>
|
|
398
|
+
<p class="hero-meta">Scanned on ${esc(scanDate)} · ${durationSec}s</p>
|
|
399
|
+
</div>
|
|
400
|
+
<div class="hero-score">
|
|
401
|
+
<div><span class="score-num" style="color:${color}">${total}</span><span class="score-den">/100</span></div>
|
|
402
|
+
<div class="score-label">Compliance score</div>
|
|
403
|
+
</div>
|
|
404
|
+
</div>`;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// ── Score grid ────────────────────────────────────────────────────────────────
|
|
408
|
+
|
|
409
|
+
function buildScoreGrid(breakdown: {
|
|
410
|
+
consentValidity: number;
|
|
411
|
+
easyRefusal: number;
|
|
412
|
+
transparency: number;
|
|
413
|
+
cookieBehavior: number;
|
|
414
|
+
}): string {
|
|
415
|
+
const card = (label: string, value: number) => {
|
|
416
|
+
const pct = Math.round((value / 25) * 100);
|
|
417
|
+
const color =
|
|
418
|
+
pct >= 80
|
|
419
|
+
? GRADE_COLOR.A
|
|
420
|
+
: pct >= 60
|
|
421
|
+
? GRADE_COLOR.C
|
|
422
|
+
: pct >= 40
|
|
423
|
+
? GRADE_COLOR.D
|
|
424
|
+
: GRADE_COLOR.F;
|
|
425
|
+
return `<div class="score-card">
|
|
426
|
+
<div class="score-card-label">${label}</div>
|
|
427
|
+
<div class="score-card-value">${value}<span>/25</span></div>
|
|
428
|
+
<div class="progress-track">
|
|
429
|
+
<div class="progress-fill" style="width:${pct}%;background:${color}"></div>
|
|
430
|
+
</div>
|
|
431
|
+
</div>`;
|
|
432
|
+
};
|
|
433
|
+
|
|
434
|
+
return `<div class="score-grid">
|
|
435
|
+
${card("Consent validity", breakdown.consentValidity)}
|
|
436
|
+
${card("Easy refusal", breakdown.easyRefusal)}
|
|
437
|
+
${card("Transparency", breakdown.transparency)}
|
|
438
|
+
${card("Cookie behavior", breakdown.cookieBehavior)}
|
|
439
|
+
</div>`;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// ── Issues ────────────────────────────────────────────────────────────────────
|
|
443
|
+
|
|
444
|
+
function buildIssuesSection(
|
|
445
|
+
criticalIssues: DarkPatternIssue[],
|
|
446
|
+
warningIssues: DarkPatternIssue[],
|
|
447
|
+
): string {
|
|
448
|
+
if (criticalIssues.length === 0 && warningIssues.length === 0) {
|
|
449
|
+
return `<div class="section">
|
|
450
|
+
<div class="section-header">
|
|
451
|
+
<h2>Issues</h2>
|
|
452
|
+
</div>
|
|
453
|
+
<div class="section-body">
|
|
454
|
+
<div class="no-issues">✓ No compliance issue detected</div>
|
|
455
|
+
</div>
|
|
456
|
+
</div>`;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const cards = [
|
|
460
|
+
...criticalIssues.map(issueCard("critical")),
|
|
461
|
+
...warningIssues.map(issueCard("warning")),
|
|
462
|
+
].join("\n");
|
|
463
|
+
|
|
464
|
+
const total = criticalIssues.length + warningIssues.length;
|
|
465
|
+
|
|
466
|
+
return `<div class="section">
|
|
467
|
+
<div class="section-header">
|
|
468
|
+
<h2>Issues</h2>
|
|
469
|
+
<span class="count-badge">${total}</span>
|
|
470
|
+
${criticalIssues.length > 0 ? `<span class="badge badge-critical">${criticalIssues.length} critical</span>` : ""}
|
|
471
|
+
${warningIssues.length > 0 ? `<span class="badge badge-warning">${warningIssues.length} warning${warningIssues.length > 1 ? "s" : ""}</span>` : ""}
|
|
472
|
+
</div>
|
|
473
|
+
<div class="section-body">
|
|
474
|
+
<div class="issue-list">
|
|
475
|
+
${cards}
|
|
476
|
+
</div>
|
|
477
|
+
</div>
|
|
478
|
+
</div>`;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function issueCard(severity: "critical" | "warning") {
|
|
482
|
+
return (issue: DarkPatternIssue) => `<div class="issue-card ${severity}">
|
|
483
|
+
<div class="issue-title">${esc(issue.description)}</div>
|
|
484
|
+
<p class="issue-evidence">${esc(issue.evidence)}</p>
|
|
485
|
+
</div>`;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// ── Consent modal ─────────────────────────────────────────────────────────────
|
|
489
|
+
|
|
490
|
+
function buildModalSection(result: ScanResult): string {
|
|
491
|
+
const { modal } = result;
|
|
492
|
+
|
|
493
|
+
if (!modal.detected) {
|
|
494
|
+
return `<div class="section">
|
|
495
|
+
<div class="section-header"><h2>Consent modal</h2></div>
|
|
496
|
+
<div class="section-body">
|
|
497
|
+
<span class="badge badge-critical">Not detected</span>
|
|
498
|
+
<p style="margin:12px 0 0;color:var(--text-muted);font-size:13px">No consent banner was found on the page.</p>
|
|
499
|
+
</div>
|
|
500
|
+
</div>`;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const privacyLink = modal.privacyPolicyUrl
|
|
504
|
+
? `<a href="${esc(modal.privacyPolicyUrl)}" target="_blank" rel="noopener">${esc(modal.privacyPolicyUrl)}</a>`
|
|
505
|
+
: `<span class="badge badge-warning">Not found</span>`;
|
|
506
|
+
|
|
507
|
+
const buttonsHtml =
|
|
508
|
+
modal.buttons.length === 0
|
|
509
|
+
? `<p style="color:var(--text-muted);font-size:13px">No buttons detected.</p>`
|
|
510
|
+
: `<table class="data-table">
|
|
511
|
+
<thead><tr>
|
|
512
|
+
<th>Type</th><th>Label</th><th>Font size</th><th>Contrast</th><th>Clicks</th>
|
|
513
|
+
</tr></thead>
|
|
514
|
+
<tbody>
|
|
515
|
+
${modal.buttons.map(buttonRow).join("\n")}
|
|
516
|
+
</tbody>
|
|
517
|
+
</table>`;
|
|
518
|
+
|
|
519
|
+
const preTicked = modal.checkboxes.filter((c) => c.isCheckedByDefault);
|
|
520
|
+
|
|
521
|
+
return `<div class="section">
|
|
522
|
+
<div class="section-header">
|
|
523
|
+
<h2>Consent modal</h2>
|
|
524
|
+
<span class="badge badge-ok">Detected</span>
|
|
525
|
+
</div>
|
|
526
|
+
<div class="section-body">
|
|
527
|
+
<div class="info-grid" style="margin-bottom:20px">
|
|
528
|
+
<div class="info-item">
|
|
529
|
+
<div class="info-label">Selector</div>
|
|
530
|
+
<div class="info-value"><code>${esc(modal.selector ?? "—")}</code></div>
|
|
531
|
+
</div>
|
|
532
|
+
<div class="info-item">
|
|
533
|
+
<div class="info-label">Granular controls</div>
|
|
534
|
+
<div class="info-value">${modal.hasGranularControls ? '<span class="status-ok">✓ Yes</span>' : '<span class="status-warn">✗ No</span>'}</div>
|
|
535
|
+
</div>
|
|
536
|
+
<div class="info-item">
|
|
537
|
+
<div class="info-label">Privacy policy link</div>
|
|
538
|
+
<div class="info-value">${privacyLink}</div>
|
|
539
|
+
</div>
|
|
540
|
+
<div class="info-item">
|
|
541
|
+
<div class="info-label">Pre-ticked checkboxes</div>
|
|
542
|
+
<div class="info-value">${preTicked.length === 0 ? '<span class="status-ok">✓ None</span>' : `<span class="status-ko">✗ ${preTicked.length} (${preTicked.map((c) => esc(c.label || c.name)).join(", ")})</span>`}</div>
|
|
543
|
+
</div>
|
|
544
|
+
</div>
|
|
545
|
+
<div style="font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:.04em;color:var(--text-muted);margin-bottom:8px">Buttons</div>
|
|
546
|
+
${buttonsHtml}
|
|
547
|
+
</div>
|
|
548
|
+
</div>`;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function buttonRow(b: ConsentButton): string {
|
|
552
|
+
const chip = `<span class="btn-chip ${b.type}">${esc(b.type)}</span>`;
|
|
553
|
+
const fontSize = b.fontSize ? `${b.fontSize}px` : "—";
|
|
554
|
+
const contrast = b.contrastRatio !== null ? `${b.contrastRatio}:1` : "—";
|
|
555
|
+
return `<tr>
|
|
556
|
+
<td>${chip}</td>
|
|
557
|
+
<td>${esc(b.text.substring(0, 40))}</td>
|
|
558
|
+
<td>${fontSize}</td>
|
|
559
|
+
<td>${contrast}</td>
|
|
560
|
+
<td>${b.clickDepth}</td>
|
|
561
|
+
</tr>`;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// ── Cookies ───────────────────────────────────────────────────────────────────
|
|
565
|
+
|
|
566
|
+
function buildCookiesSection(result: ScanResult): string {
|
|
567
|
+
const phases: Array<{
|
|
568
|
+
label: string;
|
|
569
|
+
cookies: ScannedCookie[];
|
|
570
|
+
phase: ScannedCookie["capturedAt"];
|
|
571
|
+
}> = [
|
|
572
|
+
{
|
|
573
|
+
label: "Before interaction",
|
|
574
|
+
cookies: result.cookiesBeforeInteraction,
|
|
575
|
+
phase: "before-interaction",
|
|
576
|
+
},
|
|
577
|
+
{ label: "After reject", cookies: result.cookiesAfterReject, phase: "after-reject" },
|
|
578
|
+
{ label: "After accept", cookies: result.cookiesAfterAccept, phase: "after-accept" },
|
|
579
|
+
];
|
|
580
|
+
|
|
581
|
+
const illegalPre = result.cookiesBeforeInteraction.filter((c) => c.requiresConsent);
|
|
582
|
+
const illegalPost = result.cookiesAfterReject.filter(
|
|
583
|
+
(c) => c.requiresConsent && c.capturedAt === "after-reject",
|
|
584
|
+
);
|
|
585
|
+
|
|
586
|
+
const phaseTables = phases
|
|
587
|
+
.map(({ label, cookies, phase }) => {
|
|
588
|
+
const filtered = cookies.filter((c) => c.capturedAt === phase);
|
|
589
|
+
return `<div style="margin-bottom:24px">
|
|
590
|
+
<div style="font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:.04em;color:var(--text-muted);margin-bottom:8px;display:flex;align-items:center;gap:8px">
|
|
591
|
+
${esc(label)}
|
|
592
|
+
<span class="count-badge">${filtered.length}</span>
|
|
593
|
+
${phase === "before-interaction" && illegalPre.length > 0 ? `<span class="badge badge-critical">${illegalPre.length} non-essential</span>` : ""}
|
|
594
|
+
${phase === "after-reject" && illegalPost.length > 0 ? `<span class="badge badge-critical">${illegalPost.length} non-essential</span>` : ""}
|
|
595
|
+
</div>
|
|
596
|
+
${cookieTable(filtered)}
|
|
597
|
+
</div>`;
|
|
598
|
+
})
|
|
599
|
+
.join("\n");
|
|
600
|
+
|
|
601
|
+
return `<div class="section">
|
|
602
|
+
<div class="section-header"><h2>Cookies</h2></div>
|
|
603
|
+
<div class="section-body">
|
|
604
|
+
${phaseTables}
|
|
605
|
+
</div>
|
|
606
|
+
</div>`;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function cookieTable(cookies: ScannedCookie[]): string {
|
|
610
|
+
if (cookies.length === 0) {
|
|
611
|
+
return `<p class="empty-state">No cookies detected.</p>`;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
const rows = cookies
|
|
615
|
+
.map((c) => {
|
|
616
|
+
const consent = c.requiresConsent
|
|
617
|
+
? `<span class="badge badge-warning">Required</span>`
|
|
618
|
+
: `<span class="badge badge-muted">No</span>`;
|
|
619
|
+
return `<tr>
|
|
620
|
+
<td><code>${esc(c.name)}</code></td>
|
|
621
|
+
<td style="color:var(--text-muted)">${esc(c.domain)}</td>
|
|
622
|
+
<td><span class="badge badge-muted">${esc(c.category)}</span></td>
|
|
623
|
+
<td style="color:var(--text-muted)">${formatExpiry(c)}</td>
|
|
624
|
+
<td>${consent}</td>
|
|
625
|
+
</tr>`;
|
|
626
|
+
})
|
|
627
|
+
.join("\n");
|
|
628
|
+
|
|
629
|
+
return `<table class="data-table">
|
|
630
|
+
<thead><tr>
|
|
631
|
+
<th>Name</th><th>Domain</th><th>Category</th><th>Expiry</th><th>Consent</th>
|
|
632
|
+
</tr></thead>
|
|
633
|
+
<tbody>${rows}</tbody>
|
|
634
|
+
</table>`;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function formatExpiry(c: ScannedCookie): string {
|
|
638
|
+
if (c.expires === null) return "Session";
|
|
639
|
+
const days = Math.round((c.expires * 1000 - Date.now()) / 86_400_000);
|
|
640
|
+
if (days < 0) return "Expired";
|
|
641
|
+
if (days === 0) return "< 1 day";
|
|
642
|
+
if (days < 30) return `${days}d`;
|
|
643
|
+
return `${Math.round(days / 30)}mo`;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// ── Network ───────────────────────────────────────────────────────────────────
|
|
647
|
+
|
|
648
|
+
function buildNetworkSection(result: ScanResult): string {
|
|
649
|
+
const trackers = [
|
|
650
|
+
...result.networkBeforeInteraction,
|
|
651
|
+
...result.networkAfterReject,
|
|
652
|
+
...result.networkAfterAccept,
|
|
653
|
+
].filter((r) => r.trackerCategory !== null);
|
|
654
|
+
|
|
655
|
+
if (trackers.length === 0) {
|
|
656
|
+
return `<div class="section">
|
|
657
|
+
<div class="section-header"><h2>Network trackers</h2></div>
|
|
658
|
+
<div class="section-body">
|
|
659
|
+
<div class="no-issues">✓ No network tracker detected</div>
|
|
660
|
+
</div>
|
|
661
|
+
</div>`;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
const preTrackers = result.networkBeforeInteraction.filter((r) => r.trackerCategory !== null);
|
|
665
|
+
|
|
666
|
+
const rows = trackers
|
|
667
|
+
.slice(0, 50)
|
|
668
|
+
.map((req) => {
|
|
669
|
+
const isBefore = req.capturedAt === "before-interaction";
|
|
670
|
+
const url = req.url.length > 70 ? req.url.substring(0, 67) + "…" : req.url;
|
|
671
|
+
return `<tr>
|
|
672
|
+
<td>${esc(req.trackerName ?? "Unknown")}</td>
|
|
673
|
+
<td><span class="badge badge-muted">${esc(req.trackerCategory ?? "")}</span></td>
|
|
674
|
+
<td>${isBefore ? `<span class="badge badge-critical">before consent</span>` : `<span class="badge badge-muted">${esc(req.capturedAt)}</span>`}</td>
|
|
675
|
+
<td style="font-size:11px;color:var(--text-muted);word-break:break-all"><code>${esc(url)}</code></td>
|
|
676
|
+
</tr>`;
|
|
677
|
+
})
|
|
678
|
+
.join("\n");
|
|
679
|
+
|
|
680
|
+
return `<div class="section">
|
|
681
|
+
<div class="section-header">
|
|
682
|
+
<h2>Network trackers</h2>
|
|
683
|
+
<span class="count-badge">${trackers.length}</span>
|
|
684
|
+
${preTrackers.length > 0 ? `<span class="badge badge-critical">${preTrackers.length} before consent</span>` : ""}
|
|
685
|
+
</div>
|
|
686
|
+
<div class="section-body no-pad">
|
|
687
|
+
<table class="data-table">
|
|
688
|
+
<thead><tr><th>Tracker</th><th>Category</th><th>Phase</th><th>URL</th></tr></thead>
|
|
689
|
+
<tbody>${rows}</tbody>
|
|
690
|
+
</table>
|
|
691
|
+
${trackers.length > 50 ? `<p style="padding:12px 14px;font-size:12px;color:var(--text-muted)">… and ${trackers.length - 50} more.</p>` : ""}
|
|
692
|
+
</div>
|
|
693
|
+
</div>`;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// ── Recommendations ───────────────────────────────────────────────────────────
|
|
697
|
+
|
|
698
|
+
function buildRecommendationsSection(result: ScanResult): string {
|
|
699
|
+
const recs: string[] = [];
|
|
700
|
+
const { modal, compliance } = result;
|
|
701
|
+
const { issues } = compliance;
|
|
702
|
+
const has = (type: string) => issues.some((i) => i.type === type);
|
|
703
|
+
|
|
704
|
+
if (!modal.detected)
|
|
705
|
+
recs.push(
|
|
706
|
+
"Deploy a CMP solution (Axeptio, Didomi, OneTrust, Cookiebot) that displays a consent modal before any non-essential cookie.",
|
|
707
|
+
);
|
|
708
|
+
if (has("pre-ticked"))
|
|
709
|
+
recs.push(
|
|
710
|
+
"Remove pre-ticked checkboxes. Consent must result from an explicit positive action (GDPR Recital 32).",
|
|
711
|
+
);
|
|
712
|
+
if (has("no-reject-button") || has("buried-reject"))
|
|
713
|
+
recs.push(
|
|
714
|
+
'Add a "Reject all" button at the first layer of the modal, requiring no more clicks than "Accept all" (CNIL 2022).',
|
|
715
|
+
);
|
|
716
|
+
if (has("click-asymmetry"))
|
|
717
|
+
recs.push(
|
|
718
|
+
"Balance the number of clicks to accept and reject. Rejection must not require more steps than acceptance.",
|
|
719
|
+
);
|
|
720
|
+
if (has("asymmetric-prominence") || has("nudging"))
|
|
721
|
+
recs.push(
|
|
722
|
+
"Equalise the styling of Accept / Reject buttons: same size, same colour, same level of visibility.",
|
|
723
|
+
);
|
|
724
|
+
if (has("auto-consent"))
|
|
725
|
+
recs.push(
|
|
726
|
+
"Do not set any non-essential cookie before consent. Gate the initialisation of third-party scripts on the acceptance callback.",
|
|
727
|
+
);
|
|
728
|
+
if (has("missing-info"))
|
|
729
|
+
recs.push(
|
|
730
|
+
"Complete the modal information: processing purposes, identity of sub-processors, retention period, right to withdraw.",
|
|
731
|
+
);
|
|
732
|
+
if (result.cookiesAfterReject.filter((c) => c.requiresConsent).length > 0)
|
|
733
|
+
recs.push(
|
|
734
|
+
"Remove or block non-essential cookies after rejection, and verify consent handling server-side.",
|
|
735
|
+
);
|
|
736
|
+
|
|
737
|
+
if (recs.length === 0) {
|
|
738
|
+
return `<div class="section">
|
|
739
|
+
<div class="section-header"><h2>Recommendations</h2></div>
|
|
740
|
+
<div class="section-body">
|
|
741
|
+
<div class="no-issues">✓ No critical recommendation. Conduct regular audits to maintain compliance.</div>
|
|
742
|
+
</div>
|
|
743
|
+
</div>`;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
const items = recs
|
|
747
|
+
.map(
|
|
748
|
+
(rec, i) => `<li class="rec-item">
|
|
749
|
+
<span class="rec-num">${i + 1}</span>
|
|
750
|
+
<span>${esc(rec)}</span>
|
|
751
|
+
</li>`,
|
|
752
|
+
)
|
|
753
|
+
.join("\n");
|
|
754
|
+
|
|
755
|
+
return `<div class="section">
|
|
756
|
+
<div class="section-header"><h2>Recommendations</h2><span class="count-badge">${recs.length}</span></div>
|
|
757
|
+
<div class="section-body">
|
|
758
|
+
<ul class="rec-list">${items}</ul>
|
|
759
|
+
</div>
|
|
760
|
+
</div>`;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
// ── Checklist ─────────────────────────────────────────────────────────────────
|
|
764
|
+
|
|
765
|
+
function buildChecklistSection(result: ScanResult): string {
|
|
766
|
+
const { modal, compliance } = result;
|
|
767
|
+
const { issues } = compliance;
|
|
768
|
+
const hasIssue = (type: string) => issues.some((i) => i.type === type);
|
|
769
|
+
|
|
770
|
+
type Row = { category: string; rule: string; status: "ok" | "ko" | "warn"; detail: string };
|
|
771
|
+
const rows: Row[] = [];
|
|
772
|
+
|
|
773
|
+
const push = (category: string, rule: string, status: "ok" | "ko" | "warn", detail: string) =>
|
|
774
|
+
rows.push({ category, rule, status, detail });
|
|
775
|
+
|
|
776
|
+
push(
|
|
777
|
+
"Consent",
|
|
778
|
+
"Consent modal detected",
|
|
779
|
+
modal.detected ? "ok" : "ko",
|
|
780
|
+
modal.detected ? `Detected (${modal.selector})` : "No consent banner found",
|
|
781
|
+
);
|
|
782
|
+
|
|
783
|
+
const preTicked = modal.checkboxes.filter((c) => c.isCheckedByDefault);
|
|
784
|
+
push(
|
|
785
|
+
"Consent",
|
|
786
|
+
"No pre-ticked checkboxes",
|
|
787
|
+
preTicked.length === 0 ? "ok" : "ko",
|
|
788
|
+
preTicked.length === 0 ? "None detected" : `${preTicked.length} pre-ticked`,
|
|
789
|
+
);
|
|
790
|
+
|
|
791
|
+
push(
|
|
792
|
+
"Consent",
|
|
793
|
+
"Unambiguous accept label",
|
|
794
|
+
!modal.detected || !hasIssue("misleading-wording") ? "ok" : "warn",
|
|
795
|
+
modal.buttons.find((b) => b.type === "accept")?.text ?? "No accept button",
|
|
796
|
+
);
|
|
797
|
+
|
|
798
|
+
push(
|
|
799
|
+
"Easy refusal",
|
|
800
|
+
"Reject button at first layer",
|
|
801
|
+
!modal.detected
|
|
802
|
+
? "ko"
|
|
803
|
+
: hasIssue("no-reject-button") || hasIssue("buried-reject")
|
|
804
|
+
? "ko"
|
|
805
|
+
: "ok",
|
|
806
|
+
modal.buttons.find((b) => b.type === "reject")?.text ?? "Not found",
|
|
807
|
+
);
|
|
808
|
+
|
|
809
|
+
push(
|
|
810
|
+
"Easy refusal",
|
|
811
|
+
"Reject ≤ clicks than accept",
|
|
812
|
+
!modal.detected ? "ko" : hasIssue("click-asymmetry") ? "ko" : "ok",
|
|
813
|
+
(() => {
|
|
814
|
+
const a = modal.buttons.find((b) => b.type === "accept");
|
|
815
|
+
const r = modal.buttons.find((b) => b.type === "reject");
|
|
816
|
+
return a && r ? `Accept: ${a.clickDepth} · Reject: ${r.clickDepth}` : "Cannot verify";
|
|
817
|
+
})(),
|
|
818
|
+
);
|
|
819
|
+
|
|
820
|
+
push(
|
|
821
|
+
"Easy refusal",
|
|
822
|
+
"Button size symmetry",
|
|
823
|
+
!modal.detected ? "ko" : hasIssue("asymmetric-prominence") ? "warn" : "ok",
|
|
824
|
+
hasIssue("asymmetric-prominence")
|
|
825
|
+
? "Accept button is significantly larger"
|
|
826
|
+
: "Comparable sizes",
|
|
827
|
+
);
|
|
828
|
+
|
|
829
|
+
push(
|
|
830
|
+
"Transparency",
|
|
831
|
+
"Granular controls available",
|
|
832
|
+
!modal.detected ? "ko" : modal.hasGranularControls ? "ok" : "warn",
|
|
833
|
+
modal.hasGranularControls
|
|
834
|
+
? `${modal.checkboxes.length} control(s) detected`
|
|
835
|
+
: "No granular controls",
|
|
836
|
+
);
|
|
837
|
+
|
|
838
|
+
push(
|
|
839
|
+
"Transparency",
|
|
840
|
+
"Privacy policy in modal",
|
|
841
|
+
!modal.detected ? "ko" : modal.privacyPolicyUrl ? "ok" : "warn",
|
|
842
|
+
modal.privacyPolicyUrl ?? "Not found",
|
|
843
|
+
);
|
|
844
|
+
|
|
845
|
+
push(
|
|
846
|
+
"Transparency",
|
|
847
|
+
"Privacy policy on page",
|
|
848
|
+
result.privacyPolicyUrl ? "ok" : "warn",
|
|
849
|
+
result.privacyPolicyUrl ?? "Not found",
|
|
850
|
+
);
|
|
851
|
+
|
|
852
|
+
const illegalPre = result.cookiesBeforeInteraction.filter((c) => c.requiresConsent);
|
|
853
|
+
push(
|
|
854
|
+
"Cookie behavior",
|
|
855
|
+
"No non-essential cookie before consent",
|
|
856
|
+
illegalPre.length === 0 ? "ok" : "ko",
|
|
857
|
+
illegalPre.length === 0
|
|
858
|
+
? "None"
|
|
859
|
+
: `${illegalPre.length}: ${illegalPre.map((c) => c.name).join(", ")}`,
|
|
860
|
+
);
|
|
861
|
+
|
|
862
|
+
const persistAfterReject = result.cookiesAfterReject.filter(
|
|
863
|
+
(c) => c.requiresConsent && c.capturedAt === "after-reject",
|
|
864
|
+
);
|
|
865
|
+
push(
|
|
866
|
+
"Cookie behavior",
|
|
867
|
+
"Non-essential cookies removed after reject",
|
|
868
|
+
persistAfterReject.length === 0 ? "ok" : "ko",
|
|
869
|
+
persistAfterReject.length === 0
|
|
870
|
+
? "Correctly removed"
|
|
871
|
+
: `${persistAfterReject.length} persisting`,
|
|
872
|
+
);
|
|
873
|
+
|
|
874
|
+
const preTrackers = result.networkBeforeInteraction.filter(
|
|
875
|
+
(r) => r.trackerCategory !== null && r.trackerCategory !== "cdn",
|
|
876
|
+
);
|
|
877
|
+
push(
|
|
878
|
+
"Cookie behavior",
|
|
879
|
+
"No tracker before consent",
|
|
880
|
+
preTrackers.length === 0 ? "ok" : "ko",
|
|
881
|
+
preTrackers.length === 0 ? "None" : `${preTrackers.length} tracker(s)`,
|
|
882
|
+
);
|
|
883
|
+
|
|
884
|
+
const categories = [...new Set(rows.map((r) => r.category))];
|
|
885
|
+
const okCount = rows.filter((r) => r.status === "ok").length;
|
|
886
|
+
const koCount = rows.filter((r) => r.status === "ko").length;
|
|
887
|
+
const warnCount = rows.filter((r) => r.status === "warn").length;
|
|
888
|
+
|
|
889
|
+
const statusCell = (status: "ok" | "ko" | "warn") =>
|
|
890
|
+
status === "ok"
|
|
891
|
+
? `<span class="status-ok">✓ Compliant</span>`
|
|
892
|
+
: status === "ko"
|
|
893
|
+
? `<span class="status-ko">✗ Non-compliant</span>`
|
|
894
|
+
: `<span class="status-warn">⚠ Warning</span>`;
|
|
895
|
+
|
|
896
|
+
const tableRows = categories
|
|
897
|
+
.map((cat) => {
|
|
898
|
+
const catRows = rows.filter((r) => r.category === cat);
|
|
899
|
+
return catRows
|
|
900
|
+
.map(
|
|
901
|
+
(row, i) =>
|
|
902
|
+
`<tr>
|
|
903
|
+
${i === 0 ? `<td rowspan="${catRows.length}" style="font-weight:600;vertical-align:top;background:var(--bg)">${esc(cat)}</td>` : ""}
|
|
904
|
+
<td>${esc(row.rule)}</td>
|
|
905
|
+
<td>${statusCell(row.status)}</td>
|
|
906
|
+
<td style="color:var(--text-muted);font-size:12px">${esc(row.detail)}</td>
|
|
907
|
+
</tr>`,
|
|
908
|
+
)
|
|
909
|
+
.join("\n");
|
|
910
|
+
})
|
|
911
|
+
.join("\n");
|
|
912
|
+
|
|
913
|
+
const totalRules = rows.length;
|
|
914
|
+
|
|
915
|
+
return `<div class="section">
|
|
916
|
+
<div class="section-header">
|
|
917
|
+
<h2>Compliance checklist</h2>
|
|
918
|
+
<span class="count-badge">${totalRules} rules</span>
|
|
919
|
+
<span class="badge badge-ok">${okCount} ✓</span>
|
|
920
|
+
<span class="badge badge-critical">${koCount} ✗</span>
|
|
921
|
+
${warnCount > 0 ? `<span class="badge badge-warning">${warnCount} ⚠</span>` : ""}
|
|
922
|
+
</div>
|
|
923
|
+
<div class="section-body no-pad">
|
|
924
|
+
<table class="data-table">
|
|
925
|
+
<thead><tr><th>Category</th><th>Rule</th><th>Status</th><th>Detail</th></tr></thead>
|
|
926
|
+
<tbody>${tableRows}</tbody>
|
|
927
|
+
</table>
|
|
928
|
+
</div>
|
|
929
|
+
</div>`;
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
933
|
+
|
|
934
|
+
function esc(s: string): string {
|
|
935
|
+
return s
|
|
936
|
+
.replace(/&/g, "&")
|
|
937
|
+
.replace(/</g, "<")
|
|
938
|
+
.replace(/>/g, ">")
|
|
939
|
+
.replace(/"/g, """);
|
|
940
|
+
}
|