@slashgear/gdpr-cookie-scanner 1.0.0 → 1.2.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/.github/workflows/release.yml +2 -17
- package/.prettierignore +3 -0
- package/CHANGELOG.md +38 -0
- package/CLAUDE.md +16 -4
- package/dist/cli.js +2 -1
- package/dist/cli.js.map +1 -1
- package/dist/report/generator.d.ts +8 -1
- package/dist/report/generator.d.ts.map +1 -1
- package/dist/report/generator.js +251 -19
- package/dist/report/generator.js.map +1 -1
- package/dist/report/pdf.d.ts +2 -0
- package/dist/report/pdf.d.ts.map +1 -0
- package/dist/report/pdf.js +22 -0
- package/dist/report/pdf.js.map +1 -0
- package/dist/scanner/index.d.ts.map +1 -1
- package/dist/scanner/index.js +8 -2
- package/dist/scanner/index.js.map +1 -1
- package/package.json +23 -13
- package/src/cli.ts +2 -1
- package/src/report/generator.ts +307 -20
- package/src/report/pdf.ts +21 -0
- package/src/scanner/index.ts +8 -2
- package/.idea/gdpr-report.iml +0 -8
- package/.idea/modules.xml +0 -8
- package/.idea/vcs.xml +0 -6
package/src/report/generator.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { execFile } from "child_process";
|
|
2
|
-
import { writeFile, mkdir } from "fs/promises";
|
|
3
|
-
import { basename, dirname, join } from "path";
|
|
2
|
+
import { writeFile, mkdir, readFile } from "fs/promises";
|
|
3
|
+
import { basename, dirname, extname, join } from "path";
|
|
4
4
|
import { promisify } from "util";
|
|
5
5
|
import { fileURLToPath } from "url";
|
|
6
|
+
import { Marked } from "marked";
|
|
7
|
+
import { generatePdf } from "./pdf.js";
|
|
6
8
|
|
|
7
9
|
const execFileAsync = promisify(execFile);
|
|
8
10
|
const oxfmtBin = join(dirname(fileURLToPath(import.meta.url)), "../../node_modules/.bin/oxfmt");
|
|
@@ -18,7 +20,7 @@ import type { ScanOptions } from "../types.js";
|
|
|
18
20
|
export class ReportGenerator {
|
|
19
21
|
constructor(private readonly options: ScanOptions) {}
|
|
20
22
|
|
|
21
|
-
async generate(result: ScanResult): Promise<string> {
|
|
23
|
+
async generate(result: ScanResult): Promise<{ reportPath: string; pdfPath: string }> {
|
|
22
24
|
await mkdir(this.options.outputDir, { recursive: true });
|
|
23
25
|
|
|
24
26
|
const hostname = new URL(result.url).hostname.replace(/^www\./, "");
|
|
@@ -36,7 +38,154 @@ export class ReportGenerator {
|
|
|
36
38
|
await writeFile(checklistPath, checklist, "utf-8");
|
|
37
39
|
await execFileAsync(oxfmtBin, [checklistPath]).catch(() => {});
|
|
38
40
|
|
|
39
|
-
|
|
41
|
+
const cookiesFilename = `gdpr-cookies-${hostname}-${date}.md`;
|
|
42
|
+
const cookiesPath = join(this.options.outputDir, cookiesFilename);
|
|
43
|
+
const cookiesInventory = this.buildCookiesInventory(result);
|
|
44
|
+
await writeFile(cookiesPath, cookiesInventory, "utf-8");
|
|
45
|
+
await execFileAsync(oxfmtBin, [cookiesPath]).catch(() => {});
|
|
46
|
+
|
|
47
|
+
const combined = [markdown, checklist, cookiesInventory].join("\n\n---\n\n");
|
|
48
|
+
const rawBody = await this.buildHtmlBody(combined);
|
|
49
|
+
const body = await this.inlineImages(rawBody, this.options.outputDir);
|
|
50
|
+
const html = this.wrapHtml(body, hostname);
|
|
51
|
+
const pdfFilename = `gdpr-report-${hostname}-${date}.pdf`;
|
|
52
|
+
const pdfPath = join(this.options.outputDir, pdfFilename);
|
|
53
|
+
await generatePdf(html, pdfPath);
|
|
54
|
+
|
|
55
|
+
return { reportPath: outputPath, pdfPath };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
private async buildHtmlBody(markdown: string): Promise<string> {
|
|
59
|
+
type TocEntry = { level: number; text: string; id: string };
|
|
60
|
+
const entries: TocEntry[] = [];
|
|
61
|
+
const idCounts = new Map<string, number>();
|
|
62
|
+
|
|
63
|
+
const slugify = (text: string): string => {
|
|
64
|
+
const base =
|
|
65
|
+
text
|
|
66
|
+
.replace(/[^\p{L}\p{N}\s-]/gu, "")
|
|
67
|
+
.trim()
|
|
68
|
+
.toLowerCase()
|
|
69
|
+
.replace(/\s+/g, "-")
|
|
70
|
+
.replace(/-+/g, "-") || "section";
|
|
71
|
+
const count = idCounts.get(base) ?? 0;
|
|
72
|
+
idCounts.set(base, count + 1);
|
|
73
|
+
return count === 0 ? base : `${base}-${count}`;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const localMarked = new Marked();
|
|
77
|
+
localMarked.use({
|
|
78
|
+
renderer: {
|
|
79
|
+
heading({ text, depth }: { text: string; depth: number }) {
|
|
80
|
+
const id = slugify(text);
|
|
81
|
+
if (depth <= 2) entries.push({ level: depth, text, id });
|
|
82
|
+
return `<h${depth} id="${id}">${text}</h${depth}>\n`;
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const body = await localMarked.parse(markdown);
|
|
88
|
+
|
|
89
|
+
if (entries.length === 0) return body;
|
|
90
|
+
|
|
91
|
+
const tocItems = entries
|
|
92
|
+
.map(({ level, text, id }) => {
|
|
93
|
+
const cls = level === 1 ? "toc-h1" : "toc-h2";
|
|
94
|
+
return `<li class="${cls}"><a href="#${id}">${text}</a></li>`;
|
|
95
|
+
})
|
|
96
|
+
.join("\n");
|
|
97
|
+
|
|
98
|
+
const toc = `<nav class="toc">
|
|
99
|
+
<p class="toc-title">Table of Contents</p>
|
|
100
|
+
<ul>
|
|
101
|
+
${tocItems}
|
|
102
|
+
</ul>
|
|
103
|
+
</nav>`;
|
|
104
|
+
|
|
105
|
+
return toc + "\n" + body;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
private async inlineImages(html: string, outputDir: string): Promise<string> {
|
|
109
|
+
const mimeTypes: Record<string, string> = {
|
|
110
|
+
".png": "image/png",
|
|
111
|
+
".jpg": "image/jpeg",
|
|
112
|
+
".jpeg": "image/jpeg",
|
|
113
|
+
".gif": "image/gif",
|
|
114
|
+
".webp": "image/webp",
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const imgRegex = /<img([^>]*)\ssrc="([^"#][^"]*)"([^>]*)>/gi;
|
|
118
|
+
const replacements: Array<{ original: string; replacement: string }> = [];
|
|
119
|
+
|
|
120
|
+
for (const match of html.matchAll(imgRegex)) {
|
|
121
|
+
const [full, before, src, after] = match;
|
|
122
|
+
if (src.startsWith("data:") || src.startsWith("http://") || src.startsWith("https://"))
|
|
123
|
+
continue;
|
|
124
|
+
const mime = mimeTypes[extname(src).toLowerCase()];
|
|
125
|
+
if (!mime) continue;
|
|
126
|
+
try {
|
|
127
|
+
const buf = await readFile(join(outputDir, src));
|
|
128
|
+
replacements.push({
|
|
129
|
+
original: full,
|
|
130
|
+
replacement: `<img${before} src="data:${mime};base64,${buf.toString("base64")}"${after}>`,
|
|
131
|
+
});
|
|
132
|
+
} catch {
|
|
133
|
+
// file not found — leave the tag as-is
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return replacements.reduce(
|
|
138
|
+
(acc, { original, replacement }) => acc.replace(original, replacement),
|
|
139
|
+
html,
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
private wrapHtml(body: string, hostname: string): string {
|
|
144
|
+
return `<!DOCTYPE html>
|
|
145
|
+
<html lang="en">
|
|
146
|
+
<head>
|
|
147
|
+
<meta charset="UTF-8">
|
|
148
|
+
<title>GDPR Report — ${hostname}</title>
|
|
149
|
+
<style>
|
|
150
|
+
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
151
|
+
font-size: 11pt; line-height: 1.6; color: #1a1a1a; max-width: 900px;
|
|
152
|
+
margin: 0 auto; padding: 0 8px; }
|
|
153
|
+
h1 { font-size: 18pt; border-bottom: 2px solid #1a1a2e; padding-bottom: 6px;
|
|
154
|
+
color: #1a1a2e; margin-top: 2em; }
|
|
155
|
+
h2 { font-size: 14pt; color: #1a1a2e; margin-top: 1.5em; }
|
|
156
|
+
h3 { font-size: 12pt; margin-top: 1.2em; }
|
|
157
|
+
table { width: 100%; border-collapse: collapse; font-size: 9.5pt;
|
|
158
|
+
margin: 1em 0; page-break-inside: auto; }
|
|
159
|
+
th { background: #f0f0f4; padding: 6px 10px; text-align: left;
|
|
160
|
+
border-bottom: 2px solid #ccc; }
|
|
161
|
+
td { padding: 5px 10px; border-bottom: 1px solid #eee; vertical-align: top; }
|
|
162
|
+
tr { page-break-inside: avoid; }
|
|
163
|
+
code { font-family: "SFMono-Regular", Consolas, monospace; background: #f4f4f4;
|
|
164
|
+
padding: 1px 5px; border-radius: 3px; font-size: 9pt; }
|
|
165
|
+
pre { background: #f4f4f4; padding: 12px; border-radius: 4px;
|
|
166
|
+
overflow-x: auto; font-size: 9pt; }
|
|
167
|
+
blockquote { border-left: 3px solid #ccc; margin: 0.5em 0;
|
|
168
|
+
padding: 0.5em 1em; color: #555; }
|
|
169
|
+
hr { border: none; border-top: 1px solid #ddd; margin: 2em 0;
|
|
170
|
+
page-break-after: always; }
|
|
171
|
+
a { color: #0066cc; }
|
|
172
|
+
img { max-width: 100%; height: auto; border: 1px solid #ddd; border-radius: 4px; }
|
|
173
|
+
nav.toc { background: #f4f5f8; border-left: 4px solid #1a1a2e; border-radius: 4px;
|
|
174
|
+
padding: 14px 20px; margin: 0 0 2.5em 0; page-break-inside: avoid; }
|
|
175
|
+
.toc-title { font-weight: 700; font-size: 11pt; margin: 0 0 10px 0; color: #1a1a2e; }
|
|
176
|
+
nav.toc ul { list-style: none; margin: 0; padding: 0; }
|
|
177
|
+
nav.toc li { margin: 3px 0; line-height: 1.4; }
|
|
178
|
+
.toc-h1 { font-weight: 600; margin-top: 6px; }
|
|
179
|
+
.toc-h2 { padding-left: 1.2em; font-size: 9.5pt; }
|
|
180
|
+
nav.toc a { color: #0055aa; text-decoration: none; }
|
|
181
|
+
@media print {
|
|
182
|
+
h1 { page-break-before: always; }
|
|
183
|
+
h1:first-child { page-break-before: avoid; }
|
|
184
|
+
}
|
|
185
|
+
</style>
|
|
186
|
+
</head>
|
|
187
|
+
<body>${body}</body>
|
|
188
|
+
</html>`;
|
|
40
189
|
}
|
|
41
190
|
|
|
42
191
|
private buildMarkdown(r: ScanResult): string {
|
|
@@ -193,7 +342,7 @@ ${row("Cookie behavior", breakdown.cookieBehavior, 25)}
|
|
|
193
342
|
const { modal } = r;
|
|
194
343
|
const acceptBtn = modal.buttons.find((b) => b.type === "accept");
|
|
195
344
|
const rejectBtn = modal.buttons.find((b) => b.type === "reject");
|
|
196
|
-
const
|
|
345
|
+
const _prefBtn = modal.buttons.find((b) => b.type === "preferences");
|
|
197
346
|
|
|
198
347
|
const preTicked = modal.checkboxes.filter((c) => c.isCheckedByDefault);
|
|
199
348
|
|
|
@@ -460,6 +609,131 @@ ${rows.join("\n")}
|
|
|
460
609
|
return recs.join("\n\n");
|
|
461
610
|
}
|
|
462
611
|
|
|
612
|
+
private buildCookiesInventory(r: ScanResult): string {
|
|
613
|
+
const hostname = new URL(r.url).hostname;
|
|
614
|
+
const scanDate = new Date(r.scanDate).toLocaleString("en-GB");
|
|
615
|
+
|
|
616
|
+
// Collect all cookies across all phases, keyed by name+domain
|
|
617
|
+
type CookieEntry = {
|
|
618
|
+
name: string;
|
|
619
|
+
domain: string;
|
|
620
|
+
category: string;
|
|
621
|
+
phases: Set<string>;
|
|
622
|
+
expires: number | null;
|
|
623
|
+
httpOnly: boolean;
|
|
624
|
+
secure: boolean;
|
|
625
|
+
requiresConsent: boolean;
|
|
626
|
+
};
|
|
627
|
+
|
|
628
|
+
const cookieMap = new Map<string, CookieEntry>();
|
|
629
|
+
|
|
630
|
+
const phaseLabel: Record<ScannedCookie["capturedAt"], string> = {
|
|
631
|
+
"before-interaction": "before consent",
|
|
632
|
+
"after-accept": "after acceptance",
|
|
633
|
+
"after-reject": "after rejection",
|
|
634
|
+
};
|
|
635
|
+
|
|
636
|
+
const allCookies = [
|
|
637
|
+
...r.cookiesBeforeInteraction,
|
|
638
|
+
...r.cookiesAfterAccept,
|
|
639
|
+
...r.cookiesAfterReject,
|
|
640
|
+
];
|
|
641
|
+
|
|
642
|
+
for (const c of allCookies) {
|
|
643
|
+
const key = `${c.name}||${c.domain}`;
|
|
644
|
+
if (!cookieMap.has(key)) {
|
|
645
|
+
cookieMap.set(key, {
|
|
646
|
+
name: c.name,
|
|
647
|
+
domain: c.domain,
|
|
648
|
+
category: c.category,
|
|
649
|
+
phases: new Set(),
|
|
650
|
+
expires: c.expires,
|
|
651
|
+
httpOnly: c.httpOnly,
|
|
652
|
+
secure: c.secure,
|
|
653
|
+
requiresConsent: c.requiresConsent,
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
cookieMap.get(key)!.phases.add(phaseLabel[c.capturedAt]);
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
const expires = (entry: CookieEntry): string => {
|
|
660
|
+
if (entry.expires === null) return "Session";
|
|
661
|
+
const days = Math.round((entry.expires * 1000 - Date.now()) / 86400000);
|
|
662
|
+
if (days < 0) return "Expired";
|
|
663
|
+
if (days === 0) return "< 1 day";
|
|
664
|
+
if (days < 30) return `${days} days`;
|
|
665
|
+
return `${Math.round(days / 30)} months`;
|
|
666
|
+
};
|
|
667
|
+
|
|
668
|
+
const categoryLabel: Record<string, string> = {
|
|
669
|
+
"strictly-necessary": "Strictly necessary",
|
|
670
|
+
analytics: "Analytics",
|
|
671
|
+
advertising: "Advertising",
|
|
672
|
+
social: "Social",
|
|
673
|
+
personalization: "Personalization",
|
|
674
|
+
unknown: "Unknown",
|
|
675
|
+
};
|
|
676
|
+
|
|
677
|
+
const entries = [...cookieMap.values()].sort((a, b) => {
|
|
678
|
+
// Sort: strictly-necessary first, then by category, then by name
|
|
679
|
+
const order = [
|
|
680
|
+
"strictly-necessary",
|
|
681
|
+
"analytics",
|
|
682
|
+
"advertising",
|
|
683
|
+
"social",
|
|
684
|
+
"personalization",
|
|
685
|
+
"unknown",
|
|
686
|
+
];
|
|
687
|
+
const oa = order.indexOf(a.category);
|
|
688
|
+
const ob = order.indexOf(b.category);
|
|
689
|
+
if (oa !== ob) return oa - ob;
|
|
690
|
+
return a.name.localeCompare(b.name);
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
const lines: string[] = [];
|
|
694
|
+
|
|
695
|
+
lines.push(`# Cookie Inventory — ${hostname}`);
|
|
696
|
+
lines.push(`
|
|
697
|
+
> **Scan date:** ${scanDate}
|
|
698
|
+
> **Scanned URL:** ${r.url}
|
|
699
|
+
> **Unique cookies detected:** ${entries.length}
|
|
700
|
+
`);
|
|
701
|
+
|
|
702
|
+
lines.push(`## Instructions`);
|
|
703
|
+
lines.push(`
|
|
704
|
+
This table lists all cookies detected during the scan, across all phases.
|
|
705
|
+
The **Description / Purpose** column is to be filled in by the DPO or technical owner.
|
|
706
|
+
|
|
707
|
+
- **Before consent** — cookie present from page load, before any interaction
|
|
708
|
+
- **After acceptance** — cookie set or persisting after clicking "Accept all"
|
|
709
|
+
- **After rejection** — cookie present after clicking "Reject all"
|
|
710
|
+
`);
|
|
711
|
+
|
|
712
|
+
lines.push(`## Cookie table\n`);
|
|
713
|
+
lines.push(
|
|
714
|
+
`| Cookie | Domain | Category | Phases | Expiry | Consent required | Description / Purpose |`,
|
|
715
|
+
);
|
|
716
|
+
lines.push(
|
|
717
|
+
`|--------|--------|----------|--------|--------|------------------|-----------------------|`,
|
|
718
|
+
);
|
|
719
|
+
|
|
720
|
+
for (const entry of entries) {
|
|
721
|
+
const phases = [...entry.phases].join(", ");
|
|
722
|
+
const consent = entry.requiresConsent ? "⚠️ Yes" : "✅ No";
|
|
723
|
+
const cat = categoryLabel[entry.category] ?? entry.category;
|
|
724
|
+
lines.push(
|
|
725
|
+
`| \`${entry.name}\` | ${entry.domain} | ${cat} | ${phases} | ${expires(entry)} | ${consent} | <!-- fill in --> |`,
|
|
726
|
+
);
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
lines.push(`\n---`);
|
|
730
|
+
lines.push(
|
|
731
|
+
`\n_Automatically generated by gdpr-cookie-scanner. Categories marked "Unknown" could not be identified automatically and should be verified manually._\n`,
|
|
732
|
+
);
|
|
733
|
+
|
|
734
|
+
return lines.join("\n") + "\n";
|
|
735
|
+
}
|
|
736
|
+
|
|
463
737
|
private buildChecklist(r: ScanResult): string {
|
|
464
738
|
const hostname = new URL(r.url).hostname;
|
|
465
739
|
const scanDate = new Date(r.scanDate).toLocaleString("en-GB");
|
|
@@ -485,7 +759,8 @@ ${rows.join("\n")}
|
|
|
485
759
|
rows.push({
|
|
486
760
|
category: "Consent",
|
|
487
761
|
rule: "Consent modal detected",
|
|
488
|
-
reference:
|
|
762
|
+
reference:
|
|
763
|
+
"[GDPR Art. 7](https://gdpr-info.eu/art-7-gdpr/) · [ePrivacy Dir. Art. 5(3)](https://eur-lex.europa.eu/legal-content/EN/TXT/?uri=CELEX%3A32002L0058)",
|
|
489
764
|
status: r.modal.detected ? ok : ko,
|
|
490
765
|
detail: r.modal.detected
|
|
491
766
|
? `Detected (\`${r.modal.selector}\`)`
|
|
@@ -496,7 +771,7 @@ ${rows.join("\n")}
|
|
|
496
771
|
rows.push({
|
|
497
772
|
category: "Consent",
|
|
498
773
|
rule: "No pre-ticked checkboxes",
|
|
499
|
-
reference: "
|
|
774
|
+
reference: "[GDPR Recital 32](https://gdpr-info.eu/recitals/no-32/)",
|
|
500
775
|
status: preTicked.length === 0 ? ok : ko,
|
|
501
776
|
detail:
|
|
502
777
|
preTicked.length === 0
|
|
@@ -509,7 +784,7 @@ ${rows.join("\n")}
|
|
|
509
784
|
rows.push({
|
|
510
785
|
category: "Consent",
|
|
511
786
|
rule: "Accept button label is unambiguous",
|
|
512
|
-
reference: "
|
|
787
|
+
reference: "[GDPR Art. 4(11)](https://gdpr-info.eu/art-4-gdpr/)",
|
|
513
788
|
status:
|
|
514
789
|
!r.modal.detected || !misleadingAccept
|
|
515
790
|
? ok
|
|
@@ -531,7 +806,8 @@ ${rows.join("\n")}
|
|
|
531
806
|
rows.push({
|
|
532
807
|
category: "Easy refusal",
|
|
533
808
|
rule: "Reject button present at first layer",
|
|
534
|
-
reference:
|
|
809
|
+
reference:
|
|
810
|
+
"[CNIL Recommendation 2022](https://www.cnil.fr/fr/cookies-et-autres-traceurs/regles/cookies/recommandation-sur-les-cookies-et-autres-traceurs)",
|
|
535
811
|
status: !r.modal.detected ? ko : noReject ? ko : ok,
|
|
536
812
|
detail: !r.modal.detected
|
|
537
813
|
? "Modal not detected"
|
|
@@ -544,7 +820,8 @@ ${rows.join("\n")}
|
|
|
544
820
|
rows.push({
|
|
545
821
|
category: "Easy refusal",
|
|
546
822
|
rule: "Rejecting requires no more clicks than accepting",
|
|
547
|
-
reference:
|
|
823
|
+
reference:
|
|
824
|
+
"[CNIL Recommendation 2022](https://www.cnil.fr/fr/cookies-et-autres-traceurs/regles/cookies/recommandation-sur-les-cookies-et-autres-traceurs)",
|
|
548
825
|
status: !r.modal.detected ? ko : clickIssue ? ko : ok,
|
|
549
826
|
detail: !r.modal.detected
|
|
550
827
|
? "Modal not detected"
|
|
@@ -559,7 +836,8 @@ ${rows.join("\n")}
|
|
|
559
836
|
rows.push({
|
|
560
837
|
category: "Easy refusal",
|
|
561
838
|
rule: "Size symmetry between Accept and Reject",
|
|
562
|
-
reference:
|
|
839
|
+
reference:
|
|
840
|
+
"[EDPB Guidelines 03/2022](https://edpb.europa.eu/our-work-tools/our-documents/guidelines/guidelines-032022-dark-patterns-social-media-platform_en)",
|
|
563
841
|
status: !r.modal.detected ? ko : sizeIssue ? warn : ok,
|
|
564
842
|
detail: !r.modal.detected
|
|
565
843
|
? "Modal not detected"
|
|
@@ -572,7 +850,8 @@ ${rows.join("\n")}
|
|
|
572
850
|
rows.push({
|
|
573
851
|
category: "Easy refusal",
|
|
574
852
|
rule: "Font symmetry between Accept and Reject",
|
|
575
|
-
reference:
|
|
853
|
+
reference:
|
|
854
|
+
"[EDPB Guidelines 03/2022](https://edpb.europa.eu/our-work-tools/our-documents/guidelines/guidelines-032022-dark-patterns-social-media-platform_en)",
|
|
576
855
|
status: !r.modal.detected ? ko : nudgeIssue ? warn : ok,
|
|
577
856
|
detail: !r.modal.detected
|
|
578
857
|
? "Modal not detected"
|
|
@@ -585,7 +864,8 @@ ${rows.join("\n")}
|
|
|
585
864
|
rows.push({
|
|
586
865
|
category: "Transparency",
|
|
587
866
|
rule: "Granular controls available",
|
|
588
|
-
reference:
|
|
867
|
+
reference:
|
|
868
|
+
"[EDPB Guidelines 05/2020](https://edpb.europa.eu/our-work-tools/our-documents/guidelines/guidelines-052020-consent-under-regulation-2016679_en)",
|
|
589
869
|
status: !r.modal.detected ? ko : r.modal.hasGranularControls ? ok : warn,
|
|
590
870
|
detail: !r.modal.detected
|
|
591
871
|
? "Modal not detected"
|
|
@@ -595,21 +875,25 @@ ${rows.join("\n")}
|
|
|
595
875
|
});
|
|
596
876
|
|
|
597
877
|
const infoChecks: Array<{ key: string; label: string; ref: string }> = [
|
|
598
|
-
{
|
|
878
|
+
{
|
|
879
|
+
key: "purposes",
|
|
880
|
+
label: "Processing purposes mentioned",
|
|
881
|
+
ref: "[GDPR Art. 13-14](https://gdpr-info.eu/art-13-gdpr/)",
|
|
882
|
+
},
|
|
599
883
|
{
|
|
600
884
|
key: "third-parties",
|
|
601
885
|
label: "Sub-processors / third parties mentioned",
|
|
602
|
-
ref: "
|
|
886
|
+
ref: "[GDPR Art. 13-14](https://gdpr-info.eu/art-13-gdpr/)",
|
|
603
887
|
},
|
|
604
888
|
{
|
|
605
889
|
key: "duration",
|
|
606
890
|
label: "Retention period mentioned",
|
|
607
|
-
ref: "
|
|
891
|
+
ref: "[GDPR Art. 13(2)(a)](https://gdpr-info.eu/art-13-gdpr/)",
|
|
608
892
|
},
|
|
609
893
|
{
|
|
610
894
|
key: "withdrawal",
|
|
611
895
|
label: "Right to withdraw consent mentioned",
|
|
612
|
-
ref: "
|
|
896
|
+
ref: "[GDPR Art. 7(3)](https://gdpr-info.eu/art-7-gdpr/)",
|
|
613
897
|
},
|
|
614
898
|
];
|
|
615
899
|
|
|
@@ -635,7 +919,8 @@ ${rows.join("\n")}
|
|
|
635
919
|
rows.push({
|
|
636
920
|
category: "Cookie behavior",
|
|
637
921
|
rule: "No non-essential cookie before consent",
|
|
638
|
-
reference:
|
|
922
|
+
reference:
|
|
923
|
+
"[GDPR Art. 7](https://gdpr-info.eu/art-7-gdpr/) · [ePrivacy Dir. Art. 5(3)](https://eur-lex.europa.eu/legal-content/EN/TXT/?uri=CELEX%3A32002L0058)",
|
|
639
924
|
status: illegalPre.length === 0 ? ok : ko,
|
|
640
925
|
detail:
|
|
641
926
|
illegalPre.length === 0
|
|
@@ -649,7 +934,8 @@ ${rows.join("\n")}
|
|
|
649
934
|
rows.push({
|
|
650
935
|
category: "Cookie behavior",
|
|
651
936
|
rule: "Non-essential cookies removed after rejection",
|
|
652
|
-
reference:
|
|
937
|
+
reference:
|
|
938
|
+
"[GDPR Art. 7](https://gdpr-info.eu/art-7-gdpr/) · [CNIL Recommendation 2022](https://www.cnil.fr/fr/cookies-et-autres-traceurs/regles/cookies/recommandation-sur-les-cookies-et-autres-traceurs)",
|
|
653
939
|
status: persistAfterReject.length === 0 ? ok : ko,
|
|
654
940
|
detail:
|
|
655
941
|
persistAfterReject.length === 0
|
|
@@ -663,7 +949,8 @@ ${rows.join("\n")}
|
|
|
663
949
|
rows.push({
|
|
664
950
|
category: "Cookie behavior",
|
|
665
951
|
rule: "No network tracker before consent",
|
|
666
|
-
reference:
|
|
952
|
+
reference:
|
|
953
|
+
"[GDPR Art. 7](https://gdpr-info.eu/art-7-gdpr/) · [ePrivacy Dir. Art. 5(3)](https://eur-lex.europa.eu/legal-content/EN/TXT/?uri=CELEX%3A32002L0058)",
|
|
667
954
|
status: preTrackers.length === 0 ? ok : ko,
|
|
668
955
|
detail:
|
|
669
956
|
preTrackers.length === 0
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { writeFile } from "fs/promises";
|
|
2
|
+
import { chromium } from "playwright";
|
|
3
|
+
|
|
4
|
+
export async function generatePdf(html: string, outputPath: string): Promise<void> {
|
|
5
|
+
const browser = await chromium.launch({
|
|
6
|
+
headless: true,
|
|
7
|
+
args: ["--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage"],
|
|
8
|
+
});
|
|
9
|
+
try {
|
|
10
|
+
const page = await browser.newPage();
|
|
11
|
+
await page.setContent(html, { waitUntil: "domcontentloaded" });
|
|
12
|
+
const pdf = await page.pdf({
|
|
13
|
+
format: "A4",
|
|
14
|
+
printBackground: true,
|
|
15
|
+
margin: { top: "20mm", bottom: "20mm", left: "15mm", right: "15mm" },
|
|
16
|
+
});
|
|
17
|
+
await writeFile(outputPath, pdf);
|
|
18
|
+
} finally {
|
|
19
|
+
await browser.close();
|
|
20
|
+
}
|
|
21
|
+
}
|
package/src/scanner/index.ts
CHANGED
|
@@ -97,7 +97,7 @@ export class Scanner {
|
|
|
97
97
|
await clearState(session2.context);
|
|
98
98
|
const interceptor4 = createNetworkInterceptor(session2.page, "after-accept");
|
|
99
99
|
|
|
100
|
-
let cookiesAfterAccept =
|
|
100
|
+
let cookiesAfterAccept: typeof cookiesBeforeInteraction = [];
|
|
101
101
|
let networkAfterAccept: typeof networkBeforeInteraction = [];
|
|
102
102
|
|
|
103
103
|
try {
|
|
@@ -105,8 +105,14 @@ export class Scanner {
|
|
|
105
105
|
waitUntil: "networkidle",
|
|
106
106
|
timeout: this.options.timeout,
|
|
107
107
|
});
|
|
108
|
-
|
|
108
|
+
} catch (err) {
|
|
109
|
+
errors.push(`Accept phase navigation timeout: ${String(err)}`);
|
|
110
|
+
}
|
|
109
111
|
|
|
112
|
+
// Give a moment for late-loading scripts even if networkidle timed out
|
|
113
|
+
await session2.page.waitForTimeout(2000);
|
|
114
|
+
|
|
115
|
+
try {
|
|
110
116
|
const modal2 = await detectConsentModal(session2.page, this.options);
|
|
111
117
|
const acceptButton = modal2.buttons.find((b) => b.type === "accept");
|
|
112
118
|
|
package/.idea/gdpr-report.iml
DELETED
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
-
<module type="WEB_MODULE" version="4">
|
|
3
|
-
<component name="NewModuleRootManager">
|
|
4
|
-
<content url="file://$MODULE_DIR$" />
|
|
5
|
-
<orderEntry type="inheritedJdk" />
|
|
6
|
-
<orderEntry type="sourceFolder" forTests="false" />
|
|
7
|
-
</component>
|
|
8
|
-
</module>
|
package/.idea/modules.xml
DELETED
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
-
<project version="4">
|
|
3
|
-
<component name="ProjectModuleManager">
|
|
4
|
-
<modules>
|
|
5
|
-
<module fileurl="file://$PROJECT_DIR$/.idea/gdpr-report.iml" filepath="$PROJECT_DIR$/.idea/gdpr-report.iml" />
|
|
6
|
-
</modules>
|
|
7
|
-
</component>
|
|
8
|
-
</project>
|