@slashgear/gdpr-cookie-scanner 3.6.0 → 3.8.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 (149) hide show
  1. package/.dockerignore +3 -0
  2. package/.gitattributes +1 -0
  3. package/.github/workflows/website.yml +80 -0
  4. package/CHANGELOG.md +52 -0
  5. package/CLAUDE.md +12 -1
  6. package/CONTRIBUTING.md +32 -4
  7. package/NEXT_STEPS.md +37 -3
  8. package/README.md +23 -0
  9. package/dist/analyzers/colour.d.ts +36 -0
  10. package/dist/analyzers/colour.d.ts.map +1 -0
  11. package/dist/analyzers/colour.js +75 -0
  12. package/dist/analyzers/colour.js.map +1 -0
  13. package/dist/analyzers/compliance.d.ts.map +1 -1
  14. package/dist/analyzers/compliance.js +24 -6
  15. package/dist/analyzers/compliance.js.map +1 -1
  16. package/dist/analyzers/tcf-decoder.d.ts +9 -0
  17. package/dist/analyzers/tcf-decoder.d.ts.map +1 -0
  18. package/dist/analyzers/tcf-decoder.js +123 -0
  19. package/dist/analyzers/tcf-decoder.js.map +1 -0
  20. package/dist/analyzers/wording.d.ts +1 -0
  21. package/dist/analyzers/wording.d.ts.map +1 -1
  22. package/dist/analyzers/wording.js +39 -0
  23. package/dist/analyzers/wording.js.map +1 -1
  24. package/dist/report/generator.d.ts +1 -0
  25. package/dist/report/generator.d.ts.map +1 -1
  26. package/dist/report/generator.js +71 -1
  27. package/dist/report/generator.js.map +1 -1
  28. package/dist/report/html.d.ts.map +1 -1
  29. package/dist/report/html.js +123 -0
  30. package/dist/report/html.js.map +1 -1
  31. package/dist/scanner/consent-modal.d.ts.map +1 -1
  32. package/dist/scanner/consent-modal.js +4 -2
  33. package/dist/scanner/consent-modal.js.map +1 -1
  34. package/dist/scanner/index.d.ts.map +1 -1
  35. package/dist/scanner/index.js +4 -0
  36. package/dist/scanner/index.js.map +1 -1
  37. package/dist/scanner/tcf.d.ts +9 -0
  38. package/dist/scanner/tcf.d.ts.map +1 -0
  39. package/dist/scanner/tcf.js +72 -0
  40. package/dist/scanner/tcf.js.map +1 -0
  41. package/dist/types.d.ts +26 -0
  42. package/dist/types.d.ts.map +1 -1
  43. package/package.json +7 -3
  44. package/pnpm-workspace.yaml +3 -0
  45. package/scripts/build-showcase.mjs +113 -0
  46. package/src/analyzers/colour.ts +89 -0
  47. package/src/analyzers/compliance.ts +35 -10
  48. package/src/analyzers/tcf-decoder.ts +130 -0
  49. package/src/analyzers/wording.ts +44 -0
  50. package/src/report/generator.ts +83 -1
  51. package/src/report/html.ts +146 -0
  52. package/src/scanner/consent-modal.ts +3 -1
  53. package/src/scanner/index.ts +5 -0
  54. package/src/scanner/tcf.ts +80 -0
  55. package/src/types.ts +29 -0
  56. package/tests/analyzers/colour.test.ts +187 -0
  57. package/tests/analyzers/compliance.test.ts +102 -0
  58. package/tests/analyzers/tcf-decoder.test.ts +292 -0
  59. package/tests/analyzers/wording.test.ts +38 -0
  60. package/tests/scanner/button-classification.test.ts +32 -0
  61. package/website/Dockerfile +55 -0
  62. package/website/node_modules/.bin/oxfmt +21 -0
  63. package/website/node_modules/.bin/oxlint +21 -0
  64. package/website/node_modules/.bin/tsc +21 -0
  65. package/website/node_modules/.bin/tsserver +21 -0
  66. package/website/node_modules/.bin/tsx +21 -0
  67. package/website/package.json +29 -0
  68. package/{docs → website/public}/index.html +88 -50
  69. package/website/public/reports/www.20minutes.fr/after-accept.png +3 -0
  70. package/website/public/reports/www.20minutes.fr/after-reject.png +3 -0
  71. package/website/public/reports/www.20minutes.fr/gdpr-report-20minutes.fr-2026-02-22.html +907 -0
  72. package/website/public/reports/www.20minutes.fr/modal-initial.png +3 -0
  73. package/website/public/reports/www.arte.tv/after-accept.png +3 -0
  74. package/website/public/reports/www.arte.tv/after-reject.png +3 -0
  75. package/website/public/reports/www.arte.tv/gdpr-report-arte.tv-2026-02-24.html +998 -0
  76. package/website/public/reports/www.arte.tv/modal-initial.png +3 -0
  77. package/website/public/reports/www.backmarket.fr/after-accept.png +3 -0
  78. package/website/public/reports/www.backmarket.fr/after-reject.png +3 -0
  79. package/website/public/reports/www.backmarket.fr/gdpr-report-backmarket.fr-2026-02-24.html +1530 -0
  80. package/website/public/reports/www.backmarket.fr/modal-initial.png +3 -0
  81. package/website/public/reports/www.deezer.com/after-accept.png +3 -0
  82. package/website/public/reports/www.deezer.com/after-reject.png +3 -0
  83. package/website/public/reports/www.deezer.com/gdpr-report-deezer.com-2026-02-22.html +1668 -0
  84. package/website/public/reports/www.deezer.com/modal-initial.png +3 -0
  85. package/website/public/reports/www.france.tv/after-accept.png +3 -0
  86. package/website/public/reports/www.france.tv/after-reject.png +3 -0
  87. package/website/public/reports/www.france.tv/gdpr-report-france.tv-2026-02-23.html +977 -0
  88. package/website/public/reports/www.france.tv/modal-initial.png +3 -0
  89. package/website/public/reports/www.m6.fr/after-accept.png +3 -0
  90. package/website/public/reports/www.m6.fr/after-reject.png +3 -0
  91. package/website/public/reports/www.m6.fr/gdpr-report-m6.fr-2026-02-28.html +1862 -0
  92. package/website/public/reports/www.m6.fr/modal-initial.png +3 -0
  93. package/website/public/reports/www.netflix.com/after-accept.png +3 -0
  94. package/website/public/reports/www.netflix.com/after-reject.png +3 -0
  95. package/website/public/reports/www.netflix.com/gdpr-report-netflix.com-2026-02-23.html +1051 -0
  96. package/website/public/reports/www.netflix.com/modal-initial.png +3 -0
  97. package/website/public/reports/www.radiofrance.fr/after-accept.png +3 -0
  98. package/website/public/reports/www.radiofrance.fr/after-reject.png +3 -0
  99. package/website/public/reports/www.radiofrance.fr/gdpr-report-radiofrance.fr-2026-02-24.html +1146 -0
  100. package/website/public/reports/www.radiofrance.fr/modal-initial.png +3 -0
  101. package/website/public/reports/www.tf1.fr/after-accept.png +3 -0
  102. package/website/public/reports/www.tf1.fr/after-reject.png +3 -0
  103. package/website/public/reports/www.tf1.fr/gdpr-report-tf1.fr-2026-02-23.html +1512 -0
  104. package/website/public/reports/www.tf1.fr/modal-initial.png +3 -0
  105. package/website/src/index.ts +15 -0
  106. package/website/src/security.ts +26 -0
  107. package/website/tsconfig.json +14 -0
  108. package/.github/workflows/pages.yml +0 -40
  109. package/docs/reports/github.com/after-accept.png +0 -0
  110. package/docs/reports/github.com/after-reject.png +0 -0
  111. package/docs/reports/github.com/gdpr-checklist-github.com-2026-02-22.md +0 -44
  112. package/docs/reports/github.com/gdpr-cookies-github.com-2026-02-22.md +0 -29
  113. package/docs/reports/github.com/gdpr-report-github.com-2026-02-22.md +0 -102
  114. package/docs/reports/github.com/gdpr-report-github.com-2026-02-22.pdf +0 -0
  115. package/docs/reports/gitlab.com/after-accept.png +0 -0
  116. package/docs/reports/gitlab.com/after-reject.png +0 -0
  117. package/docs/reports/gitlab.com/gdpr-checklist-gitlab.com-2026-02-22.md +0 -44
  118. package/docs/reports/gitlab.com/gdpr-cookies-gitlab.com-2026-02-22.md +0 -55
  119. package/docs/reports/gitlab.com/gdpr-report-gitlab.com-2026-02-22.md +0 -200
  120. package/docs/reports/gitlab.com/gdpr-report-gitlab.com-2026-02-22.pdf +0 -0
  121. package/docs/reports/gitlab.com/modal-initial.png +0 -0
  122. package/docs/reports/npmjs.com/after-accept.png +0 -0
  123. package/docs/reports/npmjs.com/after-reject.png +0 -0
  124. package/docs/reports/npmjs.com/gdpr-checklist-npmjs.com-2026-02-22.md +0 -44
  125. package/docs/reports/npmjs.com/gdpr-cookies-npmjs.com-2026-02-22.md +0 -25
  126. package/docs/reports/npmjs.com/gdpr-report-npmjs.com-2026-02-22.md +0 -88
  127. package/docs/reports/npmjs.com/gdpr-report-npmjs.com-2026-02-22.pdf +0 -0
  128. package/docs/reports/reddit.com/after-accept.png +0 -0
  129. package/docs/reports/reddit.com/after-reject.png +0 -0
  130. package/docs/reports/reddit.com/gdpr-checklist-reddit.com-2026-02-22.md +0 -44
  131. package/docs/reports/reddit.com/gdpr-cookies-reddit.com-2026-02-22.md +0 -33
  132. package/docs/reports/reddit.com/gdpr-report-reddit.com-2026-02-22.md +0 -148
  133. package/docs/reports/reddit.com/gdpr-report-reddit.com-2026-02-22.pdf +0 -0
  134. package/docs/reports/reddit.com/modal-initial.png +0 -0
  135. package/docs/reports/stackoverflow.com/after-accept.png +0 -0
  136. package/docs/reports/stackoverflow.com/after-reject.png +0 -0
  137. package/docs/reports/stackoverflow.com/gdpr-checklist-stackoverflow.com-2026-02-22.md +0 -44
  138. package/docs/reports/stackoverflow.com/gdpr-cookies-stackoverflow.com-2026-02-22.md +0 -67
  139. package/docs/reports/stackoverflow.com/gdpr-report-stackoverflow.com-2026-02-22.md +0 -206
  140. package/docs/reports/stackoverflow.com/gdpr-report-stackoverflow.com-2026-02-22.pdf +0 -0
  141. package/docs/reports/stackoverflow.com/modal-initial.png +0 -0
  142. package/docs/reports/www.afp.com/after-accept.png +0 -0
  143. package/docs/reports/www.afp.com/after-reject.png +0 -0
  144. package/docs/reports/www.afp.com/gdpr-checklist-afp.com-2026-02-22.md +0 -44
  145. package/docs/reports/www.afp.com/gdpr-cookies-afp.com-2026-02-22.md +0 -42
  146. package/docs/reports/www.afp.com/gdpr-report-afp.com-2026-02-22.md +0 -202
  147. package/docs/reports/www.afp.com/gdpr-report-afp.com-2026-02-22.pdf +0 -0
  148. package/docs/reports/www.afp.com/modal-initial.png +0 -0
  149. /package/{docs → website/public}/style.css +0 -0
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Reads docs/reports/<host>/ directories, extracts grade/score/date from
3
+ * each HTML report, and regenerates the "Live Reports" cards in docs/index.html.
4
+ *
5
+ * Usage: node scripts/build-showcase.mjs
6
+ * Or: pnpm build:showcase
7
+ */
8
+
9
+ import { readdir, readFile, writeFile } from "fs/promises";
10
+ import { join, resolve } from "path";
11
+ import { existsSync } from "fs";
12
+
13
+ const WEBSITE_PUBLIC_DIR = resolve("website/public");
14
+ const REPORTS_DIR = join(WEBSITE_PUBLIC_DIR, "reports");
15
+ const INDEX_HTML = join(WEBSITE_PUBLIC_DIR, "index.html");
16
+
17
+ const START_MARKER = "<!-- ── REPORTS_START ── -->";
18
+ const END_MARKER = "<!-- ── REPORTS_END ── -->";
19
+
20
+ async function extractMeta(hostDir) {
21
+ const files = await readdir(join(REPORTS_DIR, hostDir));
22
+ const htmlFile = files.find((f) => f.endsWith(".html"));
23
+ if (!htmlFile) return null;
24
+
25
+ const content = await readFile(join(REPORTS_DIR, hostDir, htmlFile), "utf8");
26
+
27
+ // oxfmt may split closing tags across lines — match opening tag content only
28
+ const grade = content.match(/"grade-badge"[^>]*>([A-F])<\/div>/)?.[1] ?? "?";
29
+ const score = content.match(/"score-num"[^>]*>(\d+)/)?.[1] ?? "?";
30
+
31
+ // Date from filename: gdpr-report-<host>-YYYY-MM-DD.html
32
+ const dateRaw = htmlFile.match(/(\d{4}-\d{2}-\d{2})\.html$/)?.[1];
33
+ const dateStr = dateRaw
34
+ ? new Date(dateRaw).toLocaleDateString("en-GB", {
35
+ day: "numeric",
36
+ month: "short",
37
+ year: "numeric",
38
+ })
39
+ : "";
40
+
41
+ const displayHost = hostDir.replace(/^www\./, "");
42
+
43
+ // Inject modal screenshot if PNG exists alongside the HTML but isn't referenced yet
44
+ const pngPath = join(REPORTS_DIR, hostDir, "modal-initial.png");
45
+ const htmlPath = join(REPORTS_DIR, hostDir, htmlFile);
46
+ if (existsSync(pngPath) && !content.includes("modal-initial.png")) {
47
+ const IMG = `<img src="modal-initial.png" alt="Consent modal screenshot" class="modal-screenshot" />`;
48
+ // Insert right after the section-body div that follows <h2>Consent modal</h2>
49
+ const patched = content.replace(
50
+ /(<h2>Consent modal<\/h2>[\s\S]*?<div class="section-body">)/,
51
+ `$1\n ${IMG}`,
52
+ );
53
+ if (patched !== content) {
54
+ await writeFile(htmlPath, patched, "utf8");
55
+ }
56
+ }
57
+
58
+ return { grade, score: parseInt(score, 10) || 0, dateStr, displayHost, hostDir, htmlFile };
59
+ }
60
+
61
+ function buildCard({ grade, score, dateStr, displayHost, hostDir, htmlFile }) {
62
+ return ` <!-- ${displayHost} — ${score}/100 ${grade} -->
63
+ <div class="report-card">
64
+ <div class="report-header">
65
+ <div class="grade-badge grade-${grade}">${grade}</div>
66
+ <div class="report-meta">
67
+ <h3>${displayHost}</h3>
68
+ <span class="score">${score} / 100</span>
69
+ </div>
70
+ </div>
71
+ <p class="report-date">Scanned ${dateStr}</p>
72
+ <a class="btn btn-outline" href="reports/${hostDir}/${htmlFile}">
73
+ View report →
74
+ </a>
75
+ </div>`;
76
+ }
77
+
78
+ async function main() {
79
+ const hosts = (await readdir(REPORTS_DIR, { withFileTypes: true }))
80
+ .filter((d) => d.isDirectory())
81
+ .map((d) => d.name);
82
+
83
+ const reports = (await Promise.all(hosts.map(extractMeta))).filter(Boolean);
84
+
85
+ // Sort by score descending (best first)
86
+ reports.sort((a, b) => b.score - a.score);
87
+
88
+ const cards = reports.map(buildCard).join("\n\n");
89
+
90
+ let index = await readFile(INDEX_HTML, "utf8");
91
+ const startIdx = index.indexOf(START_MARKER);
92
+ const endIdx = index.indexOf(END_MARKER);
93
+
94
+ if (startIdx === -1 || endIdx === -1) {
95
+ throw new Error(`Markers not found in ${INDEX_HTML}. Add:\n ${START_MARKER}\n ${END_MARKER}`);
96
+ }
97
+
98
+ const newIndex =
99
+ index.slice(0, startIdx + START_MARKER.length) +
100
+ "\n" +
101
+ cards +
102
+ "\n " +
103
+ index.slice(endIdx);
104
+
105
+ await writeFile(INDEX_HTML, newIndex, "utf8");
106
+ console.log(`✓ ${INDEX_HTML} updated with ${reports.length} report cards`);
107
+ reports.forEach((r) => console.log(` ${r.grade} ${r.score}/100 ${r.displayHost}`));
108
+ }
109
+
110
+ main().catch((e) => {
111
+ console.error(e);
112
+ process.exit(1);
113
+ });
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Colour nudging detection — EDPB Guidelines 03/2022 § 3.3.3.
3
+ *
4
+ * A "positive" colour (green = go, approve) on the accept button combined with
5
+ * a "negative" or neutral colour (grey, red) on the reject button steers users
6
+ * toward consent without technically hiding the refusal option.
7
+ */
8
+
9
+ function parseRgb(css: string): [number, number, number] | null {
10
+ const m = css.match(/rgba?\(\s*(\d+),\s*(\d+),\s*(\d+)/);
11
+ if (!m) return null;
12
+ return [parseInt(m[1], 10), parseInt(m[2], 10), parseInt(m[3], 10)];
13
+ }
14
+
15
+ /** Returns [hue 0–360, saturation 0–100, lightness 0–100]. */
16
+ export function rgbToHsl(r: number, g: number, b: number): [number, number, number] {
17
+ const rr = r / 255,
18
+ gg = g / 255,
19
+ bb = b / 255;
20
+ const max = Math.max(rr, gg, bb),
21
+ min = Math.min(rr, gg, bb);
22
+ const l = (max + min) / 2;
23
+ if (max === min) return [0, 0, Math.round(l * 100)];
24
+ const d = max - min;
25
+ const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
26
+ let h: number;
27
+ switch (max) {
28
+ case rr:
29
+ h = (gg - bb) / d + (gg < bb ? 6 : 0);
30
+ break;
31
+ case gg:
32
+ h = (bb - rr) / d + 2;
33
+ break;
34
+ default:
35
+ h = (rr - gg) / d + 4;
36
+ }
37
+ return [Math.round(h * 60), Math.round(s * 100), Math.round(l * 100)];
38
+ }
39
+
40
+ export type ButtonHue = "green" | "red" | "grey" | "blue" | "neutral";
41
+
42
+ /**
43
+ * Classify the perceptual "valence" of a colour:
44
+ * - green → positive, approval (h 80–165, s ≥ 25)
45
+ * - red → negative, danger (h ≤ 20 or ≥ 340, s ≥ 25)
46
+ * - grey → neutral / muted (s < 20)
47
+ * - blue → informational (h 195–265, s ≥ 25)
48
+ * - neutral → anything else
49
+ *
50
+ * Very dark (<10 L) or very light (>93 L) colours are treated as neutral
51
+ * because their hue carries little visual weight in a button context.
52
+ */
53
+ export function classifyHue(r: number, g: number, b: number): ButtonHue {
54
+ const [h, s, l] = rgbToHsl(r, g, b);
55
+ if (l < 10 || l > 93) return "neutral";
56
+ if (s < 20) return "grey";
57
+ if (h >= 80 && h <= 165) return "green";
58
+ if (h <= 20 || h >= 340) return "red";
59
+ if (h >= 195 && h <= 265) return "blue";
60
+ return "neutral";
61
+ }
62
+
63
+ export interface ColourNudgingResult {
64
+ acceptHue: ButtonHue | null;
65
+ rejectHue: ButtonHue | null;
66
+ /** True when accept is green and reject is grey or red. */
67
+ isNudging: boolean;
68
+ }
69
+
70
+ /**
71
+ * Detect colour nudging between the accept and reject buttons.
72
+ *
73
+ * Returns `isNudging: true` when the accept button has a "positive" hue (green)
74
+ * while the reject button has a "negative" or neutral hue (grey or red).
75
+ */
76
+ export function detectColourNudging(
77
+ acceptBg: string | null | undefined,
78
+ rejectBg: string | null | undefined,
79
+ ): ColourNudgingResult {
80
+ const acceptRgb = acceptBg ? parseRgb(acceptBg) : null;
81
+ const rejectRgb = rejectBg ? parseRgb(rejectBg) : null;
82
+
83
+ const acceptHue = acceptRgb ? classifyHue(...acceptRgb) : null;
84
+ const rejectHue = rejectRgb ? classifyHue(...rejectRgb) : null;
85
+
86
+ const isNudging = acceptHue === "green" && (rejectHue === "grey" || rejectHue === "red");
87
+
88
+ return { acceptHue, rejectHue, isNudging };
89
+ }
@@ -6,6 +6,7 @@ import type {
6
6
  NetworkRequest,
7
7
  } from "../types.js";
8
8
  import { analyzeButtonWording, analyzeModalText } from "./wording.js";
9
+ import { detectColourNudging } from "./colour.js";
9
10
 
10
11
  interface ComplianceInput {
11
12
  modal: ConsentModal;
@@ -32,6 +33,10 @@ export function analyzeCompliance(input: ComplianceInput): ComplianceScore {
32
33
  ].some((r) => r.requiresConsent);
33
34
  const consentRequired = hasNonEssentialCookies || hasNonEssentialTrackers;
34
35
 
36
+ // Run wording analysis once — modal may not be detected, so these can be null
37
+ const wordingResult = input.modal.detected ? analyzeButtonWording(input.modal.buttons) : null;
38
+ const textResult = input.modal.detected ? analyzeModalText(input.modal.text) : null;
39
+
35
40
  // ── A. Consent validity (0-25) ────────────────────────────────
36
41
  let consentValidity = 25;
37
42
 
@@ -44,10 +49,8 @@ export function analyzeCompliance(input: ComplianceInput): ComplianceScore {
44
49
  });
45
50
  consentValidity = 0;
46
51
  } else if (input.modal.detected) {
47
- // Wording analysis
48
- const wordingResult = analyzeButtonWording(input.modal.buttons);
49
- const textResult = analyzeModalText(input.modal.text);
50
- issues.push(...wordingResult.issues, ...textResult.issues);
52
+ // Wording analysis (wordingResult / textResult hoisted above)
53
+ issues.push(...wordingResult!.issues, ...textResult!.issues);
51
54
 
52
55
  // Pre-ticked checkboxes
53
56
  const preTicked = input.modal.checkboxes.filter((c) => c.isCheckedByDefault);
@@ -62,9 +65,9 @@ export function analyzeCompliance(input: ComplianceInput): ComplianceScore {
62
65
  }
63
66
 
64
67
  // Missing info deductions
65
- if (textResult.missingInfo.includes("purposes")) consentValidity -= 5;
66
- if (textResult.missingInfo.includes("third-parties")) consentValidity -= 5;
67
- if (textResult.missingInfo.length >= 3) consentValidity -= 5;
68
+ if (textResult!.missingInfo.includes("purposes")) consentValidity -= 5;
69
+ if (textResult!.missingInfo.includes("third-parties")) consentValidity -= 5;
70
+ if (textResult!.missingInfo.length >= 3) consentValidity -= 5;
68
71
  }
