@slashgear/gdpr-cookie-scanner 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/.changeset/README.md +8 -0
  2. package/.changeset/config.json +11 -0
  3. package/.github/ISSUE_TEMPLATE/bug_report.yml +44 -0
  4. package/.github/ISSUE_TEMPLATE/feature_request.yml +26 -0
  5. package/.github/PULL_REQUEST_TEMPLATE.md +24 -0
  6. package/.github/workflows/ci.yml +38 -0
  7. package/.github/workflows/release.yml +57 -0
  8. package/.idea/gdpr-report.iml +8 -0
  9. package/.idea/modules.xml +8 -0
  10. package/.idea/vcs.xml +6 -0
  11. package/CHANGELOG.md +7 -0
  12. package/CLAUDE.md +75 -0
  13. package/CODE_OF_CONDUCT.md +41 -0
  14. package/CONTRIBUTING.md +79 -0
  15. package/LICENSE +21 -0
  16. package/README.md +127 -0
  17. package/SECURITY.md +15 -0
  18. package/dist/analyzers/compliance.d.ts +13 -0
  19. package/dist/analyzers/compliance.d.ts.map +1 -0
  20. package/dist/analyzers/compliance.js +171 -0
  21. package/dist/analyzers/compliance.js.map +1 -0
  22. package/dist/analyzers/wording.d.ts +13 -0
  23. package/dist/analyzers/wording.d.ts.map +1 -0
  24. package/dist/analyzers/wording.js +91 -0
  25. package/dist/analyzers/wording.js.map +1 -0
  26. package/dist/classifiers/cookie-classifier.d.ts +8 -0
  27. package/dist/classifiers/cookie-classifier.d.ts.map +1 -0
  28. package/dist/classifiers/cookie-classifier.js +108 -0
  29. package/dist/classifiers/cookie-classifier.js.map +1 -0
  30. package/dist/classifiers/network-classifier.d.ts +9 -0
  31. package/dist/classifiers/network-classifier.d.ts.map +1 -0
  32. package/dist/classifiers/network-classifier.js +51 -0
  33. package/dist/classifiers/network-classifier.js.map +1 -0
  34. package/dist/classifiers/tracker-list.d.ts +16 -0
  35. package/dist/classifiers/tracker-list.d.ts.map +1 -0
  36. package/dist/classifiers/tracker-list.js +86 -0
  37. package/dist/classifiers/tracker-list.js.map +1 -0
  38. package/dist/cli.d.ts +3 -0
  39. package/dist/cli.d.ts.map +1 -0
  40. package/dist/cli.js +110 -0
  41. package/dist/cli.js.map +1 -0
  42. package/dist/report/generator.d.ts +19 -0
  43. package/dist/report/generator.d.ts.map +1 -0
  44. package/dist/report/generator.js +552 -0
  45. package/dist/report/generator.js.map +1 -0
  46. package/dist/scanner/browser.d.ts +11 -0
  47. package/dist/scanner/browser.d.ts.map +1 -0
  48. package/dist/scanner/browser.js +38 -0
  49. package/dist/scanner/browser.js.map +1 -0
  50. package/dist/scanner/consent-modal.d.ts +5 -0
  51. package/dist/scanner/consent-modal.d.ts.map +1 -0
  52. package/dist/scanner/consent-modal.js +244 -0
  53. package/dist/scanner/consent-modal.js.map +1 -0
  54. package/dist/scanner/cookies.d.ts +11 -0
  55. package/dist/scanner/cookies.d.ts.map +1 -0
  56. package/dist/scanner/cookies.js +30 -0
  57. package/dist/scanner/cookies.js.map +1 -0
  58. package/dist/scanner/index.d.ts +9 -0
  59. package/dist/scanner/index.d.ts.map +1 -0
  60. package/dist/scanner/index.js +146 -0
  61. package/dist/scanner/index.js.map +1 -0
  62. package/dist/scanner/network.d.ts +8 -0
  63. package/dist/scanner/network.d.ts.map +1 -0
  64. package/dist/scanner/network.js +41 -0
  65. package/dist/scanner/network.js.map +1 -0
  66. package/dist/types.d.ts +105 -0
  67. package/dist/types.d.ts.map +1 -0
  68. package/dist/types.js +2 -0
  69. package/dist/types.js.map +1 -0
  70. package/package.json +52 -0
  71. package/renovate.json +17 -0
  72. package/src/analyzers/compliance.ts +203 -0
  73. package/src/analyzers/wording.ts +112 -0
  74. package/src/classifiers/cookie-classifier.ts +125 -0
  75. package/src/classifiers/network-classifier.ts +65 -0
  76. package/src/classifiers/tracker-list.ts +105 -0
  77. package/src/cli.ts +134 -0
  78. package/src/report/generator.ts +703 -0
  79. package/src/scanner/browser.ts +52 -0
  80. package/src/scanner/consent-modal.ts +276 -0
  81. package/src/scanner/cookies.ts +43 -0
  82. package/src/scanner/index.ts +163 -0
  83. package/src/scanner/network.ts +51 -0
  84. package/src/types.ts +134 -0
  85. package/tsconfig.json +18 -0
