@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
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
export type CookieCategory = "strictly-necessary" | "analytics" | "advertising" | "social" | "personalization" | "unknown";
|
|
2
|
+
export type ConsentButtonType = "accept" | "reject" | "preferences" | "close" | "unknown";
|
|
3
|
+
export interface ScannedCookie {
|
|
4
|
+
name: string;
|
|
5
|
+
domain: string;
|
|
6
|
+
path: string;
|
|
7
|
+
value: string;
|
|
8
|
+
expires: number | null;
|
|
9
|
+
httpOnly: boolean;
|
|
10
|
+
secure: boolean;
|
|
11
|
+
sameSite: string | null;
|
|
12
|
+
category: CookieCategory;
|
|
13
|
+
requiresConsent: boolean;
|
|
14
|
+
capturedAt: "before-interaction" | "after-accept" | "after-reject";
|
|
15
|
+
}
|
|
16
|
+
export interface NetworkRequest {
|
|
17
|
+
url: string;
|
|
18
|
+
method: string;
|
|
19
|
+
resourceType: string;
|
|
20
|
+
initiator: string | null;
|
|
21
|
+
isThirdParty: boolean;
|
|
22
|
+
trackerCategory: TrackerCategory | null;
|
|
23
|
+
trackerName: string | null;
|
|
24
|
+
capturedAt: "before-interaction" | "after-accept" | "after-reject";
|
|
25
|
+
responseStatus: number | null;
|
|
26
|
+
contentType: string | null;
|
|
27
|
+
}
|
|
28
|
+
export type TrackerCategory = "analytics" | "advertising" | "social" | "fingerprinting" | "pixel" | "cdn" | "unknown";
|
|
29
|
+
export interface ConsentButton {
|
|
30
|
+
type: ConsentButtonType;
|
|
31
|
+
text: string;
|
|
32
|
+
selector: string;
|
|
33
|
+
isVisible: boolean;
|
|
34
|
+
boundingBox: {
|
|
35
|
+
x: number;
|
|
36
|
+
y: number;
|
|
37
|
+
width: number;
|
|
38
|
+
height: number;
|
|
39
|
+
} | null;
|
|
40
|
+
fontSize: number | null;
|
|
41
|
+
backgroundColor: string | null;
|
|
42
|
+
textColor: string | null;
|
|
43
|
+
contrastRatio: number | null;
|
|
44
|
+
clickDepth: number;
|
|
45
|
+
}
|
|
46
|
+
export interface ConsentCheckbox {
|
|
47
|
+
name: string;
|
|
48
|
+
label: string;
|
|
49
|
+
isCheckedByDefault: boolean;
|
|
50
|
+
category: CookieCategory;
|
|
51
|
+
selector: string;
|
|
52
|
+
}
|
|
53
|
+
export interface ConsentModal {
|
|
54
|
+
detected: boolean;
|
|
55
|
+
selector: string | null;
|
|
56
|
+
text: string;
|
|
57
|
+
buttons: ConsentButton[];
|
|
58
|
+
checkboxes: ConsentCheckbox[];
|
|
59
|
+
hasGranularControls: boolean;
|
|
60
|
+
layerCount: number;
|
|
61
|
+
screenshotPath: string | null;
|
|
62
|
+
}
|
|
63
|
+
export interface DarkPatternIssue {
|
|
64
|
+
type: DarkPatternType;
|
|
65
|
+
severity: "critical" | "warning" | "info";
|
|
66
|
+
description: string;
|
|
67
|
+
evidence: string;
|
|
68
|
+
}
|
|
69
|
+
export type DarkPatternType = "asymmetric-prominence" | "click-asymmetry" | "pre-ticked" | "misleading-wording" | "cookie-wall" | "nudging" | "no-reject-button" | "buried-reject" | "auto-consent" | "missing-info";
|
|
70
|
+
export interface ComplianceScore {
|
|
71
|
+
total: number;
|
|
72
|
+
breakdown: {
|
|
73
|
+
consentValidity: number;
|
|
74
|
+
easyRefusal: number;
|
|
75
|
+
transparency: number;
|
|
76
|
+
cookieBehavior: number;
|
|
77
|
+
};
|
|
78
|
+
issues: DarkPatternIssue[];
|
|
79
|
+
grade: "A" | "B" | "C" | "D" | "F";
|
|
80
|
+
}
|
|
81
|
+
export interface ScanOptions {
|
|
82
|
+
url: string;
|
|
83
|
+
outputDir: string;
|
|
84
|
+
timeout: number;
|
|
85
|
+
screenshots: boolean;
|
|
86
|
+
locale: string;
|
|
87
|
+
verbose: boolean;
|
|
88
|
+
userAgent?: string;
|
|
89
|
+
}
|
|
90
|
+
export interface ScanResult {
|
|
91
|
+
url: string;
|
|
92
|
+
scanDate: string;
|
|
93
|
+
duration: number;
|
|
94
|
+
modal: ConsentModal;
|
|
95
|
+
cookiesBeforeInteraction: ScannedCookie[];
|
|
96
|
+
cookiesAfterAccept: ScannedCookie[];
|
|
97
|
+
cookiesAfterReject: ScannedCookie[];
|
|
98
|
+
networkBeforeInteraction: NetworkRequest[];
|
|
99
|
+
networkAfterAccept: NetworkRequest[];
|
|
100
|
+
networkAfterReject: NetworkRequest[];
|
|
101
|
+
compliance: ComplianceScore;
|
|
102
|
+
screenshotPaths: string[];
|
|
103
|
+
errors: string[];
|
|
104
|
+
}
|
|
105
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":""}
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@slashgear/gdpr-cookie-scanner",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "CLI tool to scan websites for GDPR cookie consent compliance",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"compliance",
|
|
7
|
+
"consent",
|
|
8
|
+
"cookie",
|
|
9
|
+
"gdpr",
|
|
10
|
+
"playwright",
|
|
11
|
+
"privacy",
|
|
12
|
+
"rgpd",
|
|
13
|
+
"scanner"
|
|
14
|
+
],
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"bin": {
|
|
17
|
+
"gdpr-scan": "dist/cli.js"
|
|
18
|
+
},
|
|
19
|
+
"type": "module",
|
|
20
|
+
"main": "dist/index.js",
|
|
21
|
+
"publishConfig": {
|
|
22
|
+
"access": "public",
|
|
23
|
+
"registry": "https://registry.npmjs.org"
|
|
24
|
+
},
|
|
25
|
+
"scripts": {
|
|
26
|
+
"build": "tsc",
|
|
27
|
+
"dev": "tsc --watch",
|
|
28
|
+
"start": "node dist/cli.js",
|
|
29
|
+
"lint": "oxlint .",
|
|
30
|
+
"lint:fix": "oxlint --fix .",
|
|
31
|
+
"format": "oxfmt .",
|
|
32
|
+
"format:check": "oxfmt --check .",
|
|
33
|
+
"typecheck": "tsc --noEmit",
|
|
34
|
+
"changeset": "changeset"
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"chalk": "^5.3.0",
|
|
38
|
+
"commander": "^12.1.0",
|
|
39
|
+
"ora": "^8.1.0",
|
|
40
|
+
"playwright": "^1.49.0"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@changesets/cli": "^2.29.8",
|
|
44
|
+
"@types/node": "^22.0.0",
|
|
45
|
+
"oxfmt": "^0.33.0",
|
|
46
|
+
"oxlint": "^1.48.0",
|
|
47
|
+
"typescript": "^5.5.0"
|
|
48
|
+
},
|
|
49
|
+
"engines": {
|
|
50
|
+
"node": ">=20.0.0"
|
|
51
|
+
}
|
|
52
|
+
}
|
package/renovate.json
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
|
3
|
+
"extends": ["config:recommended"],
|
|
4
|
+
"schedule": ["every weekend"],
|
|
5
|
+
"packageRules": [
|
|
6
|
+
{
|
|
7
|
+
"matchDepTypes": ["devDependencies"],
|
|
8
|
+
"automerge": true,
|
|
9
|
+
"automergeType": "squash"
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
"matchPackageNames": ["playwright"],
|
|
13
|
+
"reviewers": ["Slashgear"],
|
|
14
|
+
"automerge": false
|
|
15
|
+
}
|
|
16
|
+
]
|
|
17
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ComplianceScore,
|
|
3
|
+
ConsentModal,
|
|
4
|
+
DarkPatternIssue,
|
|
5
|
+
ScannedCookie,
|
|
6
|
+
NetworkRequest,
|
|
7
|
+
} from "../types.js";
|
|
8
|
+
import { analyzeButtonWording, analyzeModalText } from "./wording.js";
|
|
9
|
+
|
|
10
|
+
interface ComplianceInput {
|
|
11
|
+
modal: ConsentModal;
|
|
12
|
+
cookiesBeforeInteraction: ScannedCookie[];
|
|
13
|
+
cookiesAfterAccept: ScannedCookie[];
|
|
14
|
+
cookiesAfterReject: ScannedCookie[];
|
|
15
|
+
networkBeforeInteraction: NetworkRequest[];
|
|
16
|
+
networkAfterAccept: NetworkRequest[];
|
|
17
|
+
networkAfterReject: NetworkRequest[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function analyzeCompliance(input: ComplianceInput): ComplianceScore {
|
|
21
|
+
const issues: DarkPatternIssue[] = [];
|
|
22
|
+
|
|
23
|
+
// ── A. Consent validity (0-25) ────────────────────────────────
|
|
24
|
+
let consentValidity = 25;
|
|
25
|
+
|
|
26
|
+
if (!input.modal.detected) {
|
|
27
|
+
issues.push({
|
|
28
|
+
type: "no-reject-button",
|
|
29
|
+
severity: "critical",
|
|
30
|
+
description: "No cookie consent modal detected",
|
|
31
|
+
evidence: "A consent mechanism is required before depositing non-essential cookies",
|
|
32
|
+
});
|
|
33
|
+
consentValidity = 0;
|
|
34
|
+
} else {
|
|
35
|
+
// Wording analysis
|
|
36
|
+
const wordingResult = analyzeButtonWording(input.modal.buttons);
|
|
37
|
+
const textResult = analyzeModalText(input.modal.text);
|
|
38
|
+
issues.push(...wordingResult.issues, ...textResult.issues);
|
|
39
|
+
|
|
40
|
+
// Pre-ticked checkboxes
|
|
41
|
+
const preTicked = input.modal.checkboxes.filter((c) => c.isCheckedByDefault);
|
|
42
|
+
if (preTicked.length > 0) {
|
|
43
|
+
issues.push({
|
|
44
|
+
type: "pre-ticked",
|
|
45
|
+
severity: "critical",
|
|
46
|
+
description: `${preTicked.length} checkbox(es) pre-ticked by default`,
|
|
47
|
+
evidence: `Pre-ticked boxes are invalid consent under RGPD Recital 32. Affected: ${preTicked.map((c) => c.label || c.name).join(", ")}`,
|
|
48
|
+
});
|
|
49
|
+
consentValidity -= 10;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Missing info deductions
|
|
53
|
+
if (textResult.missingInfo.includes("purposes")) consentValidity -= 5;
|
|
54
|
+
if (textResult.missingInfo.includes("third-parties")) consentValidity -= 5;
|
|
55
|
+
if (textResult.missingInfo.length >= 3) consentValidity -= 5;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ── B. Easy refusal (0-25) ────────────────────────────────────
|
|
59
|
+
let easyRefusal = 25;
|
|
60
|
+
|
|
61
|
+
if (!input.modal.detected) {
|
|
62
|
+
easyRefusal = 0;
|
|
63
|
+
} else {
|
|
64
|
+
const acceptButton = input.modal.buttons.find((b) => b.type === "accept");
|
|
65
|
+
const rejectButton = input.modal.buttons.find((b) => b.type === "reject");
|
|
66
|
+
|
|
67
|
+
if (!rejectButton) {
|
|
68
|
+
issues.push({
|
|
69
|
+
type: "buried-reject",
|
|
70
|
+
severity: "critical",
|
|
71
|
+
description: "No reject button on first layer",
|
|
72
|
+
evidence: "CNIL (2022) requires reject to require no more clicks than accept",
|
|
73
|
+
});
|
|
74
|
+
easyRefusal -= 15;
|
|
75
|
+
} else if (rejectButton.clickDepth > (acceptButton?.clickDepth ?? 1)) {
|
|
76
|
+
issues.push({
|
|
77
|
+
type: "click-asymmetry",
|
|
78
|
+
severity: "critical",
|
|
79
|
+
description: "Reject requires more clicks than accept",
|
|
80
|
+
evidence: `Accept: ${acceptButton?.clickDepth ?? 1} click(s), Reject: ${rejectButton.clickDepth} click(s)`,
|
|
81
|
+
});
|
|
82
|
+
easyRefusal -= 15;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Visual asymmetry: if accept button is significantly larger/more prominent
|
|
86
|
+
if (acceptButton && rejectButton && acceptButton.boundingBox && rejectButton.boundingBox) {
|
|
87
|
+
const acceptArea = acceptButton.boundingBox.width * acceptButton.boundingBox.height;
|
|
88
|
+
const rejectArea = rejectButton.boundingBox.width * rejectButton.boundingBox.height;
|
|
89
|
+
if (acceptArea > rejectArea * 3) {
|
|
90
|
+
issues.push({
|
|
91
|
+
type: "asymmetric-prominence",
|
|
92
|
+
severity: "warning",
|
|
93
|
+
description: "Accept button is significantly larger than reject button",
|
|
94
|
+
evidence: `Accept area: ${Math.round(acceptArea)}px², Reject area: ${Math.round(rejectArea)}px²`,
|
|
95
|
+
});
|
|
96
|
+
easyRefusal -= 5;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Font size asymmetry
|
|
101
|
+
if (acceptButton?.fontSize && rejectButton?.fontSize) {
|
|
102
|
+
if (acceptButton.fontSize > rejectButton.fontSize * 1.3) {
|
|
103
|
+
issues.push({
|
|
104
|
+
type: "nudging",
|
|
105
|
+
severity: "warning",
|
|
106
|
+
description: "Accept button font is significantly larger than reject button",
|
|
107
|
+
evidence: `Accept: ${acceptButton.fontSize}px, Reject: ${rejectButton.fontSize}px`,
|
|
108
|
+
});
|
|
109
|
+
easyRefusal -= 5;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ── C. Transparency (0-25) ────────────────────────────────────
|
|
115
|
+
let transparency = 25;
|
|
116
|
+
|
|
117
|
+
if (!input.modal.detected) {
|
|
118
|
+
transparency = 0;
|
|
119
|
+
} else {
|
|
120
|
+
if (!input.modal.hasGranularControls) {
|
|
121
|
+
transparency -= 10;
|
|
122
|
+
}
|
|
123
|
+
// Already deducted in consentValidity for missing info
|
|
124
|
+
const wordingResult = analyzeModalText(input.modal.text);
|
|
125
|
+
if (wordingResult.missingInfo.length > 0) {
|
|
126
|
+
transparency -= wordingResult.missingInfo.length * 3;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ── D. Cookie behavior (0-25) ─────────────────────────────────
|
|
131
|
+
let cookieBehavior = 25;
|
|
132
|
+
|
|
133
|
+
// Cookies deposited before any interaction that require consent
|
|
134
|
+
const illegalPreConsentCookies = input.cookiesBeforeInteraction.filter((c) => c.requiresConsent);
|
|
135
|
+
|
|
136
|
+
if (illegalPreConsentCookies.length > 0) {
|
|
137
|
+
issues.push({
|
|
138
|
+
type: "auto-consent",
|
|
139
|
+
severity: "critical",
|
|
140
|
+
description: `${illegalPreConsentCookies.length} non-essential cookie(s) deposited before any interaction`,
|
|
141
|
+
evidence: illegalPreConsentCookies.map((c) => `${c.name} (${c.category})`).join(", "),
|
|
142
|
+
});
|
|
143
|
+
cookieBehavior -= Math.min(20, illegalPreConsentCookies.length * 4);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Non-essential cookies persisting after reject
|
|
147
|
+
const consentCookiesAfterReject = input.cookiesAfterReject.filter(
|
|
148
|
+
(c) => c.requiresConsent && c.capturedAt === "after-reject",
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
if (consentCookiesAfterReject.length > 0) {
|
|
152
|
+
issues.push({
|
|
153
|
+
type: "auto-consent",
|
|
154
|
+
severity: "critical",
|
|
155
|
+
description: `${consentCookiesAfterReject.length} non-essential cookie(s) persist after rejection`,
|
|
156
|
+
evidence: consentCookiesAfterReject.map((c) => `${c.name} (${c.category})`).join(", "),
|
|
157
|
+
});
|
|
158
|
+
cookieBehavior -= Math.min(15, consentCookiesAfterReject.length * 3);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Network trackers firing before interaction
|
|
162
|
+
const preInteractionTrackers = input.networkBeforeInteraction.filter(
|
|
163
|
+
(r) => r.trackerCategory !== null && r.trackerCategory !== "cdn",
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
if (preInteractionTrackers.length > 0) {
|
|
167
|
+
issues.push({
|
|
168
|
+
type: "auto-consent",
|
|
169
|
+
severity: "critical",
|
|
170
|
+
description: `${preInteractionTrackers.length} tracker request(s) fired before any consent`,
|
|
171
|
+
evidence: [...new Set(preInteractionTrackers.map((r) => r.trackerName ?? r.url))]
|
|
172
|
+
.slice(0, 5)
|
|
173
|
+
.join(", "),
|
|
174
|
+
});
|
|
175
|
+
cookieBehavior -= Math.min(10, preInteractionTrackers.length * 2);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Clamp all scores
|
|
179
|
+
const clamp = (v: number) => Math.max(0, Math.min(25, v));
|
|
180
|
+
const breakdown = {
|
|
181
|
+
consentValidity: clamp(consentValidity),
|
|
182
|
+
easyRefusal: clamp(easyRefusal),
|
|
183
|
+
transparency: clamp(transparency),
|
|
184
|
+
cookieBehavior: clamp(cookieBehavior),
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
const total = Object.values(breakdown).reduce((a, b) => a + b, 0);
|
|
188
|
+
|
|
189
|
+
return {
|
|
190
|
+
total,
|
|
191
|
+
breakdown,
|
|
192
|
+
issues,
|
|
193
|
+
grade: scoreToGrade(total),
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function scoreToGrade(score: number): "A" | "B" | "C" | "D" | "F" {
|
|
198
|
+
if (score >= 90) return "A";
|
|
199
|
+
if (score >= 75) return "B";
|
|
200
|
+
if (score >= 55) return "C";
|
|
201
|
+
if (score >= 35) return "D";
|
|
202
|
+
return "F";
|
|
203
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import type { ConsentButton, DarkPatternIssue } from "../types.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Ambiguous button labels that don't clearly express consent.
|
|
5
|
+
* These are used as "accept" but don't say "accept" — a dark pattern.
|
|
6
|
+
*/
|
|
7
|
+
const MISLEADING_ACCEPT_LABELS = [
|
|
8
|
+
/^(ok|okay|got it|understood|d'accord|compris|j'ai compris|c'est ok|continuer|continue|proceed|go ahead|next|suivant|proceed)$/i,
|
|
9
|
+
/^(i agree|i understand|i consent)$/i, // acceptable but worth flagging as borderline
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Labels that suggest rejection but are actually just "close" or navigate away.
|
|
14
|
+
*/
|
|
15
|
+
const FAKE_REJECT_LABELS = [/^(×|✕|✖|close|fermer|dismiss|ignorer|skip|passer)$/i];
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Required informational elements in consent text (RGPD Art. 13-14).
|
|
19
|
+
*/
|
|
20
|
+
const REQUIRED_INFO_PATTERNS = [
|
|
21
|
+
{ key: "purposes", patterns: [/finalit[eé]|purpose|objectif|utilisation/i] },
|
|
22
|
+
{ key: "third-parties", patterns: [/partenaire|tiers|third.part|sous.traitant|vendor/i] },
|
|
23
|
+
{
|
|
24
|
+
key: "duration",
|
|
25
|
+
patterns: [/dur[eé]e|expir|conservation|validit[eé]|period|month|year|mois|an(s)?/i],
|
|
26
|
+
},
|
|
27
|
+
{ key: "withdrawal", patterns: [/retrait|retirer|withdraw|revok|modif|changer|chang/i] },
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
export interface WordingAnalysis {
|
|
31
|
+
issues: DarkPatternIssue[];
|
|
32
|
+
missingInfo: string[];
|
|
33
|
+
hasPositiveActionForAccept: boolean;
|
|
34
|
+
hasExplicitRejectOption: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function analyzeButtonWording(buttons: ConsentButton[]): WordingAnalysis {
|
|
38
|
+
const issues: DarkPatternIssue[] = [];
|
|
39
|
+
const acceptButton = buttons.find((b) => b.type === "accept");
|
|
40
|
+
const rejectButton = buttons.find((b) => b.type === "reject");
|
|
41
|
+
const prefButton = buttons.find((b) => b.type === "preferences");
|
|
42
|
+
|
|
43
|
+
// ── Misleading "accept" wording ──────────────────────────────
|
|
44
|
+
if (acceptButton) {
|
|
45
|
+
for (const pattern of MISLEADING_ACCEPT_LABELS) {
|
|
46
|
+
if (pattern.test(acceptButton.text.trim())) {
|
|
47
|
+
issues.push({
|
|
48
|
+
type: "misleading-wording",
|
|
49
|
+
severity: "warning",
|
|
50
|
+
description: `Accept button has ambiguous label: "${acceptButton.text}"`,
|
|
51
|
+
evidence: `Button text "${acceptButton.text}" does not clearly express consent`,
|
|
52
|
+
});
|
|
53
|
+
break;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ── No reject button at all ───────────────────────────────────
|
|
59
|
+
if (!rejectButton && !prefButton) {
|
|
60
|
+
issues.push({
|
|
61
|
+
type: "no-reject-button",
|
|
62
|
+
severity: "critical",
|
|
63
|
+
description: "No reject/decline option found in the consent modal",
|
|
64
|
+
evidence: "RGPD requires refusal to be as easy as acceptance (CNIL 2022)",
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ── Fake reject (close button instead) ───────────────────────
|
|
69
|
+
if (rejectButton) {
|
|
70
|
+
for (const pattern of FAKE_REJECT_LABELS) {
|
|
71
|
+
if (pattern.test(rejectButton.text.trim())) {
|
|
72
|
+
issues.push({
|
|
73
|
+
type: "misleading-wording",
|
|
74
|
+
severity: "critical",
|
|
75
|
+
description: `Reject button has misleading label: "${rejectButton.text}"`,
|
|
76
|
+
evidence: "A close/dismiss button is not a valid rejection mechanism",
|
|
77
|
+
});
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
issues,
|
|
85
|
+
missingInfo: [], // filled in by analyzeModalText
|
|
86
|
+
hasPositiveActionForAccept: !!acceptButton,
|
|
87
|
+
hasExplicitRejectOption: !!rejectButton,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function analyzeModalText(text: string): {
|
|
92
|
+
missingInfo: string[];
|
|
93
|
+
issues: DarkPatternIssue[];
|
|
94
|
+
} {
|
|
95
|
+
const missingInfo: string[] = [];
|
|
96
|
+
const issues: DarkPatternIssue[] = [];
|
|
97
|
+
|
|
98
|
+
for (const { key, patterns } of REQUIRED_INFO_PATTERNS) {
|
|
99
|
+
const found = patterns.some((p) => p.test(text));
|
|
100
|
+
if (!found) {
|
|
101
|
+
missingInfo.push(key);
|
|
102
|
+
issues.push({
|
|
103
|
+
type: "missing-info",
|
|
104
|
+
severity: "warning",
|
|
105
|
+
description: `Missing required information: "${key}"`,
|
|
106
|
+
evidence: `The consent text does not mention ${key}`,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return { missingInfo, issues };
|
|
112
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import type { CookieCategory } from "../types.js";
|
|
2
|
+
|
|
3
|
+
interface CookieClassification {
|
|
4
|
+
category: CookieCategory;
|
|
5
|
+
requiresConsent: boolean;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Cookie name patterns mapped to categories.
|
|
10
|
+
* Patterns are checked against the cookie name (case-insensitive).
|
|
11
|
+
*/
|
|
12
|
+
const COOKIE_PATTERNS: Array<{
|
|
13
|
+
pattern: RegExp;
|
|
14
|
+
category: CookieCategory;
|
|
15
|
+
requiresConsent: boolean;
|
|
16
|
+
}> = [
|
|
17
|
+
// ── Strictly necessary ────────────────────────────────────────
|
|
18
|
+
{
|
|
19
|
+
pattern: /^(PHPSESSID|JSESSIONID|ASP\.NET_SessionId|__session)$/i,
|
|
20
|
+
category: "strictly-necessary",
|
|
21
|
+
requiresConsent: false,
|
|
22
|
+
},
|
|
23
|
+
{ pattern: /^sess(ion)?[-_]?id$/i, category: "strictly-necessary", requiresConsent: false },
|
|
24
|
+
{
|
|
25
|
+
pattern: /^(csrf|xsrf|_token|authenticity_token)[-_]?/i,
|
|
26
|
+
category: "strictly-necessary",
|
|
27
|
+
requiresConsent: false,
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
pattern: /^(auth|authenticated|login|logged[-_]in)[-_]?/i,
|
|
31
|
+
category: "strictly-necessary",
|
|
32
|
+
requiresConsent: false,
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
pattern: /^(cart|basket|bag|checkout)[-_]?/i,
|
|
36
|
+
category: "strictly-necessary",
|
|
37
|
+
requiresConsent: false,
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
pattern: /^(lang|locale|language|country|currency)$/i,
|
|
41
|
+
category: "strictly-necessary",
|
|
42
|
+
requiresConsent: false,
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
pattern: /^(consent|cookie[-_]consent|cc[-_]cookie|cookieconsent)[-_]?/i,
|
|
46
|
+
category: "strictly-necessary",
|
|
47
|
+
requiresConsent: false,
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
pattern: /^(axeptio|didomi|cookiebot|onetrust|tarteaucitron)[-_]?/i,
|
|
51
|
+
category: "strictly-necessary",
|
|
52
|
+
requiresConsent: false,
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
// ── Analytics ──────────────────────────────────────────────────
|
|
56
|
+
{ pattern: /^_ga$/i, category: "analytics", requiresConsent: true },
|
|
57
|
+
{ pattern: /^_ga_/i, category: "analytics", requiresConsent: true },
|
|
58
|
+
{ pattern: /^_gid$/i, category: "analytics", requiresConsent: true },
|
|
59
|
+
{ pattern: /^_gat/i, category: "analytics", requiresConsent: true },
|
|
60
|
+
{ pattern: /^_utm/i, category: "analytics", requiresConsent: true },
|
|
61
|
+
{ pattern: /^__utm/i, category: "analytics", requiresConsent: true },
|
|
62
|
+
{ pattern: /^_pk_/i, category: "analytics", requiresConsent: true }, // Matomo/Piwik
|
|
63
|
+
{ pattern: /^pk_/i, category: "analytics", requiresConsent: true },
|
|
64
|
+
{ pattern: /^amp_/i, category: "analytics", requiresConsent: true }, // Amplitude
|
|
65
|
+
{ pattern: /^(ajs_|segment_)/i, category: "analytics", requiresConsent: true }, // Segment
|
|
66
|
+
{ pattern: /^_hjSessionUser/i, category: "analytics", requiresConsent: true }, // Hotjar
|
|
67
|
+
{ pattern: /^_hj/i, category: "analytics", requiresConsent: true },
|
|
68
|
+
{ pattern: /^mixpanel/i, category: "analytics", requiresConsent: true },
|
|
69
|
+
{ pattern: /^(heap_|heap\.)/i, category: "analytics", requiresConsent: true },
|
|
70
|
+
{ pattern: /^(clarity_|clid|CLID)$/i, category: "analytics", requiresConsent: true }, // Microsoft Clarity
|
|
71
|
+
|
|
72
|
+
// ── Advertising ────────────────────────────────────────────────
|
|
73
|
+
{ pattern: /^(_fbp|_fbc|fb_)/, category: "advertising", requiresConsent: true }, // Meta/Facebook
|
|
74
|
+
{
|
|
75
|
+
pattern: /^(IDE|NID|DSID|ANID|__gads|__gpi|FCNEC)$/i,
|
|
76
|
+
category: "advertising",
|
|
77
|
+
requiresConsent: true,
|
|
78
|
+
}, // Google Ads
|
|
79
|
+
{
|
|
80
|
+
pattern: /^(muid|MUID|at_check|atidvisitor)$/i,
|
|
81
|
+
category: "advertising",
|
|
82
|
+
requiresConsent: true,
|
|
83
|
+
}, // Microsoft
|
|
84
|
+
{ pattern: /^(li_|linkedin_|bcookie|bscookie)/, category: "advertising", requiresConsent: true }, // LinkedIn
|
|
85
|
+
{
|
|
86
|
+
pattern: /^(twitter|_twitter_sess|personalization_id|guest_id)$/i,
|
|
87
|
+
category: "advertising",
|
|
88
|
+
requiresConsent: true,
|
|
89
|
+
},
|
|
90
|
+
{ pattern: /^(criteo_|cto_|uid)$/i, category: "advertising", requiresConsent: true }, // Criteo
|
|
91
|
+
{ pattern: /^(tapad|tapid)$/i, category: "advertising", requiresConsent: true },
|
|
92
|
+
{ pattern: /^(DoubleClick|DCLK)$/i, category: "advertising", requiresConsent: true },
|
|
93
|
+
{ pattern: /^_ttp$/i, category: "advertising", requiresConsent: true }, // TikTok
|
|
94
|
+
|
|
95
|
+
// ── Social ─────────────────────────────────────────────────────
|
|
96
|
+
{ pattern: /^(fbsr_|fbm_)/, category: "social", requiresConsent: true }, // Facebook login
|
|
97
|
+
{ pattern: /^(yt-|VISITOR_INFO|YSC|GPS)$/i, category: "social", requiresConsent: true }, // YouTube
|
|
98
|
+
|
|
99
|
+
// ── Personalization ────────────────────────────────────────────
|
|
100
|
+
{
|
|
101
|
+
pattern: /^(ab_|abt_|abtest|experiment|variant|split[-_]test)/i,
|
|
102
|
+
category: "personalization",
|
|
103
|
+
requiresConsent: true,
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
pattern: /^(optimizely|vwo_|convert_|cxense)/i,
|
|
107
|
+
category: "personalization",
|
|
108
|
+
requiresConsent: true,
|
|
109
|
+
},
|
|
110
|
+
];
|
|
111
|
+
|
|
112
|
+
export function classifyCookie(name: string, domain: string, value: string): CookieClassification {
|
|
113
|
+
for (const { pattern, category, requiresConsent } of COOKIE_PATTERNS) {
|
|
114
|
+
if (pattern.test(name)) {
|
|
115
|
+
return { category, requiresConsent };
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Heuristic: very short session cookie with no clear purpose
|
|
120
|
+
if (name.length <= 4 && !value.includes("=")) {
|
|
121
|
+
return { category: "unknown", requiresConsent: true };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return { category: "unknown", requiresConsent: false };
|
|
125
|
+
}
|