@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.
- package/.changeset/README.md +8 -0
- package/.changeset/config.json +11 -0
- package/.github/ISSUE_TEMPLATE/bug_report.yml +44 -0
- package/.github/ISSUE_TEMPLATE/feature_request.yml +26 -0
- package/.github/PULL_REQUEST_TEMPLATE.md +24 -0
- package/.github/workflows/ci.yml +38 -0
- package/.github/workflows/release.yml +57 -0
- package/.idea/gdpr-report.iml +8 -0
- package/.idea/modules.xml +8 -0
- package/.idea/vcs.xml +6 -0
- package/CHANGELOG.md +7 -0
- package/CLAUDE.md +75 -0
- package/CODE_OF_CONDUCT.md +41 -0
- package/CONTRIBUTING.md +79 -0
- package/LICENSE +21 -0
- package/README.md +127 -0
- package/SECURITY.md +15 -0
- package/dist/analyzers/compliance.d.ts +13 -0
- package/dist/analyzers/compliance.d.ts.map +1 -0
- package/dist/analyzers/compliance.js +171 -0
- package/dist/analyzers/compliance.js.map +1 -0
- package/dist/analyzers/wording.d.ts +13 -0
- package/dist/analyzers/wording.d.ts.map +1 -0
- package/dist/analyzers/wording.js +91 -0
- package/dist/analyzers/wording.js.map +1 -0
- package/dist/classifiers/cookie-classifier.d.ts +8 -0
- package/dist/classifiers/cookie-classifier.d.ts.map +1 -0
- package/dist/classifiers/cookie-classifier.js +108 -0
- package/dist/classifiers/cookie-classifier.js.map +1 -0
- package/dist/classifiers/network-classifier.d.ts +9 -0
- package/dist/classifiers/network-classifier.d.ts.map +1 -0
- package/dist/classifiers/network-classifier.js +51 -0
- package/dist/classifiers/network-classifier.js.map +1 -0
- package/dist/classifiers/tracker-list.d.ts +16 -0
- package/dist/classifiers/tracker-list.d.ts.map +1 -0
- package/dist/classifiers/tracker-list.js +86 -0
- package/dist/classifiers/tracker-list.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +110 -0
- package/dist/cli.js.map +1 -0
- package/dist/report/generator.d.ts +19 -0
- package/dist/report/generator.d.ts.map +1 -0
- package/dist/report/generator.js +552 -0
- package/dist/report/generator.js.map +1 -0
- package/dist/scanner/browser.d.ts +11 -0
- package/dist/scanner/browser.d.ts.map +1 -0
- package/dist/scanner/browser.js +38 -0
- package/dist/scanner/browser.js.map +1 -0
- package/dist/scanner/consent-modal.d.ts +5 -0
- package/dist/scanner/consent-modal.d.ts.map +1 -0
- package/dist/scanner/consent-modal.js +244 -0
- package/dist/scanner/consent-modal.js.map +1 -0
- package/dist/scanner/cookies.d.ts +11 -0
- package/dist/scanner/cookies.d.ts.map +1 -0
- package/dist/scanner/cookies.js +30 -0
- package/dist/scanner/cookies.js.map +1 -0
- package/dist/scanner/index.d.ts +9 -0
- package/dist/scanner/index.d.ts.map +1 -0
- package/dist/scanner/index.js +146 -0
- package/dist/scanner/index.js.map +1 -0
- package/dist/scanner/network.d.ts +8 -0
- package/dist/scanner/network.d.ts.map +1 -0
- package/dist/scanner/network.js +41 -0
- package/dist/scanner/network.js.map +1 -0
- package/dist/types.d.ts +105 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +52 -0
- package/renovate.json +17 -0
- package/src/analyzers/compliance.ts +203 -0
- package/src/analyzers/wording.ts +112 -0
- package/src/classifiers/cookie-classifier.ts +125 -0
- package/src/classifiers/network-classifier.ts +65 -0
- package/src/classifiers/tracker-list.ts +105 -0
- package/src/cli.ts +134 -0
- package/src/report/generator.ts +703 -0
- package/src/scanner/browser.ts +52 -0
- package/src/scanner/consent-modal.ts +276 -0
- package/src/scanner/cookies.ts +43 -0
- package/src/scanner/index.ts +163 -0
- package/src/scanner/network.ts +51 -0
- package/src/types.ts +134 -0
- 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(`})`);
|
|
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
|
+
}
|