@@ -0,0 +1,703 @@
1
+ import { execFile } from "child_process";
2
+ import { writeFile, mkdir } from "fs/promises";
3
+ import { basename, dirname, join } from "path";
4
+ import { promisify } from "util";
5
+ import { fileURLToPath } from "url";
6
+
7
+ const execFileAsync = promisify(execFile);
8
+ const oxfmtBin = join(dirname(fileURLToPath(import.meta.url)), "../../node_modules/.bin/oxfmt");
9
+ import type {
10
+ ScanResult,
11
+ ScannedCookie,
12
+ NetworkRequest,
13
+ DarkPatternIssue,
14
+ ConsentButton,
15
+ } from "../types.js";
16
+ import type { ScanOptions } from "../types.js";
17
+
18
+ export class ReportGenerator {
19
+ constructor(private readonly options: ScanOptions) {}
20
+
21
+ async generate(result: ScanResult): Promise<string> {
22
+ await mkdir(this.options.outputDir, { recursive: true });
23
+
24
+ const hostname = new URL(result.url).hostname.replace(/^www\./, "");
25
+ const date = new Date(result.scanDate).toISOString().split("T")[0];
26
+ const filename = `gdpr-report-${hostname}-${date}.md`;
27
+ const outputPath = join(this.options.outputDir, filename);
28
+
29
+ const markdown = this.buildMarkdown(result);
30
+ await writeFile(outputPath, markdown, "utf-8");
31
+ await execFileAsync(oxfmtBin, [outputPath]).catch(() => {});
32
+
33
+ const checklistFilename = `gdpr-checklist-${hostname}-${date}.md`;
34
+ const checklistPath = join(this.options.outputDir, checklistFilename);
35
+ const checklist = this.buildChecklist(result);
36
+ await writeFile(checklistPath, checklist, "utf-8");
37
+ await execFileAsync(oxfmtBin, [checklistPath]).catch(() => {});
38
+
39
+ return outputPath;
40
+ }
41
+
42
+ private buildMarkdown(r: ScanResult): string {
43
+ const hostname = new URL(r.url).hostname;
44
+ const scanDate = new Date(r.scanDate).toLocaleString("en-GB");
45
+ const durationSec = (r.duration / 1000).toFixed(1);
46
+ const grade = r.compliance.grade;
47
+ const score = r.compliance.total;
48
+
49
+ const gradeEmoji = grade === "A" ? "🟢" : grade === "B" ? "🟡" : grade === "C" ? "🟠" : "🔴";
50
+
51
+ const sections: string[] = [];
52
+
53
+ // ── Header ────────────────────────────────────────────────────
54
+ sections.push(`# GDPR Compliance Report — ${hostname}`);
55
+ sections.push(`
56
+ > **Scan date:** ${scanDate}
57
+ > **Scanned URL:** ${r.url}
58
+ > **Scan duration:** ${durationSec}s
59
+ > **Tool:** gdpr-cookie-scanner v0.1.0
60
+ `);
61
+
62
+ // ── Global score ──────────────────────────────────────────────
63
+ sections.push(`## Global Compliance Score\n`);
64
+ sections.push(`### ${gradeEmoji} ${score}/100 — Grade ${grade}\n`);
65
+ sections.push(this.buildScoreTable(r));
66
+
67
+ // ── Executive summary ─────────────────────────────────────────
68
+ sections.push(`## Executive Summary\n`);
69
+ sections.push(this.buildExecutiveSummary(r));
70
+
71
+ // ── Consent modal ─────────────────────────────────────────────
72
+ sections.push(`## 1. Consent Modal\n`);
73
+ sections.push(this.buildModalSection(r));
74
+
75
+ // ── Dark patterns ─────────────────────────────────────────────
76
+ sections.push(`## 2. Dark Patterns and Detected Issues\n`);
77
+ sections.push(this.buildIssuesSection(r.compliance.issues));
78
+
79
+ // ── Cookies before interaction ────────────────────────────────
80
+ sections.push(`## 3. Cookies Set Before Any Interaction\n`);
81
+ sections.push(this.buildCookiesTable(r.cookiesBeforeInteraction, "before-interaction"));
82
+
83
+ // ── Cookies after reject ──────────────────────────────────────
84
+ sections.push(`## 4. Cookies After Consent Rejection\n`);
85
+ sections.push(this.buildCookiesAfterRejectSection(r));
86
+
87
+ // ── Cookies after accept ──────────────────────────────────────
88
+ sections.push(`## 5. Cookies After Consent Acceptance\n`);
89
+ sections.push(this.buildCookiesTable(r.cookiesAfterAccept, "after-accept"));
90
+
91
+ // ── Network tracker requests ──────────────────────────────────
92
+ sections.push(`## 6. Network Requests — Detected Trackers\n`);
93
+ sections.push(this.buildNetworkSection(r));
94
+
95
+ // ── Recommendations ───────────────────────────────────────────
96
+ sections.push(`## 7. Recommendations\n`);
97
+ sections.push(this.buildRecommendations(r));
98
+
99
+ // ── Scan errors ───────────────────────────────────────────────
100
+ if (r.errors.length > 0) {
101
+ sections.push(`## Scan Errors and Warnings\n`);
102
+ sections.push(r.errors.map((e) => `- ⚠️ ${e}`).join("\n"));
103
+ }
104
+
105
+ // ── Legal references ──────────────────────────────────────────
106
+ sections.push(`## Legal References\n`);
107
+ sections.push(`
108
+ - **RGPD Art. 7** — Conditions for consent
109
+ - **RGPD Recital 32** — Consent must result from an unambiguous positive action
110
+ - **ePrivacy Directive 2002/58/EC** — Consent requirement for non-essential cookies
111
+ - **CEPD Guidelines 05/2020** — Consent under the RGPD
112
+ - **CEPD Guidelines 03/2022** — Dark patterns on platforms
113
+ - **CNIL Recommendation 2022** — Rejection must be as easy as acceptance (same number of clicks)
114
+ `);
115
+
116
+ return sections.join("\n\n") + "\n";
117
+ }
118
+
119
+ private buildScoreTable(r: ScanResult): string {
120
+ const { breakdown } = r.compliance;
121
+ const row = (label: string, score: number, max: number) => {
122
+ const pct = Math.round((score / max) * 100);
123
+ const bar = "█".repeat(Math.round(pct / 10)) + "░".repeat(10 - Math.round(pct / 10));
124
+ const status = pct >= 80 ? "✅" : pct >= 50 ? "⚠️" : "❌";
125
+ return `| ${label} | ${score}/${max} | ${bar} | ${status} |`;
126
+ };
127
+
128
+ return `| Criterion | Score | Progress | Status |
129
+ |-----------|-------|----------|--------|
130
+ ${row("Consent validity", breakdown.consentValidity, 25)}
131
+ ${row("Easy refusal", breakdown.easyRefusal, 25)}
132
+ ${row("Transparency", breakdown.transparency, 25)}
133
+ ${row("Cookie behavior", breakdown.cookieBehavior, 25)}
134
+ | **TOTAL** | **${r.compliance.total}/100** | | **${r.compliance.grade}** |
135
+ `;
136
+ }
137
+
138
+ private buildExecutiveSummary(r: ScanResult): string {
139
+ const criticalCount = r.compliance.issues.filter((i) => i.severity === "critical").length;
140
+ const warningCount = r.compliance.issues.filter((i) => i.severity === "warning").length;
141
+ const illegalPreCookies = r.cookiesBeforeInteraction.filter((c) => c.requiresConsent);
142
+ const persistAfterReject = r.cookiesAfterReject.filter((c) => c.requiresConsent);
143
+ const preInteractionTrackers = r.networkBeforeInteraction.filter(
144
+ (n) => n.trackerCategory !== null,
145
+ );
146
+
147
+ const lines: string[] = [];
148
+
149
+ if (!r.modal.detected) {
150
+ lines.push(
151
+ "❌ **No consent modal detected.** The site sets cookies without requesting consent.",
152
+ );
153
+ } else {
154
+ lines.push(`✅ Consent modal detected (\`${r.modal.selector}\`).`);
155
+ }
156
+
157
+ if (illegalPreCookies.length > 0) {
158
+ lines.push(
159
+ `❌ **${illegalPreCookies.length} non-essential cookie(s)** set before any interaction (RGPD violation).`,
160
+ );
161
+ } else {
162
+ lines.push("✅ No non-essential cookie set before interaction.");
163
+ }
164
+
165
+ if (persistAfterReject.length > 0) {
166
+ lines.push(
167
+ `❌ **${persistAfterReject.length} non-essential cookie(s)** persisting after rejection (RGPD violation).`,
168
+ );
169
+ } else {
170
+ lines.push("✅ Non-essential cookies are correctly removed after rejection.");
171
+ }
172
+
173
+ if (preInteractionTrackers.length > 0) {
174
+ lines.push(
175
+ `❌ **${preInteractionTrackers.length} tracker request(s)** fired before consent.`,
176
+ );
177
+ } else {
178
+ lines.push("✅ No tracker requests before consent.");
179
+ }
180
+
181
+ lines.push(
182
+ `\n**${criticalCount} critical issue(s)** and **${warningCount} warning(s)** identified.`,
183
+ );
184
+
185
+ return lines.join("\n");
186
+ }
187
+
188
+ private buildModalSection(r: ScanResult): string {
189
+ if (!r.modal.detected) {
190
+ return "_No consent modal detected on the page._\n";
191
+ }
192
+
193
+ const { modal } = r;
194
+ const acceptBtn = modal.buttons.find((b) => b.type === "accept");
195
+ const rejectBtn = modal.buttons.find((b) => b.type === "reject");
196
+ const prefBtn = modal.buttons.find((b) => b.type === "preferences");
197
+
198
+ const preTicked = modal.checkboxes.filter((c) => c.isCheckedByDefault);
199
+
200
+ const lines: string[] = [
201
+ `**CSS selector:** \`${modal.selector}\``,
202
+ `**Granular controls:** ${modal.hasGranularControls ? "✅ Yes" : "❌ No"}`,
203
+ `**Layer count:** ${modal.layerCount}`,
204
+ "",
205
+ "### Detected buttons",
206
+ "",
207
+ "| Button | Text | Visible | Font size | Contrast ratio |",
208
+ "|--------|------|---------|-----------|----------------|",
209
+ ...modal.buttons.map((b) => this.buildButtonRow(b)),
210
+ "",
211
+ ];
212
+
213
+ if (acceptBtn && rejectBtn) {
214
+ lines.push("### Comparative analysis: Accept / Reject\n");
215
+ if (
216
+ acceptBtn.fontSize &&
217
+ rejectBtn.fontSize &&
218
+ acceptBtn.fontSize > rejectBtn.fontSize * 1.2
219
+ ) {
220
+ lines.push(
221
+ `⚠️ The **Accept** button (${acceptBtn.fontSize}px) is larger than the **Reject** button (${rejectBtn.fontSize}px).`,
222
+ );
223
+ } else {
224
+ lines.push("✅ Accept / Reject button sizes are comparable.");
225
+ }
226
+
227
+ const acceptArea = acceptBtn.boundingBox
228
+ ? acceptBtn.boundingBox.width * acceptBtn.boundingBox.height
229
+ : 0;
230
+ const rejectArea = rejectBtn.boundingBox
231
+ ? rejectBtn.boundingBox.width * rejectBtn.boundingBox.height
232
+ : 0;
233
+ if (acceptArea > rejectArea * 2) {
234
+ lines.push(
235
+ `⚠️ **Accept** button area (${Math.round(acceptArea)}px²) is significantly larger than **Reject** (${Math.round(rejectArea)}px²).`,
236
+ );
237
+ }
238
+ }
239
+
240
+ if (preTicked.length > 0) {
241
+ lines.push("\n### Pre-ticked checkboxes (RGPD violation)\n");
242
+ lines.push("| Name | Label |");
243
+ lines.push("|------|-------|");
244
+ for (const cb of preTicked) {
245
+ lines.push(`| \`${cb.name}\` | ${cb.label} |`);
246
+ }
247
+ }
248
+
249
+ if (modal.screenshotPath) {
250
+ lines.push(`\n### Screenshot\n`);
251
+ lines.push(`![Consent modal](${basename(modal.screenshotPath)})`);
252
+ }
253
+
254
+ lines.push("\n### Modal text excerpt\n");
255
+ lines.push(`> ${modal.text.substring(0, 500)}${modal.text.length > 500 ? "..." : ""}`);
256
+
257
+ return lines.join("\n");
258
+ }
259
+
260
+ private buildButtonRow(b: ConsentButton): string {
261
+ const visible = b.isVisible ? "✅" : "❌";
262
+ const fontSize = b.fontSize ? `${b.fontSize}px` : "—";
263
+ const contrast = b.contrastRatio !== null ? `${b.contrastRatio}:1` : "—";
264
+ const typeLabel = {
265
+ accept: "🟢 Accept",
266
+ reject: "🔴 Reject",
267
+ preferences: "⚙️ Preferences",
268
+ close: "✕ Close",
269
+ unknown: "❓ Unknown",
270
+ }[b.type];
271
+ return `| ${typeLabel} | ${b.text.substring(0, 30)} | ${visible} | ${fontSize} | ${contrast} |`;
272
+ }
273
+
274
+ private buildIssuesSection(issues: DarkPatternIssue[]): string {
275
+ if (issues.length === 0) {
276
+ return "✅ No dark pattern or compliance issue detected.\n";
277
+ }
278
+
279
+ const critical = issues.filter((i) => i.severity === "critical");
280
+ const warnings = issues.filter((i) => i.severity === "warning");
281
+ const infos = issues.filter((i) => i.severity === "info");
282
+
283
+ const lines: string[] = [];
284
+
285
+ if (critical.length > 0) {
286
+ lines.push("### ❌ Critical issues\n");
287
+ for (const issue of critical) {
288
+ lines.push(`**${issue.description}**`);
289
+ lines.push(`> ${issue.evidence}\n`);
290
+ }
291
+ }
292
+
293
+ if (warnings.length > 0) {
294
+ lines.push("### ⚠️ Warnings\n");
295
+ for (const issue of warnings) {
296
+ lines.push(`**${issue.description}**`);
297
+ lines.push(`> ${issue.evidence}\n`);
298
+ }
299
+ }
300
+
301
+ if (infos.length > 0) {
302
+ lines.push("### ℹ️ Information\n");
303
+ for (const issue of infos) {
304
+ lines.push(`- ${issue.description}`);
305
+ }
306
+ }
307
+
308
+ return lines.join("\n");
309
+ }
310
+
311
+ private buildCookiesTable(cookies: ScannedCookie[], phase: ScannedCookie["capturedAt"]): string {
312
+ const filtered = cookies.filter((c) => c.capturedAt === phase);
313
+
314
+ if (filtered.length === 0) {
315
+ return "_No cookies detected._\n";
316
+ }
317
+
318
+ const consent = (c: ScannedCookie) => (c.requiresConsent ? "⚠️ Yes" : "✅ No");
319
+
320
+ const expires = (c: ScannedCookie) => {
321
+ if (c.expires === null) return "Session";
322
+ const days = Math.round((c.expires * 1000 - Date.now()) / 86400000);
323
+ if (days < 0) return "Expired";
324
+ if (days === 0) return "< 1 day";
325
+ if (days < 30) return `${days} days`;
326
+ return `${Math.round(days / 30)} months`;
327
+ };
328
+
329
+ const rows = filtered.map(
330
+ (c) => `| \`${c.name}\` | ${c.domain} | ${c.category} | ${expires(c)} | ${consent(c)} |`,
331
+ );
332
+
333
+ return `| Name | Domain | Category | Expiry | Consent required |
334
+ |------|--------|----------|--------|------------------|
335
+ ${rows.join("\n")}
336
+ `;
337
+ }
338
+
339
+ private buildCookiesAfterRejectSection(r: ScanResult): string {
340
+ const afterReject = r.cookiesAfterReject.filter((c) => c.capturedAt === "after-reject");
341
+ const violating = afterReject.filter((c) => c.requiresConsent);
342
+
343
+ const lines: string[] = [];
344
+
345
+ if (violating.length > 0) {
346
+ lines.push(`❌ **${violating.length} non-essential cookie(s)** detected after rejection:\n`);
347
+ } else {
348
+ lines.push("✅ No non-essential cookie detected after rejection.\n");
349
+ }
350
+
351
+ lines.push(this.buildCookiesTable(r.cookiesAfterReject, "after-reject"));
352
+
353
+ return lines.join("\n");
354
+ }
355
+
356
+ private buildNetworkSection(r: ScanResult): string {
357
+ const allRequests = [
358
+ ...r.networkBeforeInteraction,
359
+ ...r.networkAfterAccept,
360
+ ...r.networkAfterReject,
361
+ ].filter((req) => req.trackerCategory !== null);
362
+
363
+ if (allRequests.length === 0) {
364
+ return "_No known network tracker detected._\n";
365
+ }
366
+
367
+ const phases: Array<{ label: string; requests: NetworkRequest[] }> = [
368
+ {
369
+ label: "Before interaction",
370
+ requests: r.networkBeforeInteraction.filter((r) => r.trackerCategory !== null),
371
+ },
372
+ {
373
+ label: "After acceptance",
374
+ requests: r.networkAfterAccept.filter((r) => r.trackerCategory !== null),
375
+ },
376
+ {
377
+ label: "After rejection",
378
+ requests: r.networkAfterReject.filter((r) => r.trackerCategory !== null),
379
+ },
380
+ ];
381
+
382
+ const lines: string[] = [];
383
+
384
+ for (const { label, requests } of phases) {
385
+ if (requests.length === 0) continue;
386
+ lines.push(`### ${label} (${requests.length} tracker(s))\n`);
387
+ lines.push("| Tracker | Category | URL | Type |");
388
+ lines.push("|---------|-----------|-----|------|");
389
+ for (const req of requests.slice(0, 20)) {
390
+ const url = req.url.length > 60 ? req.url.substring(0, 57) + "..." : req.url;
391
+ lines.push(
392
+ `| ${req.trackerName ?? "Unknown"} | ${req.trackerCategory} | \`${url}\` | ${req.resourceType} |`,
393
+ );
394
+ }
395
+ if (requests.length > 20) {
396
+ lines.push(`\n_... and ${requests.length - 20} additional request(s)._`);
397
+ }
398
+ lines.push("");
399
+ }
400
+
401
+ return lines.join("\n");
402
+ }
403
+
404
+ private buildRecommendations(r: ScanResult): string {
405
+ const recs: string[] = [];
406
+ const issues = r.compliance.issues;
407
+
408
+ if (!r.modal.detected) {
409
+ recs.push(
410
+ "1. **Deploy a CMP solution** (e.g. Axeptio, Didomi, OneTrust, Cookiebot) that displays a consent modal before any non-essential cookie.",
411
+ );
412
+ }
413
+
414
+ if (issues.some((i) => i.type === "pre-ticked")) {
415
+ recs.push(
416
+ "1. **Remove pre-ticked checkboxes.** Consent must result from an explicit positive action (RGPD Recital 32).",
417
+ );
418
+ }
419
+
420
+ if (issues.some((i) => i.type === "no-reject-button" || i.type === "buried-reject")) {
421
+ recs.push(
422
+ '1. **Add a "Reject all" button** at the first layer of the modal, requiring no more clicks than "Accept all" (CNIL 2022).',
423
+ );
424
+ }
425
+
426
+ if (issues.some((i) => i.type === "click-asymmetry")) {
427
+ recs.push(
428
+ "1. **Balance the number of clicks** to accept and reject. Rejection must not require more steps than acceptance.",
429
+ );
430
+ }
431
+
432
+ if (issues.some((i) => i.type === "asymmetric-prominence" || i.type === "nudging")) {
433
+ recs.push(
434
+ "1. **Equalise the styling** of the Accept / Reject buttons: same size, same colour, same level of visibility.",
435
+ );
436
+ }
437
+
438
+ if (issues.some((i) => i.type === "auto-consent")) {
439
+ recs.push(
440
+ "1. **Do not set any non-essential cookie before consent.** Gate the initialisation of third-party scripts on acceptance.",
441
+ );
442
+ }
443
+
444
+ if (issues.some((i) => i.type === "missing-info")) {
445
+ recs.push(
446
+ "1. **Complete the modal information**: purposes, identity of sub-processors, retention period, right to withdraw.",
447
+ );
448
+ }
449
+
450
+ if (r.cookiesAfterReject.filter((c) => c.requiresConsent).length > 0) {
451
+ recs.push(
452
+ "1. **Remove or block non-essential cookies** after rejection, and verify consent handling server-side.",
453
+ );
454
+ }
455
+
456
+ if (recs.length === 0) {
457
+ recs.push("✅ No critical recommendation. Conduct regular audits to maintain compliance.");
458
+ }
459
+
460
+ return recs.join("\n\n");
461
+ }
462
+
463
+ private buildChecklist(r: ScanResult): string {
464
+ const hostname = new URL(r.url).hostname;
465
+ const scanDate = new Date(r.scanDate).toLocaleString("en-GB");
466
+ const issues = r.compliance.issues;
467
+ const hasIssue = (type: string) => issues.some((i) => i.type === type);
468
+ const getIssue = (type: string) => issues.find((i) => i.type === type);
469
+
470
+ const ok = "✅ Compliant";
471
+ const ko = "❌ Non-compliant";
472
+ const warn = "⚠️ Warning";
473
+
474
+ type Row = {
475
+ category: string;
476
+ rule: string;
477
+ reference: string;
478
+ status: string;
479
+ detail: string;
480
+ };
481
+
482
+ const rows: Row[] = [];
483
+
484
+ // ── A. Consent presence and validity ─────────────────────────
485
+ rows.push({
486
+ category: "Consent",
487
+ rule: "Consent modal detected",
488
+ reference: "RGPD Art. 7 · Dir. ePrivacy Art. 5(3)",
489
+ status: r.modal.detected ? ok : ko,
490
+ detail: r.modal.detected
491
+ ? `Detected (\`${r.modal.selector}\`)`
492
+ : "No consent banner detected",
493
+ });
494
+
495
+ const preTicked = r.modal.checkboxes.filter((c) => c.isCheckedByDefault);
496
+ rows.push({
497
+ category: "Consent",
498
+ rule: "No pre-ticked checkboxes",
499
+ reference: "RGPD Recital 32",
500
+ status: preTicked.length === 0 ? ok : ko,
501
+ detail:
502
+ preTicked.length === 0
503
+ ? "No pre-ticked checkbox detected"
504
+ : `${preTicked.length} pre-ticked box(es): ${preTicked.map((c) => c.label || c.name).join(", ")}`,
505
+ });
506
+
507
+ const misleadingAccept = getIssue("misleading-wording");
508
+ const acceptBtn = r.modal.buttons.find((b) => b.type === "accept");
509
+ rows.push({
510
+ category: "Consent",
511
+ rule: "Accept button label is unambiguous",
512
+ reference: "RGPD Art. 4(11)",
513
+ status:
514
+ !r.modal.detected || !misleadingAccept
515
+ ? ok
516
+ : misleadingAccept.severity === "critical"
517
+ ? ko
518
+ : warn,
519
+ detail: !r.modal.detected
520
+ ? "Modal not detected"
521
+ : acceptBtn
522
+ ? misleadingAccept
523
+ ? `Ambiguous label: "${acceptBtn.text}"`
524
+ : `Clear label: "${acceptBtn.text}"`
525
+ : "No Accept button detected",
526
+ });
527
+
528
+ // ── B. Easy refusal ───────────────────────────────────────────
529
+ const rejectBtn = r.modal.buttons.find((b) => b.type === "reject");
530
+ const noReject = hasIssue("no-reject-button") || hasIssue("buried-reject");
531
+ rows.push({
532
+ category: "Easy refusal",
533
+ rule: "Reject button present at first layer",
534
+ reference: "CNIL Recommendation 2022",
535
+ status: !r.modal.detected ? ko : noReject ? ko : ok,
536
+ detail: !r.modal.detected
537
+ ? "Modal not detected"
538
+ : rejectBtn
539
+ ? `Detected: "${rejectBtn.text}"`
540
+ : "No Reject button at first layer",
541
+ });
542
+
543
+ const clickIssue = getIssue("click-asymmetry");
544
+ rows.push({
545
+ category: "Easy refusal",
546
+ rule: "Rejecting requires no more clicks than accepting",
547
+ reference: "CNIL Recommendation 2022",
548
+ status: !r.modal.detected ? ko : clickIssue ? ko : ok,
549
+ detail: !r.modal.detected
550
+ ? "Modal not detected"
551
+ : clickIssue
552
+ ? clickIssue.evidence
553
+ : acceptBtn && rejectBtn
554
+ ? `Accept: ${acceptBtn.clickDepth} click(s) · Reject: ${rejectBtn.clickDepth} click(s)`
555
+ : "Cannot verify (missing buttons)",
556
+ });
557
+
558
+ const sizeIssue = getIssue("asymmetric-prominence");
559
+ rows.push({
560
+ category: "Easy refusal",
561
+ rule: "Size symmetry between Accept and Reject",
562
+ reference: "CEPD Guidelines 03/2022",
563
+ status: !r.modal.detected ? ko : sizeIssue ? warn : ok,
564
+ detail: !r.modal.detected
565
+ ? "Modal not detected"
566
+ : sizeIssue
567
+ ? sizeIssue.evidence
568
+ : "Button sizes are comparable",
569
+ });
570
+
571
+ const nudgeIssue = getIssue("nudging");
572
+ rows.push({
573
+ category: "Easy refusal",
574
+ rule: "Font symmetry between Accept and Reject",
575
+ reference: "CEPD Guidelines 03/2022",
576
+ status: !r.modal.detected ? ko : nudgeIssue ? warn : ok,
577
+ detail: !r.modal.detected
578
+ ? "Modal not detected"
579
+ : nudgeIssue
580
+ ? nudgeIssue.evidence
581
+ : "Font sizes are comparable",
582
+ });
583
+
584
+ // ── C. Transparency ───────────────────────────────────────────
585
+ rows.push({
586
+ category: "Transparency",
587
+ rule: "Granular controls available",
588
+ reference: "CEPD Guidelines 05/2020",
589
+ status: !r.modal.detected ? ko : r.modal.hasGranularControls ? ok : warn,
590
+ detail: !r.modal.detected
591
+ ? "Modal not detected"
592
+ : r.modal.hasGranularControls
593
+ ? `${r.modal.checkboxes.length} checkbox(es) or preferences panel detected`
594
+ : "No granular controls (checkboxes or panel) detected",
595
+ });
596
+
597
+ const infoChecks: Array<{ key: string; label: string; ref: string }> = [
598
+ { key: "purposes", label: "Processing purposes mentioned", ref: "RGPD Art. 13-14" },
599
+ {
600
+ key: "third-parties",
601
+ label: "Sub-processors / third parties mentioned",
602
+ ref: "RGPD Art. 13-14",
603
+ },
604
+ {
605
+ key: "duration",
606
+ label: "Retention period mentioned",
607
+ ref: "RGPD Art. 13(2)(a)",
608
+ },
609
+ {
610
+ key: "withdrawal",
611
+ label: "Right to withdraw consent mentioned",
612
+ ref: "RGPD Art. 7(3)",
613
+ },
614
+ ];
615
+
616
+ for (const { key, label, ref } of infoChecks) {
617
+ const missing = issues.find(
618
+ (i) => i.type === "missing-info" && i.description.includes(`"${key}"`),
619
+ );
620
+ rows.push({
621
+ category: "Transparency",
622
+ rule: label,
623
+ reference: ref,
624
+ status: !r.modal.detected ? ko : missing ? warn : ok,
625
+ detail: !r.modal.detected
626
+ ? "Modal not detected"
627
+ : missing
628
+ ? `Information absent from the modal text`
629
+ : "Mention found in the modal text",
630
+ });
631
+ }
632
+
633
+ // ── D. Cookie behavior ────────────────────────────────────────
634
+ const illegalPre = r.cookiesBeforeInteraction.filter((c) => c.requiresConsent);
635
+ rows.push({
636
+ category: "Cookie behavior",
637
+ rule: "No non-essential cookie before consent",
638
+ reference: "RGPD Art. 7 · Dir. ePrivacy Art. 5(3)",
639
+ status: illegalPre.length === 0 ? ok : ko,
640
+ detail:
641
+ illegalPre.length === 0
642
+ ? "No non-essential cookie set before interaction"
643
+ : `${illegalPre.length} illegal cookie(s): ${illegalPre.map((c) => `\`${c.name}\` (${c.category})`).join(", ")}`,
644
+ });
645
+
646
+ const persistAfterReject = r.cookiesAfterReject.filter(
647
+ (c) => c.requiresConsent && c.capturedAt === "after-reject",
648
+ );
649
+ rows.push({
650
+ category: "Cookie behavior",
651
+ rule: "Non-essential cookies removed after rejection",
652
+ reference: "RGPD Art. 7 · CNIL Recommendation 2022",
653
+ status: persistAfterReject.length === 0 ? ok : ko,
654
+ detail:
655
+ persistAfterReject.length === 0
656
+ ? "No non-essential cookie persisting after rejection"
657
+ : `${persistAfterReject.length} cookie(s) persisting: ${persistAfterReject.map((c) => `\`${c.name}\``).join(", ")}`,
658
+ });
659
+
660
+ const preTrackers = r.networkBeforeInteraction.filter(
661
+ (req) => req.trackerCategory !== null && req.trackerCategory !== "cdn",
662
+ );
663
+ rows.push({
664
+ category: "Cookie behavior",
665
+ rule: "No network tracker before consent",
666
+ reference: "RGPD Art. 7 · Dir. ePrivacy Art. 5(3)",
667
+ status: preTrackers.length === 0 ? ok : ko,
668
+ detail:
669
+ preTrackers.length === 0
670
+ ? "No tracker request fired before interaction"
671
+ : `${preTrackers.length} tracker(s): ${[...new Set(preTrackers.map((r) => r.trackerName ?? r.url))].slice(0, 3).join(", ")}`,
672
+ });
673
+
674
+ // ── Totals ────────────────────────────────────────────────────
675
+ const conformeCount = rows.filter((r) => r.status === ok).length;
676
+ const nonConformeCount = rows.filter((r) => r.status === ko).length;
677
+ const avertissementCount = rows.filter((r) => r.status === warn).length;
678
+
679
+ const lines: string[] = [];
680
+ lines.push(`# GDPR Compliance Checklist — ${hostname}`);
681
+ lines.push(`
682
+ > **Scan date:** ${scanDate}
683
+ > **Scanned URL:** ${r.url}
684
+ > **Global score:** ${r.compliance.total}/100 — Grade **${r.compliance.grade}**
685
+ `);
686
+ lines.push(
687
+ `**${conformeCount} rule(s) compliant** · **${nonConformeCount} non-compliant** · **${avertissementCount} warning(s)**\n`,
688
+ );
689
+
690
+ const categories = [...new Set(rows.map((r) => r.category))];
691
+ for (const category of categories) {
692
+ lines.push(`## ${category}\n`);
693
+ lines.push("| Rule | Reference | Status | Detail |");
694
+ lines.push("|------|-----------|--------|--------|");
695
+ for (const row of rows.filter((r) => r.category === category)) {
696
+ lines.push(`| ${row.rule} | ${row.reference} | ${row.status} | ${row.detail} |`);
697
+ }
698
+ lines.push("");
699
+ }
700
+
701
+ return lines.join("\n") + "\n";
702
+ }
703
+ }