@slashgear/gdpr-cookie-scanner 2.0.4 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/update-trackers.yml +95 -0
- package/CHANGELOG.md +102 -0
- package/CLAUDE.md +1 -1
- package/README.md +78 -5
- package/dist/analyzers/compliance.d.ts.map +1 -1
- package/dist/analyzers/compliance.js +52 -9
- package/dist/analyzers/compliance.js.map +1 -1
- package/dist/classifiers/cookie-classifier.d.ts.map +1 -1
- package/dist/classifiers/cookie-classifier.js +2 -1
- package/dist/classifiers/cookie-classifier.js.map +1 -1
- package/dist/classifiers/network-classifier.d.ts +1 -0
- package/dist/classifiers/network-classifier.d.ts.map +1 -1
- package/dist/classifiers/network-classifier.js +10 -1
- package/dist/classifiers/network-classifier.js.map +1 -1
- package/dist/classifiers/tracker-list.d.ts +1 -0
- package/dist/classifiers/tracker-list.d.ts.map +1 -1
- package/dist/classifiers/tracker-list.js +7 -1
- package/dist/classifiers/tracker-list.js.map +1 -1
- package/dist/cli.js +1 -1
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +51 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +46 -0
- package/dist/index.js.map +1 -0
- package/dist/report/generator.d.ts.map +1 -1
- package/dist/report/generator.js +82 -41
- package/dist/report/generator.js.map +1 -1
- package/dist/scanner/index.js +4 -4
- package/dist/scanner/index.js.map +1 -1
- package/dist/scanner/network.d.ts.map +1 -1
- package/dist/scanner/network.js +1 -0
- package/dist/scanner/network.js.map +1 -1
- package/dist/types.d.ts +2 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +11 -2
- package/scripts/update-trackers.ts +273 -0
- package/src/analyzers/compliance.ts +54 -11
- package/src/classifiers/cookie-classifier.ts +2 -1
- package/src/classifiers/network-classifier.ts +11 -1
- package/src/classifiers/tracker-list.ts +9 -1
- package/src/cli.ts +1 -5
- package/src/index.ts +87 -0
- package/src/report/generator.ts +83 -44
- package/src/scanner/index.ts +4 -4
- package/src/scanner/network.ts +1 -0
- package/src/types.ts +2 -1
- package/tests/analyzers/compliance.test.ts +489 -0
- package/tests/analyzers/wording.test.ts +160 -0
- package/tests/classifiers/cookie-classifier.test.ts +270 -0
- package/tests/classifiers/network-classifier.test.ts +140 -0
- package/tests/e2e/scanner.test.ts +4 -7
- package/tests/unit/compliance.test.ts +99 -13
- package/tests/unit/network-classifier.test.ts +27 -0
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { classifyCookie } from "../../src/classifiers/cookie-classifier.js";
|
|
3
|
+
|
|
4
|
+
describe("classifyCookie", () => {
|
|
5
|
+
// ── Strictly necessary ───────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
describe("strictly-necessary", () => {
|
|
8
|
+
it.each(["PHPSESSID", "JSESSIONID", "ASP.NET_SessionId", "__session"])(
|
|
9
|
+
"classifies %s as strictly-necessary",
|
|
10
|
+
(name) => {
|
|
11
|
+
const result = classifyCookie(name, "example.com", "abc123");
|
|
12
|
+
expect(result).toEqual({ category: "strictly-necessary", requiresConsent: false });
|
|
13
|
+
},
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
it("classifies session_id as strictly-necessary", () => {
|
|
17
|
+
expect(classifyCookie("session_id", "example.com", "xyz")).toEqual({
|
|
18
|
+
category: "strictly-necessary",
|
|
19
|
+
requiresConsent: false,
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it.each(["csrf_token", "xsrf_token", "_token", "authenticity_token"])(
|
|
24
|
+
"classifies CSRF cookie %s as strictly-necessary",
|
|
25
|
+
(name) => {
|
|
26
|
+
expect(classifyCookie(name, "example.com", "token")).toEqual({
|
|
27
|
+
category: "strictly-necessary",
|
|
28
|
+
requiresConsent: false,
|
|
29
|
+
});
|
|
30
|
+
},
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
it.each(["auth_token", "authenticated", "login_session", "logged_in"])(
|
|
34
|
+
"classifies auth cookie %s as strictly-necessary",
|
|
35
|
+
(name) => {
|
|
36
|
+
expect(classifyCookie(name, "example.com", "1")).toEqual({
|
|
37
|
+
category: "strictly-necessary",
|
|
38
|
+
requiresConsent: false,
|
|
39
|
+
});
|
|
40
|
+
},
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
it.each(["cart_id", "basket", "checkout_token"])(
|
|
44
|
+
"classifies cart/checkout cookie %s as strictly-necessary",
|
|
45
|
+
(name) => {
|
|
46
|
+
expect(classifyCookie(name, "example.com", "abc")).toEqual({
|
|
47
|
+
category: "strictly-necessary",
|
|
48
|
+
requiresConsent: false,
|
|
49
|
+
});
|
|
50
|
+
},
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
it.each(["lang", "locale", "language", "country", "currency"])(
|
|
54
|
+
"classifies preference cookie %s as strictly-necessary",
|
|
55
|
+
(name) => {
|
|
56
|
+
expect(classifyCookie(name, "example.com", "fr")).toEqual({
|
|
57
|
+
category: "strictly-necessary",
|
|
58
|
+
requiresConsent: false,
|
|
59
|
+
});
|
|
60
|
+
},
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
it.each(["cookieconsent_status", "cookie_consent", "cc_cookie"])(
|
|
64
|
+
"classifies CMP storage cookie %s as strictly-necessary",
|
|
65
|
+
(name) => {
|
|
66
|
+
expect(classifyCookie(name, "example.com", "granted")).toEqual({
|
|
67
|
+
category: "strictly-necessary",
|
|
68
|
+
requiresConsent: false,
|
|
69
|
+
});
|
|
70
|
+
},
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
it.each(["axeptio_cookies", "didomi_token", "CookieConsent", "tarteaucitron"])(
|
|
74
|
+
"classifies known CMP cookie %s as strictly-necessary",
|
|
75
|
+
(name) => {
|
|
76
|
+
expect(classifyCookie(name, "example.com", "{}")).toEqual({
|
|
77
|
+
category: "strictly-necessary",
|
|
78
|
+
requiresConsent: false,
|
|
79
|
+
});
|
|
80
|
+
},
|
|
81
|
+
);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// ── Analytics ────────────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
describe("analytics", () => {
|
|
87
|
+
it.each(["_ga", "_gid"])(
|
|
88
|
+
"classifies Google Analytics cookie %s as analytics requiring consent",
|
|
89
|
+
(name) => {
|
|
90
|
+
expect(classifyCookie(name, "example.com", "GA1.2.xxx")).toEqual({
|
|
91
|
+
category: "analytics",
|
|
92
|
+
requiresConsent: true,
|
|
93
|
+
});
|
|
94
|
+
},
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
it("classifies _ga_XXXXX (GA4 stream) as analytics", () => {
|
|
98
|
+
expect(classifyCookie("_ga_K1A2B3C4D5", "example.com", "GS1.1.xxx")).toEqual({
|
|
99
|
+
category: "analytics",
|
|
100
|
+
requiresConsent: true,
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it.each(["_gat", "_gat_UA-123456"])(
|
|
105
|
+
"classifies GA rate-limiter cookie %s as analytics",
|
|
106
|
+
(name) => {
|
|
107
|
+
expect(classifyCookie(name, "example.com", "1")).toEqual({
|
|
108
|
+
category: "analytics",
|
|
109
|
+
requiresConsent: true,
|
|
110
|
+
});
|
|
111
|
+
},
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
it.each(["_utma", "_utmb", "_utmz"])("classifies legacy UTM cookie %s as analytics", (name) => {
|
|
115
|
+
expect(classifyCookie(name, "example.com", "xxx")).toEqual({
|
|
116
|
+
category: "analytics",
|
|
117
|
+
requiresConsent: true,
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("classifies __utmz as analytics", () => {
|
|
122
|
+
expect(classifyCookie("__utmz", "example.com", "xxx")).toEqual({
|
|
123
|
+
category: "analytics",
|
|
124
|
+
requiresConsent: true,
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("classifies Matomo _pk_ cookie as analytics", () => {
|
|
129
|
+
expect(classifyCookie("_pk_id.1.1fff", "example.com", "xxx")).toEqual({
|
|
130
|
+
category: "analytics",
|
|
131
|
+
requiresConsent: true,
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("classifies Amplitude amp_ cookie as analytics", () => {
|
|
136
|
+
expect(classifyCookie("amp_abc123", "example.com", "xxx")).toEqual({
|
|
137
|
+
category: "analytics",
|
|
138
|
+
requiresConsent: true,
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("classifies Hotjar _hj cookie as analytics", () => {
|
|
143
|
+
expect(classifyCookie("_hjSessionUser_123", "example.com", "xxx")).toEqual({
|
|
144
|
+
category: "analytics",
|
|
145
|
+
requiresConsent: true,
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("classifies Microsoft Clarity CLID as analytics", () => {
|
|
150
|
+
expect(classifyCookie("CLID", "example.com", "xxx")).toEqual({
|
|
151
|
+
category: "analytics",
|
|
152
|
+
requiresConsent: true,
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// ── Advertising ──────────────────────────────────────────────────
|
|
158
|
+
|
|
159
|
+
describe("advertising", () => {
|
|
160
|
+
it.each(["_fbp", "_fbc", "fb_id"])(
|
|
161
|
+
"classifies Meta/Facebook cookie %s as advertising",
|
|
162
|
+
(name) => {
|
|
163
|
+
expect(classifyCookie(name, "example.com", "xxx")).toEqual({
|
|
164
|
+
category: "advertising",
|
|
165
|
+
requiresConsent: true,
|
|
166
|
+
});
|
|
167
|
+
},
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
it.each(["IDE", "NID", "DSID", "ANID", "__gads", "__gpi", "FCNEC"])(
|
|
171
|
+
"classifies Google Ads cookie %s as advertising",
|
|
172
|
+
(name) => {
|
|
173
|
+
expect(classifyCookie(name, "example.com", "xxx")).toEqual({
|
|
174
|
+
category: "advertising",
|
|
175
|
+
requiresConsent: true,
|
|
176
|
+
});
|
|
177
|
+
},
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
it("classifies MUID (Microsoft) as advertising", () => {
|
|
181
|
+
expect(classifyCookie("MUID", "example.com", "xxx")).toEqual({
|
|
182
|
+
category: "advertising",
|
|
183
|
+
requiresConsent: true,
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("classifies li_fat_id (LinkedIn) as advertising", () => {
|
|
188
|
+
expect(classifyCookie("li_fat_id", "example.com", "xxx")).toEqual({
|
|
189
|
+
category: "advertising",
|
|
190
|
+
requiresConsent: true,
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("classifies _ttp (TikTok) as advertising", () => {
|
|
195
|
+
expect(classifyCookie("_ttp", "example.com", "xxx")).toEqual({
|
|
196
|
+
category: "advertising",
|
|
197
|
+
requiresConsent: true,
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
// ── Social ───────────────────────────────────────────────────────
|
|
203
|
+
|
|
204
|
+
describe("social", () => {
|
|
205
|
+
it("classifies fbsr_ (Facebook login) as social", () => {
|
|
206
|
+
expect(classifyCookie("fbsr_123456", "example.com", "xxx")).toEqual({
|
|
207
|
+
category: "social",
|
|
208
|
+
requiresConsent: true,
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it.each(["YSC", "VISITOR_INFO1_LIVE", "GPS"])(
|
|
213
|
+
"classifies YouTube cookie %s as social",
|
|
214
|
+
(name) => {
|
|
215
|
+
expect(classifyCookie(name, "example.com", "xxx")).toEqual({
|
|
216
|
+
category: "social",
|
|
217
|
+
requiresConsent: true,
|
|
218
|
+
});
|
|
219
|
+
},
|
|
220
|
+
);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
// ── Personalization ──────────────────────────────────────────────
|
|
224
|
+
|
|
225
|
+
describe("personalization", () => {
|
|
226
|
+
it.each(["ab_test", "abt_variant", "abtest_bucket"])(
|
|
227
|
+
"classifies A/B test cookie %s as personalization",
|
|
228
|
+
(name) => {
|
|
229
|
+
expect(classifyCookie(name, "example.com", "variant_b")).toEqual({
|
|
230
|
+
category: "personalization",
|
|
231
|
+
requiresConsent: true,
|
|
232
|
+
});
|
|
233
|
+
},
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
it("classifies optimizely cookie as personalization", () => {
|
|
237
|
+
expect(classifyCookie("optimizely_data", "example.com", "xxx")).toEqual({
|
|
238
|
+
category: "personalization",
|
|
239
|
+
requiresConsent: true,
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
// ── Unknown / heuristics ─────────────────────────────────────────
|
|
245
|
+
|
|
246
|
+
describe("unknown", () => {
|
|
247
|
+
it("classifies unrecognised long-name cookie as unknown without consent", () => {
|
|
248
|
+
expect(classifyCookie("my_custom_app_cookie", "example.com", "somevalue=abc")).toEqual({
|
|
249
|
+
category: "unknown",
|
|
250
|
+
requiresConsent: false,
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("classifies short-name cookie (≤4 chars, no = in value) as unknown requiring consent", () => {
|
|
255
|
+
// Heuristic: suspicious session-like short cookie
|
|
256
|
+
expect(classifyCookie("sid", "example.com", "abc123")).toEqual({
|
|
257
|
+
category: "unknown",
|
|
258
|
+
requiresConsent: true,
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("does NOT flag short-name cookie as suspicious when value contains =", () => {
|
|
263
|
+
// Base64-encoded values contain = — should not be flagged
|
|
264
|
+
expect(classifyCookie("tok", "example.com", "abc=def")).toEqual({
|
|
265
|
+
category: "unknown",
|
|
266
|
+
requiresConsent: false,
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
});
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { classifyNetworkRequest } from "../../src/classifiers/network-classifier.js";
|
|
3
|
+
|
|
4
|
+
describe("classifyNetworkRequest", () => {
|
|
5
|
+
// ── Known trackers (exact hostname match) ────────────────────────
|
|
6
|
+
|
|
7
|
+
describe("tracker database — exact match", () => {
|
|
8
|
+
it("identifies Google Analytics", () => {
|
|
9
|
+
const result = classifyNetworkRequest("https://google-analytics.com/collect", "xhr");
|
|
10
|
+
expect(result.trackerCategory).toBe("analytics");
|
|
11
|
+
expect(result.trackerName).toBe("Google Analytics");
|
|
12
|
+
expect(result.requiresConsent).toBe(true);
|
|
13
|
+
expect(result.isThirdParty).toBe(true);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("identifies Google Tag Manager", () => {
|
|
17
|
+
const result = classifyNetworkRequest(
|
|
18
|
+
"https://googletagmanager.com/gtm.js?id=GTM-XXXX",
|
|
19
|
+
"script",
|
|
20
|
+
);
|
|
21
|
+
expect(result.trackerCategory).toBe("analytics");
|
|
22
|
+
expect(result.trackerName).toBe("Google Tag Manager");
|
|
23
|
+
expect(result.requiresConsent).toBe(true);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("identifies Meta Pixel (pixel.facebook.com)", () => {
|
|
27
|
+
const result = classifyNetworkRequest("https://pixel.facebook.com/tr?id=123", "xhr");
|
|
28
|
+
expect(result.trackerCategory).toBe("advertising");
|
|
29
|
+
expect(result.trackerName).toBe("Meta Pixel");
|
|
30
|
+
expect(result.requiresConsent).toBe(true);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("identifies LinkedIn Insight Tag", () => {
|
|
34
|
+
const result = classifyNetworkRequest(
|
|
35
|
+
"https://snap.licdn.com/li.lms-analytics/insight.min.js",
|
|
36
|
+
"script",
|
|
37
|
+
);
|
|
38
|
+
expect(result.trackerCategory).toBe("advertising");
|
|
39
|
+
expect(result.requiresConsent).toBe(true);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("identifies Hotjar", () => {
|
|
43
|
+
const result = classifyNetworkRequest("https://hotjar.com/api/v2/event", "xhr");
|
|
44
|
+
expect(result.trackerCategory).toBe("analytics");
|
|
45
|
+
expect(result.trackerName).toBe("Hotjar");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("identifies Microsoft Clarity", () => {
|
|
49
|
+
const result = classifyNetworkRequest("https://clarity.ms/collect", "xhr");
|
|
50
|
+
expect(result.trackerCategory).toBe("analytics");
|
|
51
|
+
expect(result.requiresConsent).toBe(true);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// ── Subdomain suffix match ────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
describe("tracker database — subdomain match", () => {
|
|
58
|
+
it("matches subdomain of google-analytics.com", () => {
|
|
59
|
+
const result = classifyNetworkRequest("https://stats.google-analytics.com/r/collect", "xhr");
|
|
60
|
+
expect(result.trackerName).toBe("Google Analytics");
|
|
61
|
+
expect(result.isThirdParty).toBe(true);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("strips www. before matching", () => {
|
|
65
|
+
const result = classifyNetworkRequest("https://www.google-analytics.com/collect", "xhr");
|
|
66
|
+
expect(result.trackerCategory).toBe("analytics");
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// ── Pixel pattern matching ────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
describe("pixel patterns", () => {
|
|
73
|
+
it("identifies tracking pixels by URL pattern", () => {
|
|
74
|
+
const result = classifyNetworkRequest(
|
|
75
|
+
"https://track.example.com/pixel.gif?uid=123&ev=pageview",
|
|
76
|
+
"image",
|
|
77
|
+
);
|
|
78
|
+
// Either caught by pixel pattern or image heuristic
|
|
79
|
+
expect(result.trackerCategory).toBe("pixel");
|
|
80
|
+
expect(result.requiresConsent).toBe(true);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// ── Image heuristic ───────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
describe("image resource heuristic", () => {
|
|
87
|
+
it("flags 1×1 gif with tracking params as pixel", () => {
|
|
88
|
+
const result = classifyNetworkRequest(
|
|
89
|
+
"https://metrics.unknown-vendor.com/track.gif?uid=abc&ts=1234",
|
|
90
|
+
"image",
|
|
91
|
+
);
|
|
92
|
+
expect(result.trackerCategory).toBe("pixel");
|
|
93
|
+
expect(result.requiresConsent).toBe(true);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("does NOT flag regular image without tracking params", () => {
|
|
97
|
+
const result = classifyNetworkRequest("https://cdn.unknown-vendor.com/hero.png", "image");
|
|
98
|
+
expect(result.trackerCategory).toBeNull();
|
|
99
|
+
expect(result.requiresConsent).toBe(false);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// ── CDN — consent not required ────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
describe("CDN entries", () => {
|
|
106
|
+
it("marks CDN requests as not requiring consent", () => {
|
|
107
|
+
// fbcdn.net is category 'social' not cdn, but let's test the cdn logic
|
|
108
|
+
// by checking that consentRequired=false entries in TRACKER_DB are respected
|
|
109
|
+
// Google fonts is not in the DB, so test a generic unknown
|
|
110
|
+
const result = classifyNetworkRequest("https://totally-unknown-cdn.io/lib.js", "script");
|
|
111
|
+
expect(result.requiresConsent).toBe(false);
|
|
112
|
+
expect(result.trackerCategory).toBeNull();
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// ── Unknown / safe requests ───────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
describe("unknown requests", () => {
|
|
119
|
+
it("returns null classification for an unknown domain", () => {
|
|
120
|
+
const result = classifyNetworkRequest("https://api.my-own-backend.com/data", "xhr");
|
|
121
|
+
expect(result.trackerCategory).toBeNull();
|
|
122
|
+
expect(result.trackerName).toBeNull();
|
|
123
|
+
expect(result.requiresConsent).toBe(false);
|
|
124
|
+
expect(result.isThirdParty).toBe(false);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("returns safe fallback for an invalid URL", () => {
|
|
128
|
+
const result = classifyNetworkRequest("not-a-url", "xhr");
|
|
129
|
+
expect(result.trackerCategory).toBeNull();
|
|
130
|
+
expect(result.requiresConsent).toBe(false);
|
|
131
|
+
expect(result.isThirdParty).toBe(false);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("returns safe fallback for an empty string", () => {
|
|
135
|
+
const result = classifyNetworkRequest("", "xhr");
|
|
136
|
+
expect(result.trackerCategory).toBeNull();
|
|
137
|
+
expect(result.requiresConsent).toBe(false);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
});
|
|
@@ -117,19 +117,16 @@ describe("Scanner E2E", { timeout: E2E_TIMEOUT }, () => {
|
|
|
117
117
|
expect(result.privacyPolicyUrl).toBeTruthy();
|
|
118
118
|
});
|
|
119
119
|
|
|
120
|
-
it("assigns grade
|
|
120
|
+
it("assigns grade A — no consent mechanism needed for a tracking-free site", async () => {
|
|
121
121
|
const scanner = new Scanner(makeOptions("no-modal-site.html"));
|
|
122
122
|
const result = await scanner.run();
|
|
123
|
-
expect(result.compliance.grade).toBe("
|
|
123
|
+
expect(result.compliance.grade).toBe("A");
|
|
124
124
|
});
|
|
125
125
|
|
|
126
|
-
it("raises a
|
|
126
|
+
it("raises no compliance issues for a tracking-free site without a modal", async () => {
|
|
127
127
|
const scanner = new Scanner(makeOptions("no-modal-site.html"));
|
|
128
128
|
const result = await scanner.run();
|
|
129
|
-
|
|
130
|
-
(i) => i.type === "no-reject-button" && i.severity === "critical",
|
|
131
|
-
);
|
|
132
|
-
expect(issue).toBeDefined();
|
|
129
|
+
expect(result.compliance.issues).toHaveLength(0);
|
|
133
130
|
});
|
|
134
131
|
});
|
|
135
132
|
});
|
|
@@ -74,6 +74,7 @@ function makeRequest(
|
|
|
74
74
|
url: string,
|
|
75
75
|
trackerCategory: NetworkRequest["trackerCategory"],
|
|
76
76
|
capturedAt: NetworkRequest["capturedAt"],
|
|
77
|
+
requiresConsent?: boolean,
|
|
77
78
|
): NetworkRequest {
|
|
78
79
|
return {
|
|
79
80
|
url,
|
|
@@ -83,6 +84,7 @@ function makeRequest(
|
|
|
83
84
|
isThirdParty: trackerCategory !== null,
|
|
84
85
|
trackerCategory,
|
|
85
86
|
trackerName: trackerCategory ? "Tracker" : null,
|
|
87
|
+
requiresConsent: requiresConsent ?? (trackerCategory !== null && trackerCategory !== "cdn"),
|
|
86
88
|
capturedAt,
|
|
87
89
|
responseStatus: 200,
|
|
88
90
|
contentType: null,
|
|
@@ -103,25 +105,52 @@ const emptyInputBase = {
|
|
|
103
105
|
|
|
104
106
|
describe("analyzeCompliance", () => {
|
|
105
107
|
describe("no consent modal detected", () => {
|
|
106
|
-
|
|
107
|
-
const
|
|
108
|
+
describe("when non-essential cookies are present (consent required)", () => {
|
|
109
|
+
const inputWithCookies = {
|
|
108
110
|
...emptyInputBase,
|
|
111
|
+
cookiesAfterAccept: [makeCookie("_ga", "analytics", true, "after-accept")],
|
|
109
112
|
modal: makeModal({ detected: false }),
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
it("scores 0 on consentValidity, easyRefusal, and transparency", () => {
|
|
116
|
+
const result = analyzeCompliance(inputWithCookies);
|
|
117
|
+
|
|
118
|
+
expect(result.breakdown.consentValidity).toBe(0);
|
|
119
|
+
expect(result.breakdown.easyRefusal).toBe(0);
|
|
120
|
+
expect(result.breakdown.transparency).toBe(0);
|
|
110
121
|
});
|
|
111
122
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
123
|
+
it("issues a critical no-reject-button issue", () => {
|
|
124
|
+
const result = analyzeCompliance(inputWithCookies);
|
|
125
|
+
const issue = result.issues.find((i) => i.type === "no-reject-button");
|
|
126
|
+
expect(issue).toBeDefined();
|
|
127
|
+
expect(issue?.severity).toBe("critical");
|
|
128
|
+
});
|
|
115
129
|
});
|
|
116
130
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
131
|
+
describe("when no non-essential cookies or trackers are present (consent not required)", () => {
|
|
132
|
+
it("scores full marks — no consent mechanism needed", () => {
|
|
133
|
+
const result = analyzeCompliance({
|
|
134
|
+
...emptyInputBase,
|
|
135
|
+
modal: makeModal({ detected: false }),
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
expect(result.breakdown.consentValidity).toBe(25);
|
|
139
|
+
expect(result.breakdown.easyRefusal).toBe(25);
|
|
140
|
+
expect(result.breakdown.transparency).toBe(25);
|
|
141
|
+
expect(result.breakdown.cookieBehavior).toBe(25);
|
|
142
|
+
expect(result.total).toBe(100);
|
|
143
|
+
expect(result.grade).toBe("A");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("raises no compliance issues", () => {
|
|
147
|
+
const result = analyzeCompliance({
|
|
148
|
+
...emptyInputBase,
|
|
149
|
+
modal: makeModal({ detected: false }),
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
expect(result.issues).toHaveLength(0);
|
|
121
153
|
});
|
|
122
|
-
const issue = result.issues.find((i) => i.type === "no-reject-button");
|
|
123
|
-
expect(issue).toBeDefined();
|
|
124
|
-
expect(issue?.severity).toBe("critical");
|
|
125
154
|
});
|
|
126
155
|
});
|
|
127
156
|
|
|
@@ -292,12 +321,14 @@ describe("analyzeCompliance", () => {
|
|
|
292
321
|
});
|
|
293
322
|
|
|
294
323
|
describe("missing privacy policy link on page", () => {
|
|
295
|
-
it("deducts 3 from transparency and raises a missing-info warning", () => {
|
|
324
|
+
it("deducts 3 from transparency and raises a missing-info warning when consent is required", () => {
|
|
296
325
|
// hasGranularControls: true so only -3 deduction (no -10 for granular)
|
|
326
|
+
// A non-essential cookie is present so consentRequired = true → deduction applies
|
|
297
327
|
const result = analyzeCompliance({
|
|
298
328
|
...emptyInputBase,
|
|
299
329
|
privacyPolicyUrl: null,
|
|
300
330
|
modal: makeModal({ hasGranularControls: true }),
|
|
331
|
+
cookiesAfterAccept: [makeCookie("_ga", "analytics", true, "after-accept")],
|
|
301
332
|
});
|
|
302
333
|
|
|
303
334
|
// 25 - 3 (no page privacy link) = 22
|
|
@@ -307,6 +338,21 @@ describe("analyzeCompliance", () => {
|
|
|
307
338
|
);
|
|
308
339
|
expect(issue).toBeDefined();
|
|
309
340
|
});
|
|
341
|
+
|
|
342
|
+
it("does not deduct when no consent is required (tracking-free site)", () => {
|
|
343
|
+
const result = analyzeCompliance({
|
|
344
|
+
...emptyInputBase,
|
|
345
|
+
privacyPolicyUrl: null,
|
|
346
|
+
modal: makeModal({ hasGranularControls: true }),
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
// No non-essential cookies/trackers → consentRequired = false → no deduction
|
|
350
|
+
expect(result.breakdown.transparency).toBe(25);
|
|
351
|
+
const issue = result.issues.find(
|
|
352
|
+
(i) => i.type === "missing-info" && i.description.includes("page"),
|
|
353
|
+
);
|
|
354
|
+
expect(issue).toBeUndefined();
|
|
355
|
+
});
|
|
310
356
|
});
|
|
311
357
|
|
|
312
358
|
describe("non-essential cookies before consent", () => {
|
|
@@ -457,4 +503,44 @@ describe("analyzeCompliance", () => {
|
|
|
457
503
|
expect(result.grade).toBe("F");
|
|
458
504
|
});
|
|
459
505
|
});
|
|
506
|
+
|
|
507
|
+
describe("consent-exempt tracker (Plausible-like)", () => {
|
|
508
|
+
const plausibleRequest = makeRequest(
|
|
509
|
+
"https://plausible.io/api/event",
|
|
510
|
+
"analytics",
|
|
511
|
+
"before-interaction",
|
|
512
|
+
false, // requiresConsent: false — CNIL ePrivacy exemption
|
|
513
|
+
);
|
|
514
|
+
|
|
515
|
+
it("does not deduct from cookieBehavior when a pre-interaction tracker is consent-exempt", () => {
|
|
516
|
+
const result = analyzeCompliance({
|
|
517
|
+
...emptyInputBase,
|
|
518
|
+
modal: makeModal({ hasGranularControls: true }),
|
|
519
|
+
networkBeforeInteraction: [plausibleRequest],
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
expect(result.breakdown.cookieBehavior).toBe(25);
|
|
523
|
+
const autoConsentIssue = result.issues.find((i) => i.type === "auto-consent");
|
|
524
|
+
expect(autoConsentIssue).toBeUndefined();
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
it("consentRequired is false when only a consent-exempt tracker is present", () => {
|
|
528
|
+
// No modal, only Plausible → should score full marks (no consent needed)
|
|
529
|
+
const result = analyzeCompliance({
|
|
530
|
+
...emptyInputBase,
|
|
531
|
+
modal: makeModal({ detected: false }),
|
|
532
|
+
networkBeforeInteraction: [plausibleRequest],
|
|
533
|
+
networkAfterAccept: [
|
|
534
|
+
makeRequest("https://plausible.io/api/event", "analytics", "after-accept", false),
|
|
535
|
+
],
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
expect(result.breakdown.consentValidity).toBe(25);
|
|
539
|
+
expect(result.breakdown.easyRefusal).toBe(25);
|
|
540
|
+
expect(result.breakdown.transparency).toBe(25);
|
|
541
|
+
expect(result.breakdown.cookieBehavior).toBe(25);
|
|
542
|
+
expect(result.total).toBe(100);
|
|
543
|
+
expect(result.grade).toBe("A");
|
|
544
|
+
});
|
|
545
|
+
});
|
|
460
546
|
});
|
|
@@ -61,6 +61,7 @@ describe("classifyNetworkRequest", () => {
|
|
|
61
61
|
expect(result.isThirdParty).toBe(false);
|
|
62
62
|
expect(result.trackerCategory).toBeNull();
|
|
63
63
|
expect(result.trackerName).toBeNull();
|
|
64
|
+
expect(result.requiresConsent).toBe(false);
|
|
64
65
|
});
|
|
65
66
|
|
|
66
67
|
it("does not flag CDN requests for known CDN domains", () => {
|
|
@@ -78,6 +79,7 @@ describe("classifyNetworkRequest", () => {
|
|
|
78
79
|
expect(result.isThirdParty).toBe(false);
|
|
79
80
|
expect(result.trackerCategory).toBeNull();
|
|
80
81
|
expect(result.trackerName).toBeNull();
|
|
82
|
+
expect(result.requiresConsent).toBe(false);
|
|
81
83
|
});
|
|
82
84
|
});
|
|
83
85
|
|
|
@@ -88,4 +90,29 @@ describe("classifyNetworkRequest", () => {
|
|
|
88
90
|
expect(result.trackerCategory).toBe("analytics");
|
|
89
91
|
});
|
|
90
92
|
});
|
|
93
|
+
|
|
94
|
+
describe("requiresConsent field", () => {
|
|
95
|
+
it("Plausible Analytics (/api/event) is classified as analytics but does not require consent", () => {
|
|
96
|
+
const result = classifyNetworkRequest("https://plausible.io/api/event", "xhr");
|
|
97
|
+
expect(result.isThirdParty).toBe(true);
|
|
98
|
+
expect(result.trackerCategory).toBe("analytics");
|
|
99
|
+
expect(result.trackerName).toBe("Plausible Analytics");
|
|
100
|
+
expect(result.requiresConsent).toBe(false);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("Google Analytics requires consent", () => {
|
|
104
|
+
const result = classifyNetworkRequest(
|
|
105
|
+
"https://www.google-analytics.com/j/collect?v=1&t=event",
|
|
106
|
+
"xhr",
|
|
107
|
+
);
|
|
108
|
+
expect(result.requiresConsent).toBe(true);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("CDN domain (akamaized.net) does not require consent", () => {
|
|
112
|
+
const result = classifyNetworkRequest("https://assets.akamaized.net/bundle.js", "script");
|
|
113
|
+
expect(result.isThirdParty).toBe(true);
|
|
114
|
+
expect(result.trackerCategory).toBe("cdn");
|
|
115
|
+
expect(result.requiresConsent).toBe(false);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
91
118
|
});
|