@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.
- package/.dockerignore +3 -0
- package/.gitattributes +1 -0
- package/.github/workflows/website.yml +80 -0
- package/CHANGELOG.md +52 -0
- package/CLAUDE.md +12 -1
- package/CONTRIBUTING.md +32 -4
- package/NEXT_STEPS.md +37 -3
- package/README.md +23 -0
- package/dist/analyzers/colour.d.ts +36 -0
- package/dist/analyzers/colour.d.ts.map +1 -0
- package/dist/analyzers/colour.js +75 -0
- package/dist/analyzers/colour.js.map +1 -0
- package/dist/analyzers/compliance.d.ts.map +1 -1
- package/dist/analyzers/compliance.js +24 -6
- package/dist/analyzers/compliance.js.map +1 -1
- package/dist/analyzers/tcf-decoder.d.ts +9 -0
- package/dist/analyzers/tcf-decoder.d.ts.map +1 -0
- package/dist/analyzers/tcf-decoder.js +123 -0
- package/dist/analyzers/tcf-decoder.js.map +1 -0
- package/dist/analyzers/wording.d.ts +1 -0
- package/dist/analyzers/wording.d.ts.map +1 -1
- package/dist/analyzers/wording.js +39 -0
- package/dist/analyzers/wording.js.map +1 -1
- package/dist/report/generator.d.ts +1 -0
- package/dist/report/generator.d.ts.map +1 -1
- package/dist/report/generator.js +71 -1
- package/dist/report/generator.js.map +1 -1
- package/dist/report/html.d.ts.map +1 -1
- package/dist/report/html.js +123 -0
- package/dist/report/html.js.map +1 -1
- package/dist/scanner/consent-modal.d.ts.map +1 -1
- package/dist/scanner/consent-modal.js +4 -2
- package/dist/scanner/consent-modal.js.map +1 -1
- package/dist/scanner/index.d.ts.map +1 -1
- package/dist/scanner/index.js +4 -0
- package/dist/scanner/index.js.map +1 -1
- package/dist/scanner/tcf.d.ts +9 -0
- package/dist/scanner/tcf.d.ts.map +1 -0
- package/dist/scanner/tcf.js +72 -0
- package/dist/scanner/tcf.js.map +1 -0
- package/dist/types.d.ts +26 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +7 -3
- package/pnpm-workspace.yaml +3 -0
- package/scripts/build-showcase.mjs +113 -0
- package/src/analyzers/colour.ts +89 -0
- package/src/analyzers/compliance.ts +35 -10
- package/src/analyzers/tcf-decoder.ts +130 -0
- package/src/analyzers/wording.ts +44 -0
- package/src/report/generator.ts +83 -1
- package/src/report/html.ts +146 -0
- package/src/scanner/consent-modal.ts +3 -1
- package/src/scanner/index.ts +5 -0
- package/src/scanner/tcf.ts +80 -0
- package/src/types.ts +29 -0
- package/tests/analyzers/colour.test.ts +187 -0
- package/tests/analyzers/compliance.test.ts +102 -0
- package/tests/analyzers/tcf-decoder.test.ts +292 -0
- package/tests/analyzers/wording.test.ts +38 -0
- package/tests/scanner/button-classification.test.ts +32 -0
- package/website/Dockerfile +55 -0
- package/website/node_modules/.bin/oxfmt +21 -0
- package/website/node_modules/.bin/oxlint +21 -0
- package/website/node_modules/.bin/tsc +21 -0
- package/website/node_modules/.bin/tsserver +21 -0
- package/website/node_modules/.bin/tsx +21 -0
- package/website/package.json +29 -0
- package/{docs → website/public}/index.html +88 -50
- package/website/public/reports/www.20minutes.fr/after-accept.png +3 -0
- package/website/public/reports/www.20minutes.fr/after-reject.png +3 -0
- package/website/public/reports/www.20minutes.fr/gdpr-report-20minutes.fr-2026-02-22.html +907 -0
- package/website/public/reports/www.20minutes.fr/modal-initial.png +3 -0
- package/website/public/reports/www.arte.tv/after-accept.png +3 -0
- package/website/public/reports/www.arte.tv/after-reject.png +3 -0
- package/website/public/reports/www.arte.tv/gdpr-report-arte.tv-2026-02-24.html +998 -0
- package/website/public/reports/www.arte.tv/modal-initial.png +3 -0
- package/website/public/reports/www.backmarket.fr/after-accept.png +3 -0
- package/website/public/reports/www.backmarket.fr/after-reject.png +3 -0
- package/website/public/reports/www.backmarket.fr/gdpr-report-backmarket.fr-2026-02-24.html +1530 -0
- package/website/public/reports/www.backmarket.fr/modal-initial.png +3 -0
- package/website/public/reports/www.deezer.com/after-accept.png +3 -0
- package/website/public/reports/www.deezer.com/after-reject.png +3 -0
- package/website/public/reports/www.deezer.com/gdpr-report-deezer.com-2026-02-22.html +1668 -0
- package/website/public/reports/www.deezer.com/modal-initial.png +3 -0
- package/website/public/reports/www.france.tv/after-accept.png +3 -0
- package/website/public/reports/www.france.tv/after-reject.png +3 -0
- package/website/public/reports/www.france.tv/gdpr-report-france.tv-2026-02-23.html +977 -0
- package/website/public/reports/www.france.tv/modal-initial.png +3 -0
- package/website/public/reports/www.m6.fr/after-accept.png +3 -0
- package/website/public/reports/www.m6.fr/after-reject.png +3 -0
- package/website/public/reports/www.m6.fr/gdpr-report-m6.fr-2026-02-28.html +1862 -0
- package/website/public/reports/www.m6.fr/modal-initial.png +3 -0
- package/website/public/reports/www.netflix.com/after-accept.png +3 -0
- package/website/public/reports/www.netflix.com/after-reject.png +3 -0
- package/website/public/reports/www.netflix.com/gdpr-report-netflix.com-2026-02-23.html +1051 -0
- package/website/public/reports/www.netflix.com/modal-initial.png +3 -0
- package/website/public/reports/www.radiofrance.fr/after-accept.png +3 -0
- package/website/public/reports/www.radiofrance.fr/after-reject.png +3 -0
- package/website/public/reports/www.radiofrance.fr/gdpr-report-radiofrance.fr-2026-02-24.html +1146 -0
- package/website/public/reports/www.radiofrance.fr/modal-initial.png +3 -0
- package/website/public/reports/www.tf1.fr/after-accept.png +3 -0
- package/website/public/reports/www.tf1.fr/after-reject.png +3 -0
- package/website/public/reports/www.tf1.fr/gdpr-report-tf1.fr-2026-02-23.html +1512 -0
- package/website/public/reports/www.tf1.fr/modal-initial.png +3 -0
- package/website/src/index.ts +15 -0
- package/website/src/security.ts +26 -0
- package/website/tsconfig.json +14 -0
- package/.github/workflows/pages.yml +0 -40
- package/docs/reports/github.com/after-accept.png +0 -0
- package/docs/reports/github.com/after-reject.png +0 -0
- package/docs/reports/github.com/gdpr-checklist-github.com-2026-02-22.md +0 -44
- package/docs/reports/github.com/gdpr-cookies-github.com-2026-02-22.md +0 -29
- package/docs/reports/github.com/gdpr-report-github.com-2026-02-22.md +0 -102
- package/docs/reports/github.com/gdpr-report-github.com-2026-02-22.pdf +0 -0
- package/docs/reports/gitlab.com/after-accept.png +0 -0
- package/docs/reports/gitlab.com/after-reject.png +0 -0
- package/docs/reports/gitlab.com/gdpr-checklist-gitlab.com-2026-02-22.md +0 -44
- package/docs/reports/gitlab.com/gdpr-cookies-gitlab.com-2026-02-22.md +0 -55
- package/docs/reports/gitlab.com/gdpr-report-gitlab.com-2026-02-22.md +0 -200
- package/docs/reports/gitlab.com/gdpr-report-gitlab.com-2026-02-22.pdf +0 -0
- package/docs/reports/gitlab.com/modal-initial.png +0 -0
- package/docs/reports/npmjs.com/after-accept.png +0 -0
- package/docs/reports/npmjs.com/after-reject.png +0 -0
- package/docs/reports/npmjs.com/gdpr-checklist-npmjs.com-2026-02-22.md +0 -44
- package/docs/reports/npmjs.com/gdpr-cookies-npmjs.com-2026-02-22.md +0 -25
- package/docs/reports/npmjs.com/gdpr-report-npmjs.com-2026-02-22.md +0 -88
- package/docs/reports/npmjs.com/gdpr-report-npmjs.com-2026-02-22.pdf +0 -0
- package/docs/reports/reddit.com/after-accept.png +0 -0
- package/docs/reports/reddit.com/after-reject.png +0 -0
- package/docs/reports/reddit.com/gdpr-checklist-reddit.com-2026-02-22.md +0 -44
- package/docs/reports/reddit.com/gdpr-cookies-reddit.com-2026-02-22.md +0 -33
- package/docs/reports/reddit.com/gdpr-report-reddit.com-2026-02-22.md +0 -148
- package/docs/reports/reddit.com/gdpr-report-reddit.com-2026-02-22.pdf +0 -0
- package/docs/reports/reddit.com/modal-initial.png +0 -0
- package/docs/reports/stackoverflow.com/after-accept.png +0 -0
- package/docs/reports/stackoverflow.com/after-reject.png +0 -0
- package/docs/reports/stackoverflow.com/gdpr-checklist-stackoverflow.com-2026-02-22.md +0 -44
- package/docs/reports/stackoverflow.com/gdpr-cookies-stackoverflow.com-2026-02-22.md +0 -67
- package/docs/reports/stackoverflow.com/gdpr-report-stackoverflow.com-2026-02-22.md +0 -206
- package/docs/reports/stackoverflow.com/gdpr-report-stackoverflow.com-2026-02-22.pdf +0 -0
- package/docs/reports/stackoverflow.com/modal-initial.png +0 -0
- package/docs/reports/www.afp.com/after-accept.png +0 -0
- package/docs/reports/www.afp.com/after-reject.png +0 -0
- package/docs/reports/www.afp.com/gdpr-checklist-afp.com-2026-02-22.md +0 -44
- package/docs/reports/www.afp.com/gdpr-cookies-afp.com-2026-02-22.md +0 -42
- package/docs/reports/www.afp.com/gdpr-report-afp.com-2026-02-22.md +0 -202
- package/docs/reports/www.afp.com/gdpr-report-afp.com-2026-02-22.pdf +0 -0
- package/docs/reports/www.afp.com/modal-initial.png +0 -0
- /package/{docs → 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
|
-
|
|
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
|
|
66
|
-
if (textResult
|
|
67
|
-
if (textResult
|
|
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
|
-
|
|
171
|
-
|
|
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
|
+
}
|
package/src/analyzers/wording.ts
CHANGED
|
@@ -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
|
|
package/src/report/generator.ts
CHANGED
|
@@ -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(`##
|
|
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;
|