69
72
 
70
73
  // ── B. Easy refusal (0-25) ────────────────────────────────────
@@ -109,6 +112,29 @@ export function analyzeCompliance(input: ComplianceInput): ComplianceScore {
109
112
  }
110
113
  }
111
114
 
115
+ // Indirect reject label ("continuer sans accepter", "continue without accepting"…)
116
+ if (wordingResult?.hasIndirectRejectLabel) {
117
+ easyRefusal -= 5;
118
+ }
119
+
120
+ // Colour nudging: green accept + grey/red reject
121
+ if (acceptButton && rejectButton) {
122
+ const { isNudging, acceptHue, rejectHue } = detectColourNudging(
123
+ acceptButton.backgroundColor,
124
+ rejectButton.backgroundColor,
125
+ );
126
+ if (isNudging) {
127
+ issues.push({
128
+ type: "nudging",
129
+ severity: "warning",
130
+ description:
131
+ 'Accept button uses a "positive" colour (green) while reject is visually de-emphasised',
132
+ evidence: `Accept: ${acceptButton.backgroundColor} (${acceptHue}), Reject: ${rejectButton.backgroundColor} (${rejectHue}) — EDPB Guidelines 03/2022 § 3.3.3`,
133
+ });
134
+ easyRefusal -= 5;
135
+ }
136
+ }
137
+
112
138
  // Font size asymmetry
113
139
  if (acceptButton?.fontSize && rejectButton?.fontSize) {
114
140
  if (acceptButton.fontSize > rejectButton.fontSize * 1.3) {
@@ -167,9 +193,8 @@ export function analyzeCompliance(input: ComplianceInput): ComplianceScore {
167
193
  transparency -= 10;
168
194
  }
169
195
  // Already deducted in consentValidity for missing info
170
- const wordingResult = analyzeModalText(input.modal.text);
171
- if (wordingResult.missingInfo.length > 0) {
172
- transparency -= wordingResult.missingInfo.length * 3;
196
+ if (textResult!.missingInfo.length > 0) {
197
+ transparency -= textResult!.missingInfo.length * 3;
173
198
  }
174
199
  // No privacy policy link in the modal
175
200
  if (!input.modal.privacyPolicyUrl) {
@@ -0,0 +1,130 @@
1
+ import type { TcfConsentString } from "../types.js";
2
+
3
+ export const IAB_PURPOSES: Record<number, string> = {
4
+ 1: "Store and/or access information on a device",
5
+ 2: "Select basic ads",
6
+ 3: "Create a personalised ads profile",
7
+ 4: "Select personalised ads",
8
+ 5: "Create a personalised content profile",
9
+ 6: "Select personalised content",
10
+ 7: "Measure ad performance",
11
+ 8: "Measure content performance",
12
+ 9: "Apply market research to generate audience insights",
13
+ 10: "Develop and improve products",
14
+ 11: "Use limited data to select content",
15
+ };
16
+
17
+ export const IAB_SPECIAL_FEATURES: Record<number, string> = {
18
+ 1: "Use precise geolocation data",
19
+ 2: "Actively scan device characteristics for identification",
20
+ };
21
+
22
+ class BitReader {
23
+ private pos = 0;
24
+
25
+ constructor(private readonly buf: Buffer) {}
26
+
27
+ readBits(n: number): number {
28
+ let value = 0;
29
+ for (let i = 0; i < n; i++) {
30
+ const byteIndex = Math.floor(this.pos / 8);
31
+ if (byteIndex >= this.buf.length) throw new Error("BitReader: out of bounds");
32
+ const bitIndex = 7 - (this.pos % 8);
33
+ const bit = (this.buf[byteIndex] >> bitIndex) & 1;
34
+ // Use multiplication instead of bit shift to avoid 32-bit overflow on 36-bit timestamps
35
+ value = value * 2 + bit;
36
+ this.pos++;
37
+ }
38
+ return value;
39
+ }
40
+ }
41
+
42
+ function deciSecondsToDate(ds: number): Date {
43
+ return new Date(ds * 100);
44
+ }
45
+
46
+ function readLanguage(reader: BitReader): string {
47
+ const c1 = reader.readBits(6) + 65; // 'A' = 0
48
+ const c2 = reader.readBits(6) + 65;
49
+ return String.fromCharCode(c1, c2);
50
+ }
51
+
52
+ function readBitField(reader: BitReader, count: number): number[] {
53
+ const active: number[] = [];
54
+ for (let i = 1; i <= count; i++) {
55
+ if (reader.readBits(1) === 1) active.push(i);
56
+ }
57
+ return active;
58
+ }
59
+
60
+ /**
61
+ * Decode a TCF v1 or v2 consent string (core segment only).
62
+ * Returns null if decoding fails or if the version is not 1 or 2.
63
+ */
64
+ export function decodeTcfConsentString(raw: string): TcfConsentString | null {
65
+ try {
66
+ // Take only the core segment (before '~')
67
+ const coreSegment = raw.split("~")[0];
68
+ // Convert base64url to standard base64 and decode
69
+ const base64 = coreSegment.replace(/-/g, "+").replace(/_/g, "/");
70
+ const buf = Buffer.from(base64, "base64");
71
+ const reader = new BitReader(buf);
72
+
73
+ const version = reader.readBits(6);
74
+ if (version !== 1 && version !== 2) return null;
75
+
76
+ const created = deciSecondsToDate(reader.readBits(36));
77
+ const lastUpdated = deciSecondsToDate(reader.readBits(36));
78
+ const cmpId = reader.readBits(12);
79
+ const cmpVersion = reader.readBits(12);
80
+ reader.readBits(6); // consentScreen (unused)
81
+ const consentLanguage = readLanguage(reader);
82
+ const vendorListVersion = reader.readBits(12);
83
+
84
+ if (version === 1) {
85
+ const purposesAllowed = readBitField(reader, 24);
86
+ return {
87
+ raw,
88
+ version: 1,
89
+ created,
90
+ lastUpdated,
91
+ cmpId,
92
+ cmpVersion,
93
+ consentLanguage,
94
+ vendorListVersion,
95
+ specialFeatureOptins: [],
96
+ purposesConsent: purposesAllowed,
97
+ purposesLegitimateInterest: [],
98
+ };
99
+ }
100
+
101
+ // TCF v2
102
+ const tcfPolicyVersion = reader.readBits(6);
103
+ const isServiceSpecific = reader.readBits(1) === 1;
104
+ reader.readBits(1); // useNonStandardStacks
105
+ const specialFeatureOptins = readBitField(reader, 12);
106
+ const purposesConsent = readBitField(reader, 24);
107
+ const purposesLegitimateInterest = readBitField(reader, 24);
108
+ reader.readBits(1); // purposeOneTreatment
109
+ const publisherCC = readLanguage(reader);
110
+
111
+ return {
112
+ raw,
113
+ version: 2,
114
+ created,
115
+ lastUpdated,
116
+ cmpId,
117
+ cmpVersion,
118
+ consentLanguage,
119
+ vendorListVersion,
120
+ tcfPolicyVersion,
121
+ isServiceSpecific,
122
+ specialFeatureOptins,
123
+ purposesConsent,
124
+ purposesLegitimateInterest,
125
+ publisherCC,
126
+ };
127
+ } catch {
128
+ return null;
129
+ }
130
+ }
@@ -14,6 +14,35 @@ const MISLEADING_ACCEPT_LABELS = [
14
14
  */
15
15
  const FAKE_REJECT_LABELS = [/^(×|✕|✖|close|fermer|dismiss|ignorer|skip|passer)$/i];
16
16
 
17
+ /**
18
+ * Indirect reject labels: the button does refuse, but without using a clear negative
19
+ * word like "refuse/reject". The user has to infer the refusal from context.
20
+ * Flagged as a dark pattern per EDPB Guidelines 03/2022 (§ 3.3.3 — hiding choices).
21
+ *
22
+ * Pattern logic: "continue/proceed + without/sans/sin/senza/ohne + consent-related word".
23
+ */
24
+ const INDIRECT_REJECT_LABELS = [
25
+ // English
26
+ /\bcontinue\s+without\b/i,
27
+ /\bproceed\s+without\b/i,
28
+ /\bwithout\s+(accepting|accept|consent|consenting)\b/i,
29
+ // French
30
+ /\bcontinuer\s+sans\b/i,
31
+ /\bsans\s+(accepter|consentir|cookies)\b/i,
32
+ // Spanish
33
+ /\bcontinuar\s+sin\b/i,
34
+ /\bsin\s+(aceptar|consentir)\b/i,
35
+ // Italian
36
+ /\bcontinua\s+senza\b/i,
37
+ /\bsenza\s+(accettare|consenso)\b/i,
38
+ // German
39
+ /\bweiter\s+ohne\b/i,
40
+ /\bohne\s+(akzeptieren|zustimmen)\b/i,
41
+ // Dutch
42
+ /\bvervolgenen?\s+zonder\b/i,
43
+ /\bzonder\s+(accepteren|toestemming)\b/i,
44
+ ];
45
+
17
46
  /**
18
47
  * Required informational elements in consent text (RGPD Art. 13-14).
19
48
  */
@@ -32,6 +61,7 @@ export interface WordingAnalysis {
32
61
  missingInfo: string[];
33
62
  hasPositiveActionForAccept: boolean;
34
63
  hasExplicitRejectOption: boolean;
64
+ hasIndirectRejectLabel: boolean;
35
65
  }
36
66
 
37
67
  export function analyzeButtonWording(buttons: ConsentButton[]): WordingAnalysis {
@@ -80,11 +110,25 @@ export function analyzeButtonWording(buttons: ConsentButton[]): WordingAnalysis
80
110
  }
81
111
  }
82
112
 
113
+ // ── Indirect reject (refusal implied, not stated) ─────────────
114
+ const hasIndirectRejectLabel =
115
+ !!rejectButton && INDIRECT_REJECT_LABELS.some((p) => p.test(rejectButton.text));
116
+ if (hasIndirectRejectLabel && rejectButton) {
117
+ issues.push({
118
+ type: "misleading-wording",
119
+ severity: "warning",
120
+ description: `Reject button uses indirect wording: "${rejectButton.text}"`,
121
+ evidence:
122
+ 'EDPB Guidelines 03/2022: the refusal option must be as clear as acceptance — indirect phrases like "continue without accepting" obscure the user\'s choice',
123
+ });
124
+ }
125
+
83
126
  return {
84
127
  issues,
85
128
  missingInfo: [], // filled in by analyzeModalText
86
129
  hasPositiveActionForAccept: !!acceptButton,
87
130
  hasExplicitRejectOption: !!rejectButton,
131
+ hasIndirectRejectLabel,
88
132
  };
89
133
  }
90
134
 
@@ -15,6 +15,7 @@ import type {
15
15
  DarkPatternIssue,
16
16
  ConsentButton,
17
17
  } from "../types.js";
18
+ import { IAB_PURPOSES, IAB_SPECIAL_FEATURES } from "../analyzers/tcf-decoder.js";
18
19
  import type { ScanOptions } from "../types.js";
19
20
  import { lookupCookie } from "../classifiers/cookie-lookup.js";
20
21
 
@@ -170,8 +171,12 @@ export class ReportGenerator {
170
171
  sections.push(`## 6. Network Requests — Detected Trackers\n`);
171
172
  sections.push(this.buildNetworkSection(r));
172
173
 
174
+ // ── IAB TCF ───────────────────────────────────────────────────
175
+ sections.push(`## 7. IAB TCF (Transparency & Consent Framework)\n`);
176
+ sections.push(this.buildTcfSection(r));
177
+
173
178
  // ── Recommendations ───────────────────────────────────────────
174
- sections.push(`## 7. Recommendations\n`);
179
+ sections.push(`## 8. Recommendations\n`);
175
180
  sections.push(this.buildRecommendations(r));
176
181
 
177
182
  // ── Scan errors ───────────────────────────────────────────────
@@ -488,6 +493,83 @@ ${rows.join("\n")}
488
493
  return lines.join("\n");
489
494
  }
490
495
 
496
+ private buildTcfSection(r: ScanResult): string {
497
+ const { tcf } = r;
498
+ const lines: string[] = [];
499
+
500
+ lines.push(`> Informational only — does not affect the compliance score.\n`);
501
+
502
+ if (!tcf.detected) {
503
+ lines.push("**TCF detected:** ❌ No TCF implementation found on this page.");
504
+ return lines.join("\n");
505
+ }
506
+
507
+ lines.push(`**TCF detected:** ✅ Yes${tcf.version ? ` (v${tcf.version})` : ""}`);
508
+ lines.push(`**CMP API (\`__tcfapi\`):** ${tcf.apiPresent ? "✅ Present" : "❌ Not present"}`);
509
+ lines.push(
510
+ `**Locator frame (\`__tcfapiLocator\`):** ${tcf.locatorFramePresent ? "✅ Present" : "❌ Not present"}`,
511
+ );
512
+ lines.push(`**Consent string cookie:** ${tcf.cookieName ? `\`${tcf.cookieName}\`` : "—"}`);
513
+ lines.push(`**CMP ID:** ${tcf.cmpId ?? "—"}`);
514
+
515
+ const cs = tcf.consentString;
516
+ if (!cs) {
517
+ lines.push("\n_No consent string could be decoded._");
518
+ return lines.join("\n");
519
+ }
520
+
521
+ lines.push("\n### Decoded consent string\n");
522
+ lines.push("| Field | Value |");
523
+ lines.push("|-------|-------|");
524
+ lines.push(`| Version | TCF v${cs.version} |`);
525
+ lines.push(`| Created | ${cs.created.toISOString().split("T")[0]} |`);
526
+ lines.push(`| Last updated | ${cs.lastUpdated.toISOString().split("T")[0]} |`);
527
+ lines.push(`| CMP ID | ${cs.cmpId} |`);
528
+ lines.push(`| CMP version | ${cs.cmpVersion} |`);
529
+ lines.push(`| Consent language | ${cs.consentLanguage} |`);
530
+ lines.push(`| Vendor list version | ${cs.vendorListVersion} |`);
531
+ if (cs.tcfPolicyVersion !== undefined) {
532
+ lines.push(`| TCF policy version | ${cs.tcfPolicyVersion} |`);
533
+ }
534
+ if (cs.isServiceSpecific !== undefined) {
535
+ lines.push(`| Service specific | ${cs.isServiceSpecific ? "Yes" : "No"} |`);
536
+ }
537
+ if (cs.publisherCC) {
538
+ lines.push(`| Publisher country | ${cs.publisherCC} |`);
539
+ }
540
+
541
+ if (cs.purposesConsent.length > 0) {
542
+ lines.push("\n### Purposes with consent\n");
543
+ lines.push("| ID | Purpose |");
544
+ lines.push("|----|---------|");
545
+ for (const id of cs.purposesConsent) {
546
+ lines.push(`| ${id} | ${IAB_PURPOSES[id] ?? "Unknown"} |`);
547
+ }
548
+ } else {
549
+ lines.push("\n_No purposes with explicit consent._");
550
+ }
551
+
552
+ if (cs.purposesLegitimateInterest.length > 0) {
553
+ lines.push("\n### Purposes with legitimate interest\n");
554
+ lines.push("| ID | Purpose |");
555
+ lines.push("|----|---------|");
556
+ for (const id of cs.purposesLegitimateInterest) {
557
+ lines.push(`| ${id} | ${IAB_PURPOSES[id] ?? "Unknown"} |`);
558
+ }
559
+ }
560
+
561
+ if (cs.specialFeatureOptins.length > 0) {
562
+ lines.push("\n### Special features opted in\n");
563
+ lines.push("| ID | Feature |");
564
+ lines.push("|----|---------|");
565
+ for (const id of cs.specialFeatureOptins) {
566
+ lines.push(`| ${id} | ${IAB_SPECIAL_FEATURES[id] ?? "Unknown"} |`);
567
+ }
568
+ }
569
+
570
+ return lines.join("\n");
571
+ }
572
+
491
573
  private buildRecommendations(r: ScanResult): string {
492
574
  const recs: string[] = [];
493
575
  const issues = r.compliance.issues;