@slashgear/gdpr-cookie-scanner 1.2.1 → 1.4.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/ci.yml +6 -0
- package/CHANGELOG.md +40 -0
- package/README.md +22 -7
- package/dist/analyzers/compliance.d.ts +1 -0
- package/dist/analyzers/compliance.d.ts.map +1 -1
- package/dist/analyzers/compliance.js +20 -0
- package/dist/analyzers/compliance.js.map +1 -1
- package/dist/report/generator.d.ts.map +1 -1
- package/dist/report/generator.js +23 -0
- package/dist/report/generator.js.map +1 -1
- package/dist/scanner/consent-modal.d.ts +5 -0
- package/dist/scanner/consent-modal.d.ts.map +1 -1
- package/dist/scanner/consent-modal.js +56 -0
- package/dist/scanner/consent-modal.js.map +1 -1
- package/dist/scanner/index.d.ts.map +1 -1
- package/dist/scanner/index.js +5 -1
- package/dist/scanner/index.js.map +1 -1
- package/dist/types.d.ts +2 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +5 -2
- package/src/analyzers/compliance.ts +23 -0
- package/src/report/generator.ts +25 -0
- package/src/scanner/consent-modal.ts +64 -0
- package/src/scanner/index.ts +6 -1
- package/src/types.ts +2 -0
- package/tests/e2e/fixtures/compliant-site.html +80 -0
- package/tests/e2e/fixtures/no-modal-site.html +17 -0
- package/tests/e2e/fixtures/non-compliant-site.html +54 -0
- package/tests/e2e/scanner.test.ts +135 -0
- package/tests/helpers/test-server.ts +57 -0
- package/tests/unit/compliance.test.ts +460 -0
- package/tests/unit/cookie-classifier.test.ts +192 -0
- package/tests/unit/network-classifier.test.ts +91 -0
- package/tests/unit/wording.test.ts +162 -0
- package/vitest.config.ts +9 -0
package/dist/types.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,cAAc,GACtB,oBAAoB,GACpB,WAAW,GACX,aAAa,GACb,QAAQ,GACR,iBAAiB,GACjB,SAAS,CAAC;AAEd,MAAM,MAAM,iBAAiB,GAAG,QAAQ,GAAG,QAAQ,GAAG,aAAa,GAAG,OAAO,GAAG,SAAS,CAAC;AAE1F,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,QAAQ,EAAE,OAAO,CAAC;IAClB,MAAM,EAAE,OAAO,CAAC;IAChB,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,QAAQ,EAAE,cAAc,CAAC;IACzB,eAAe,EAAE,OAAO,CAAC;IACzB,UAAU,EAAE,oBAAoB,GAAG,cAAc,GAAG,cAAc,CAAC;CACpE;AAED,MAAM,WAAW,cAAc;IAC7B,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,EAAE,MAAM,CAAC;IACf,YAAY,EAAE,MAAM,CAAC;IACrB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,YAAY,EAAE,OAAO,CAAC;IACtB,eAAe,EAAE,eAAe,GAAG,IAAI,CAAC;IACxC,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,UAAU,EAAE,oBAAoB,GAAG,cAAc,GAAG,cAAc,CAAC;IACnE,cAAc,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;CAC5B;AAED,MAAM,MAAM,eAAe,GACvB,WAAW,GACX,aAAa,GACb,QAAQ,GACR,gBAAgB,GAChB,OAAO,GACP,KAAK,GACL,SAAS,CAAC;AAEd,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,iBAAiB,CAAC;IACxB,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,OAAO,CAAC;IACnB,WAAW,EAAE;QAAE,CAAC,EAAE,MAAM,CAAC;QAAC,CAAC,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC;IAC5E,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,eAAe,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,aAAa,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,kBAAkB,EAAE,OAAO,CAAC;IAC5B,QAAQ,EAAE,cAAc,CAAC;IACzB,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,YAAY;IAC3B,QAAQ,EAAE,OAAO,CAAC;IAClB,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,aAAa,EAAE,CAAC;IACzB,UAAU,EAAE,eAAe,EAAE,CAAC;IAC9B,mBAAmB,EAAE,OAAO,CAAC;IAC7B,UAAU,EAAE,MAAM,CAAC;IACnB,cAAc,EAAE,MAAM,GAAG,IAAI,CAAC;
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,cAAc,GACtB,oBAAoB,GACpB,WAAW,GACX,aAAa,GACb,QAAQ,GACR,iBAAiB,GACjB,SAAS,CAAC;AAEd,MAAM,MAAM,iBAAiB,GAAG,QAAQ,GAAG,QAAQ,GAAG,aAAa,GAAG,OAAO,GAAG,SAAS,CAAC;AAE1F,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,QAAQ,EAAE,OAAO,CAAC;IAClB,MAAM,EAAE,OAAO,CAAC;IAChB,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,QAAQ,EAAE,cAAc,CAAC;IACzB,eAAe,EAAE,OAAO,CAAC;IACzB,UAAU,EAAE,oBAAoB,GAAG,cAAc,GAAG,cAAc,CAAC;CACpE;AAED,MAAM,WAAW,cAAc;IAC7B,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,EAAE,MAAM,CAAC;IACf,YAAY,EAAE,MAAM,CAAC;IACrB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,YAAY,EAAE,OAAO,CAAC;IACtB,eAAe,EAAE,eAAe,GAAG,IAAI,CAAC;IACxC,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,UAAU,EAAE,oBAAoB,GAAG,cAAc,GAAG,cAAc,CAAC;IACnE,cAAc,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;CAC5B;AAED,MAAM,MAAM,eAAe,GACvB,WAAW,GACX,aAAa,GACb,QAAQ,GACR,gBAAgB,GAChB,OAAO,GACP,KAAK,GACL,SAAS,CAAC;AAEd,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,iBAAiB,CAAC;IACxB,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,OAAO,CAAC;IACnB,WAAW,EAAE;QAAE,CAAC,EAAE,MAAM,CAAC;QAAC,CAAC,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC;IAC5E,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,eAAe,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,aAAa,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,kBAAkB,EAAE,OAAO,CAAC;IAC5B,QAAQ,EAAE,cAAc,CAAC;IACzB,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,YAAY;IAC3B,QAAQ,EAAE,OAAO,CAAC;IAClB,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,aAAa,EAAE,CAAC;IACzB,UAAU,EAAE,eAAe,EAAE,CAAC;IAC9B,mBAAmB,EAAE,OAAO,CAAC;IAC7B,UAAU,EAAE,MAAM,CAAC;IACnB,cAAc,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B,gBAAgB,EAAE,MAAM,GAAG,IAAI,CAAC;CACjC;AAED,MAAM,WAAW,gBAAgB;IAC/B,IAAI,EAAE,eAAe,CAAC;IACtB,QAAQ,EAAE,UAAU,GAAG,SAAS,GAAG,MAAM,CAAC;IAC1C,WAAW,EAAE,MAAM,CAAC;IACpB,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,MAAM,eAAe,GACvB,uBAAuB,GACvB,iBAAiB,GACjB,YAAY,GACZ,oBAAoB,GACpB,aAAa,GACb,SAAS,GACT,kBAAkB,GAClB,eAAe,GACf,cAAc,GACd,cAAc,CAAC;AAEnB,MAAM,WAAW,eAAe;IAC9B,KAAK,EAAE,MAAM,CAAC;IACd,SAAS,EAAE;QACT,eAAe,EAAE,MAAM,CAAC;QACxB,WAAW,EAAE,MAAM,CAAC;QACpB,YAAY,EAAE,MAAM,CAAC;QACrB,cAAc,EAAE,MAAM,CAAC;KACxB,CAAC;IACF,MAAM,EAAE,gBAAgB,EAAE,CAAC;IAC3B,KAAK,EAAE,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,CAAC;CACpC;AAED,MAAM,WAAW,WAAW;IAC1B,GAAG,EAAE,MAAM,CAAC;IACZ,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,EAAE,OAAO,CAAC;IACrB,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,OAAO,CAAC;IACjB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,UAAU;IACzB,GAAG,EAAE,MAAM,CAAC;IACZ,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,YAAY,CAAC;IACpB,gBAAgB,EAAE,MAAM,GAAG,IAAI,CAAC;IAChC,wBAAwB,EAAE,aAAa,EAAE,CAAC;IAC1C,kBAAkB,EAAE,aAAa,EAAE,CAAC;IACpC,kBAAkB,EAAE,aAAa,EAAE,CAAC;IACpC,wBAAwB,EAAE,cAAc,EAAE,CAAC;IAC3C,kBAAkB,EAAE,cAAc,EAAE,CAAC;IACrC,kBAAkB,EAAE,cAAc,EAAE,CAAC;IACrC,UAAU,EAAE,eAAe,CAAC;IAC5B,eAAe,EAAE,MAAM,EAAE,CAAC;IAC1B,MAAM,EAAE,MAAM,EAAE,CAAC;CAClB"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@slashgear/gdpr-cookie-scanner",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"description": "CLI tool to scan websites for GDPR cookie consent compliance",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"compliance",
|
|
@@ -43,7 +43,8 @@
|
|
|
43
43
|
"@types/node": "^22.0.0",
|
|
44
44
|
"oxfmt": "^0.33.0",
|
|
45
45
|
"oxlint": "^1.48.0",
|
|
46
|
-
"typescript": "^5.5.0"
|
|
46
|
+
"typescript": "^5.5.0",
|
|
47
|
+
"vitest": "^4.0.18"
|
|
47
48
|
},
|
|
48
49
|
"engines": {
|
|
49
50
|
"node": ">=20.0.0"
|
|
@@ -57,6 +58,8 @@
|
|
|
57
58
|
"format": "oxfmt .",
|
|
58
59
|
"format:check": "oxfmt --check .",
|
|
59
60
|
"typecheck": "tsc --noEmit",
|
|
61
|
+
"test": "vitest run",
|
|
62
|
+
"test:watch": "vitest",
|
|
60
63
|
"changeset": "changeset"
|
|
61
64
|
}
|
|
62
65
|
}
|
|
@@ -9,6 +9,7 @@ import { analyzeButtonWording, analyzeModalText } from "./wording.js";
|
|
|
9
9
|
|
|
10
10
|
interface ComplianceInput {
|
|
11
11
|
modal: ConsentModal;
|
|
12
|
+
privacyPolicyUrl: string | null;
|
|
12
13
|
cookiesBeforeInteraction: ScannedCookie[];
|
|
13
14
|
cookiesAfterAccept: ScannedCookie[];
|
|
14
15
|
cookiesAfterReject: ScannedCookie[];
|
|
@@ -125,6 +126,28 @@ export function analyzeCompliance(input: ComplianceInput): ComplianceScore {
|
|
|
125
126
|
if (wordingResult.missingInfo.length > 0) {
|
|
126
127
|
transparency -= wordingResult.missingInfo.length * 3;
|
|
127
128
|
}
|
|
129
|
+
// No privacy policy link in the modal
|
|
130
|
+
if (!input.modal.privacyPolicyUrl) {
|
|
131
|
+
issues.push({
|
|
132
|
+
type: "missing-info",
|
|
133
|
+
severity: "warning",
|
|
134
|
+
description: "No privacy policy link found in the consent modal",
|
|
135
|
+
evidence:
|
|
136
|
+
"GDPR Art. 13 requires the privacy policy to be accessible from the consent interface",
|
|
137
|
+
});
|
|
138
|
+
transparency -= 5;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// No privacy policy link anywhere on the page
|
|
143
|
+
if (!input.privacyPolicyUrl) {
|
|
144
|
+
issues.push({
|
|
145
|
+
type: "missing-info",
|
|
146
|
+
severity: "warning",
|
|
147
|
+
description: "No privacy policy link found on the page",
|
|
148
|
+
evidence: "A privacy policy must be accessible from every page (GDPR Art. 13)",
|
|
149
|
+
});
|
|
150
|
+
transparency -= 3;
|
|
128
151
|
}
|
|
129
152
|
|
|
130
153
|
// ── D. Cookie behavior (0-25) ─────────────────────────────────
|
package/src/report/generator.ts
CHANGED
|
@@ -350,6 +350,9 @@ ${row("Cookie behavior", breakdown.cookieBehavior, 25)}
|
|
|
350
350
|
`**CSS selector:** \`${modal.selector}\``,
|
|
351
351
|
`**Granular controls:** ${modal.hasGranularControls ? "✅ Yes" : "❌ No"}`,
|
|
352
352
|
`**Layer count:** ${modal.layerCount}`,
|
|
353
|
+
modal.privacyPolicyUrl
|
|
354
|
+
? `**Privacy policy link:** ✅ [${modal.privacyPolicyUrl}](${modal.privacyPolicyUrl})`
|
|
355
|
+
: `**Privacy policy link:** ⚠️ Not found in the modal`,
|
|
353
356
|
"",
|
|
354
357
|
"### Detected buttons",
|
|
355
358
|
"",
|
|
@@ -914,6 +917,28 @@ The **Description / Purpose** column is to be filled in by the DPO or technical
|
|
|
914
917
|
});
|
|
915
918
|
}
|
|
916
919
|
|
|
920
|
+
rows.push({
|
|
921
|
+
category: "Transparency",
|
|
922
|
+
rule: "Privacy policy link present in the consent modal",
|
|
923
|
+
reference: "[GDPR Art. 13](https://gdpr-info.eu/art-13-gdpr/)",
|
|
924
|
+
status: !r.modal.detected ? ko : r.modal.privacyPolicyUrl ? ok : warn,
|
|
925
|
+
detail: !r.modal.detected
|
|
926
|
+
? "Modal not detected"
|
|
927
|
+
: r.modal.privacyPolicyUrl
|
|
928
|
+
? `Link found: ${r.modal.privacyPolicyUrl}`
|
|
929
|
+
: "No privacy policy link found inside the consent modal",
|
|
930
|
+
});
|
|
931
|
+
|
|
932
|
+
rows.push({
|
|
933
|
+
category: "Transparency",
|
|
934
|
+
rule: "Privacy policy accessible from the main page",
|
|
935
|
+
reference: "[GDPR Art. 13](https://gdpr-info.eu/art-13-gdpr/)",
|
|
936
|
+
status: r.privacyPolicyUrl ? ok : warn,
|
|
937
|
+
detail: r.privacyPolicyUrl
|
|
938
|
+
? `Link found: ${r.privacyPolicyUrl}`
|
|
939
|
+
: "No privacy policy link found on the main page",
|
|
940
|
+
});
|
|
941
|
+
|
|
917
942
|
// ── D. Cookie behavior ────────────────────────────────────────
|
|
918
943
|
const illegalPre = r.cookiesBeforeInteraction.filter((c) => c.requiresConsent);
|
|
919
944
|
rows.push({
|
|
@@ -45,6 +45,65 @@ const MODAL_SELECTORS = [
|
|
|
45
45
|
"[role='alertdialog']",
|
|
46
46
|
];
|
|
47
47
|
|
|
48
|
+
const PRIVACY_POLICY_URL_PATTERNS = [
|
|
49
|
+
"privacy[_-]?polic",
|
|
50
|
+
"politique[_-]?(de[_-])?confidentialit",
|
|
51
|
+
"politique[_-]?(de[_-])?vie[_-]?priv",
|
|
52
|
+
"confidentialit",
|
|
53
|
+
"vie[_-]?priv",
|
|
54
|
+
"mentions[_-]?l.gales",
|
|
55
|
+
"datenschutz",
|
|
56
|
+
"privacidad",
|
|
57
|
+
"data[_-]?protection",
|
|
58
|
+
"data[_-]?privacy",
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
const PRIVACY_POLICY_TEXT_PATTERNS = [
|
|
62
|
+
"privacy\\s+polic",
|
|
63
|
+
"politique\\s+(de\\s+)?confidentialit",
|
|
64
|
+
"politique\\s+(de\\s+)?vie\\s+priv",
|
|
65
|
+
"confidentialit",
|
|
66
|
+
"vie\\s+priv",
|
|
67
|
+
"mentions?\\s+l.gales?",
|
|
68
|
+
"datenschutz",
|
|
69
|
+
"privacidad",
|
|
70
|
+
"data\\s+protection",
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Find a privacy policy link within a given scope (modal selector) or the full page.
|
|
75
|
+
* Returns the absolute URL of the first matching link, or null.
|
|
76
|
+
*/
|
|
77
|
+
export async function findPrivacyPolicyUrl(
|
|
78
|
+
page: Page,
|
|
79
|
+
scopeSelector?: string,
|
|
80
|
+
): Promise<string | null> {
|
|
81
|
+
return page
|
|
82
|
+
.evaluate(
|
|
83
|
+
({ scope, urlPats, textPats }) => {
|
|
84
|
+
const root: Element | Document = scope
|
|
85
|
+
? (document.querySelector(scope) ?? document)
|
|
86
|
+
: document;
|
|
87
|
+
const links = root.querySelectorAll("a[href]");
|
|
88
|
+
for (const link of links) {
|
|
89
|
+
const href = (link as HTMLAnchorElement).href ?? "";
|
|
90
|
+
const text = (link.textContent ?? "").trim();
|
|
91
|
+
if (!href || href.startsWith("javascript:") || href === "#") continue;
|
|
92
|
+
const matchUrl = urlPats.some((p) => new RegExp(p, "i").test(href));
|
|
93
|
+
const matchText = textPats.some((p) => new RegExp(p, "i").test(text));
|
|
94
|
+
if (matchUrl || matchText) return href;
|
|
95
|
+
}
|
|
96
|
+
return null;
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
scope: scopeSelector ?? null,
|
|
100
|
+
urlPats: PRIVACY_POLICY_URL_PATTERNS,
|
|
101
|
+
textPats: PRIVACY_POLICY_TEXT_PATTERNS,
|
|
102
|
+
},
|
|
103
|
+
)
|
|
104
|
+
.catch(() => null);
|
|
105
|
+
}
|
|
106
|
+
|
|
48
107
|
const ACCEPT_PATTERNS = [
|
|
49
108
|
/\b(accept|accepter|acceptez|tout accepter|accept all|j'accepte|i accept|agree|ok\b|d'accord|continuer|continue|valider|confirmer)\b/i,
|
|
50
109
|
];
|
|
@@ -109,6 +168,7 @@ export async function detectConsentModal(page: Page, options: ScanOptions): Prom
|
|
|
109
168
|
hasGranularControls: false,
|
|
110
169
|
layerCount: 0,
|
|
111
170
|
screenshotPath: null,
|
|
171
|
+
privacyPolicyUrl: null,
|
|
112
172
|
};
|
|
113
173
|
}
|
|
114
174
|
|
|
@@ -125,6 +185,9 @@ export async function detectConsentModal(page: Page, options: ScanOptions): Prom
|
|
|
125
185
|
const hasGranularControls =
|
|
126
186
|
checkboxes.length > 0 || buttons.some((b) => b.type === "preferences");
|
|
127
187
|
|
|
188
|
+
// Look for a privacy policy link inside the modal
|
|
189
|
+
const privacyPolicyUrl = await findPrivacyPolicyUrl(page, foundSelector);
|
|
190
|
+
|
|
128
191
|
return {
|
|
129
192
|
detected: true,
|
|
130
193
|
selector: foundSelector,
|
|
@@ -134,6 +197,7 @@ export async function detectConsentModal(page: Page, options: ScanOptions): Prom
|
|
|
134
197
|
hasGranularControls,
|
|
135
198
|
layerCount: hasGranularControls ? 2 : 1,
|
|
136
199
|
screenshotPath: null,
|
|
200
|
+
privacyPolicyUrl,
|
|
137
201
|
};
|
|
138
202
|
}
|
|
139
203
|
|
package/src/scanner/index.ts
CHANGED
|
@@ -4,7 +4,7 @@ import type { ScanOptions, ScanResult } from "../types.js";
|
|
|
4
4
|
import { createBrowser, clearState, closeBrowser } from "./browser.js";
|
|
5
5
|
import { captureCookies } from "./cookies.js";
|
|
6
6
|
import { createNetworkInterceptor } from "./network.js";
|
|
7
|
-
import { detectConsentModal } from "./consent-modal.js";
|
|
7
|
+
import { detectConsentModal, findPrivacyPolicyUrl } from "./consent-modal.js";
|
|
8
8
|
import { analyzeCompliance } from "../analyzers/compliance.js";
|
|
9
9
|
|
|
10
10
|
type PhaseCallback = (message: string) => void;
|
|
@@ -44,6 +44,9 @@ export class Scanner {
|
|
|
44
44
|
const networkBeforeInteraction = interceptor1.getRequests();
|
|
45
45
|
interceptor1.stop();
|
|
46
46
|
|
|
47
|
+
// Look for a privacy policy link anywhere on the page (typically footer/nav)
|
|
48
|
+
const privacyPolicyUrl = await findPrivacyPolicyUrl(session1.page);
|
|
49
|
+
|
|
47
50
|
// ────────────────────────────────────────────────────────────
|
|
48
51
|
// Phase 2 — Detect and analyze the consent modal
|
|
49
52
|
// ────────────────────────────────────────────────────────────
|
|
@@ -142,6 +145,7 @@ export class Scanner {
|
|
|
142
145
|
// ────────────────────────────────────────────────────────────
|
|
143
146
|
const compliance = analyzeCompliance({
|
|
144
147
|
modal,
|
|
148
|
+
privacyPolicyUrl,
|
|
145
149
|
cookiesBeforeInteraction,
|
|
146
150
|
cookiesAfterAccept,
|
|
147
151
|
cookiesAfterReject,
|
|
@@ -155,6 +159,7 @@ export class Scanner {
|
|
|
155
159
|
scanDate: new Date().toISOString(),
|
|
156
160
|
duration: Date.now() - startTime,
|
|
157
161
|
modal,
|
|
162
|
+
privacyPolicyUrl,
|
|
158
163
|
cookiesBeforeInteraction,
|
|
159
164
|
cookiesAfterAccept,
|
|
160
165
|
cookiesAfterReject,
|
package/src/types.ts
CHANGED
|
@@ -74,6 +74,7 @@ export interface ConsentModal {
|
|
|
74
74
|
hasGranularControls: boolean;
|
|
75
75
|
layerCount: number; // number of clicks to reach full options
|
|
76
76
|
screenshotPath: string | null;
|
|
77
|
+
privacyPolicyUrl: string | null; // link to the privacy policy found inside the modal
|
|
77
78
|
}
|
|
78
79
|
|
|
79
80
|
export interface DarkPatternIssue {
|
|
@@ -122,6 +123,7 @@ export interface ScanResult {
|
|
|
122
123
|
scanDate: string;
|
|
123
124
|
duration: number; // ms
|
|
124
125
|
modal: ConsentModal;
|
|
126
|
+
privacyPolicyUrl: string | null; // link to the privacy policy found anywhere on the page
|
|
125
127
|
cookiesBeforeInteraction: ScannedCookie[];
|
|
126
128
|
cookiesAfterAccept: ScannedCookie[];
|
|
127
129
|
cookiesAfterReject: ScannedCookie[];
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<title>Compliant Test Site</title>
|
|
6
|
+
</head>
|
|
7
|
+
<body>
|
|
8
|
+
<main>
|
|
9
|
+
<h1>Welcome to our website</h1>
|
|
10
|
+
<p>This is a test page with a GDPR-compliant cookie banner.</p>
|
|
11
|
+
</main>
|
|
12
|
+
|
|
13
|
+
<footer>
|
|
14
|
+
<a href="/privacy-policy">Privacy Policy</a>
|
|
15
|
+
<a href="/terms">Terms of Service</a>
|
|
16
|
+
</footer>
|
|
17
|
+
|
|
18
|
+
<!-- GDPR-compliant cookie banner -->
|
|
19
|
+
<div
|
|
20
|
+
id="cookie-banner"
|
|
21
|
+
style="
|
|
22
|
+
position: fixed;
|
|
23
|
+
bottom: 0;
|
|
24
|
+
left: 0;
|
|
25
|
+
right: 0;
|
|
26
|
+
background: #1a1a2e;
|
|
27
|
+
color: #fff;
|
|
28
|
+
padding: 20px;
|
|
29
|
+
z-index: 9999;
|
|
30
|
+
display: flex;
|
|
31
|
+
align-items: center;
|
|
32
|
+
gap: 16px;
|
|
33
|
+
"
|
|
34
|
+
>
|
|
35
|
+
<p style="flex: 1; margin: 0">
|
|
36
|
+
We use cookies for analytics purposes with <strong>third-party vendors</strong>. Cookies
|
|
37
|
+
expire after <strong>13 months</strong>. You may <strong>withdraw your consent</strong> at
|
|
38
|
+
any time. See our <a href="/privacy-policy" style="color: #90e0ef">Privacy Policy</a>.
|
|
39
|
+
</p>
|
|
40
|
+
<button
|
|
41
|
+
id="reject-btn"
|
|
42
|
+
style="
|
|
43
|
+
padding: 10px 20px;
|
|
44
|
+
background: transparent;
|
|
45
|
+
color: #fff;
|
|
46
|
+
border: 1px solid #fff;
|
|
47
|
+
cursor: pointer;
|
|
48
|
+
font-size: 14px;
|
|
49
|
+
"
|
|
50
|
+
>
|
|
51
|
+
Reject all
|
|
52
|
+
</button>
|
|
53
|
+
<button
|
|
54
|
+
id="accept-btn"
|
|
55
|
+
style="
|
|
56
|
+
padding: 10px 20px;
|
|
57
|
+
background: #4caf50;
|
|
58
|
+
color: #fff;
|
|
59
|
+
border: none;
|
|
60
|
+
cursor: pointer;
|
|
61
|
+
font-size: 14px;
|
|
62
|
+
"
|
|
63
|
+
>
|
|
64
|
+
Accept all
|
|
65
|
+
</button>
|
|
66
|
+
</div>
|
|
67
|
+
|
|
68
|
+
<script>
|
|
69
|
+
document.getElementById("accept-btn").addEventListener("click", function () {
|
|
70
|
+
document.getElementById("cookie-banner").style.display = "none";
|
|
71
|
+
document.cookie = "consent=accepted; path=/; max-age=31536000";
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
document.getElementById("reject-btn").addEventListener("click", function () {
|
|
75
|
+
document.getElementById("cookie-banner").style.display = "none";
|
|
76
|
+
document.cookie = "consent=rejected; path=/; max-age=31536000";
|
|
77
|
+
});
|
|
78
|
+
</script>
|
|
79
|
+
</body>
|
|
80
|
+
</html>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<title>No Modal Test Site</title>
|
|
6
|
+
</head>
|
|
7
|
+
<body>
|
|
8
|
+
<main>
|
|
9
|
+
<h1>Welcome</h1>
|
|
10
|
+
<p>This page has no cookie consent modal at all.</p>
|
|
11
|
+
</main>
|
|
12
|
+
|
|
13
|
+
<footer>
|
|
14
|
+
<a href="/privacy-policy">Privacy Policy</a>
|
|
15
|
+
</footer>
|
|
16
|
+
</body>
|
|
17
|
+
</html>
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<title>Non-Compliant Test Site</title>
|
|
6
|
+
</head>
|
|
7
|
+
<body>
|
|
8
|
+
<main>
|
|
9
|
+
<h1>Welcome</h1>
|
|
10
|
+
<p>This page has no reject button and sets analytics cookies immediately.</p>
|
|
11
|
+
</main>
|
|
12
|
+
|
|
13
|
+
<!-- Non-compliant: no reject button, no privacy policy link, cookie set before interaction -->
|
|
14
|
+
<div
|
|
15
|
+
id="cookie-banner"
|
|
16
|
+
style="
|
|
17
|
+
position: fixed;
|
|
18
|
+
bottom: 0;
|
|
19
|
+
left: 0;
|
|
20
|
+
right: 0;
|
|
21
|
+
background: #333;
|
|
22
|
+
color: #fff;
|
|
23
|
+
padding: 20px;
|
|
24
|
+
z-index: 9999;
|
|
25
|
+
"
|
|
26
|
+
>
|
|
27
|
+
<p style="margin: 0 0 10px 0">
|
|
28
|
+
By continuing to browse this site, you accept the use of cookies.
|
|
29
|
+
</p>
|
|
30
|
+
<button
|
|
31
|
+
id="accept-btn"
|
|
32
|
+
style="
|
|
33
|
+
padding: 10px 24px;
|
|
34
|
+
background: #e53935;
|
|
35
|
+
color: #fff;
|
|
36
|
+
border: none;
|
|
37
|
+
cursor: pointer;
|
|
38
|
+
font-size: 16px;
|
|
39
|
+
"
|
|
40
|
+
>
|
|
41
|
+
OK
|
|
42
|
+
</button>
|
|
43
|
+
</div>
|
|
44
|
+
|
|
45
|
+
<script>
|
|
46
|
+
// Set analytics cookie before any interaction (non-compliant)
|
|
47
|
+
document.cookie = "_ga=GA1.2.123456789.1234567890; path=/; max-age=34560000";
|
|
48
|
+
|
|
49
|
+
document.getElementById("accept-btn").addEventListener("click", function () {
|
|
50
|
+
document.getElementById("cookie-banner").style.display = "none";
|
|
51
|
+
});
|
|
52
|
+
</script>
|
|
53
|
+
</body>
|
|
54
|
+
</html>
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
|
2
|
+
import { mkdtemp, rm } from "fs/promises";
|
|
3
|
+
import { tmpdir } from "os";
|
|
4
|
+
import { join } from "path";
|
|
5
|
+
import { Scanner } from "../../src/scanner/index.js";
|
|
6
|
+
import { startTestServer, type TestServer } from "../helpers/test-server.js";
|
|
7
|
+
|
|
8
|
+
// E2E tests spin up real Playwright browsers — allow generous timeouts
|
|
9
|
+
const E2E_TIMEOUT = 60_000;
|
|
10
|
+
|
|
11
|
+
describe("Scanner E2E", { timeout: E2E_TIMEOUT }, () => {
|
|
12
|
+
let server: TestServer;
|
|
13
|
+
let outputDir: string;
|
|
14
|
+
|
|
15
|
+
beforeAll(async () => {
|
|
16
|
+
server = await startTestServer();
|
|
17
|
+
outputDir = await mkdtemp(join(tmpdir(), "gdpr-test-"));
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
afterAll(async () => {
|
|
21
|
+
await server.close();
|
|
22
|
+
await rm(outputDir, { recursive: true, force: true });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
function makeOptions(fixture: string) {
|
|
26
|
+
return {
|
|
27
|
+
url: `${server.url}/${fixture}`,
|
|
28
|
+
outputDir: join(outputDir, fixture.replace(".html", "")),
|
|
29
|
+
timeout: 30_000,
|
|
30
|
+
screenshots: false,
|
|
31
|
+
locale: "en-US",
|
|
32
|
+
verbose: false,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe("compliant-site.html", () => {
|
|
37
|
+
it("detects the consent modal", async () => {
|
|
38
|
+
const scanner = new Scanner(makeOptions("compliant-site.html"));
|
|
39
|
+
const result = await scanner.run();
|
|
40
|
+
expect(result.modal.detected).toBe(true);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("finds both accept and reject buttons", async () => {
|
|
44
|
+
const scanner = new Scanner(makeOptions("compliant-site.html"));
|
|
45
|
+
const result = await scanner.run();
|
|
46
|
+
const types = result.modal.buttons.map((b) => b.type);
|
|
47
|
+
expect(types).toContain("accept");
|
|
48
|
+
expect(types).toContain("reject");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("finds the privacy policy link in the modal", async () => {
|
|
52
|
+
const scanner = new Scanner(makeOptions("compliant-site.html"));
|
|
53
|
+
const result = await scanner.run();
|
|
54
|
+
expect(result.modal.privacyPolicyUrl).toBeTruthy();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("finds a privacy policy link on the page", async () => {
|
|
58
|
+
const scanner = new Scanner(makeOptions("compliant-site.html"));
|
|
59
|
+
const result = await scanner.run();
|
|
60
|
+
expect(result.privacyPolicyUrl).toBeTruthy();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("assigns a passing compliance grade (A or B)", async () => {
|
|
64
|
+
const scanner = new Scanner(makeOptions("compliant-site.html"));
|
|
65
|
+
const result = await scanner.run();
|
|
66
|
+
expect(["A", "B"]).toContain(result.compliance.grade);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("does not flag pre-consent cookies", async () => {
|
|
70
|
+
const scanner = new Scanner(makeOptions("compliant-site.html"));
|
|
71
|
+
const result = await scanner.run();
|
|
72
|
+
const illegalPreConsent = result.cookiesBeforeInteraction.filter((c) => c.requiresConsent);
|
|
73
|
+
expect(illegalPreConsent).toHaveLength(0);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe("non-compliant-site.html", () => {
|
|
78
|
+
it("detects the consent modal", async () => {
|
|
79
|
+
const scanner = new Scanner(makeOptions("non-compliant-site.html"));
|
|
80
|
+
const result = await scanner.run();
|
|
81
|
+
expect(result.modal.detected).toBe(true);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("detects _ga cookie set before any interaction", async () => {
|
|
85
|
+
const scanner = new Scanner(makeOptions("non-compliant-site.html"));
|
|
86
|
+
const result = await scanner.run();
|
|
87
|
+
const gaBeforeInteraction = result.cookiesBeforeInteraction.some(
|
|
88
|
+
(c) => c.name === "_ga" && c.requiresConsent,
|
|
89
|
+
);
|
|
90
|
+
expect(gaBeforeInteraction).toBe(true);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("raises an auto-consent issue for pre-interaction cookies", async () => {
|
|
94
|
+
const scanner = new Scanner(makeOptions("non-compliant-site.html"));
|
|
95
|
+
const result = await scanner.run();
|
|
96
|
+
const issue = result.compliance.issues.find((i) => i.type === "auto-consent");
|
|
97
|
+
expect(issue).toBeDefined();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("assigns a failing grade (C, D, or F)", async () => {
|
|
101
|
+
const scanner = new Scanner(makeOptions("non-compliant-site.html"));
|
|
102
|
+
const result = await scanner.run();
|
|
103
|
+
expect(["C", "D", "F"]).toContain(result.compliance.grade);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe("no-modal-site.html", () => {
|
|
108
|
+
it("reports modal as not detected", async () => {
|
|
109
|
+
const scanner = new Scanner(makeOptions("no-modal-site.html"));
|
|
110
|
+
const result = await scanner.run();
|
|
111
|
+
expect(result.modal.detected).toBe(false);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("still finds the page-level privacy policy link", async () => {
|
|
115
|
+
const scanner = new Scanner(makeOptions("no-modal-site.html"));
|
|
116
|
+
const result = await scanner.run();
|
|
117
|
+
expect(result.privacyPolicyUrl).toBeTruthy();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("assigns grade F when no modal is detected", async () => {
|
|
121
|
+
const scanner = new Scanner(makeOptions("no-modal-site.html"));
|
|
122
|
+
const result = await scanner.run();
|
|
123
|
+
expect(result.compliance.grade).toBe("F");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("raises a critical no-reject-button issue", async () => {
|
|
127
|
+
const scanner = new Scanner(makeOptions("no-modal-site.html"));
|
|
128
|
+
const result = await scanner.run();
|
|
129
|
+
const issue = result.compliance.issues.find(
|
|
130
|
+
(i) => i.type === "no-reject-button" && i.severity === "critical",
|
|
131
|
+
);
|
|
132
|
+
expect(issue).toBeDefined();
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { createServer } from "http";
|
|
2
|
+
import { readFile } from "fs/promises";
|
|
3
|
+
import { join, extname } from "path";
|
|
4
|
+
import { fileURLToPath } from "url";
|
|
5
|
+
|
|
6
|
+
const __dirname = fileURLToPath(new URL(".", import.meta.url));
|
|
7
|
+
const FIXTURES_DIR = join(__dirname, "../e2e/fixtures");
|
|
8
|
+
|
|
9
|
+
const MIME_TYPES: Record<string, string> = {
|
|
10
|
+
".html": "text/html; charset=utf-8",
|
|
11
|
+
".js": "application/javascript",
|
|
12
|
+
".css": "text/css",
|
|
13
|
+
".json": "application/json",
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export interface TestServer {
|
|
17
|
+
url: string;
|
|
18
|
+
close(): Promise<void>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Starts a minimal HTTP server serving static HTML fixtures.
|
|
23
|
+
* Returns the base URL and a close() function.
|
|
24
|
+
*/
|
|
25
|
+
export async function startTestServer(port = 0): Promise<TestServer> {
|
|
26
|
+
const server = createServer(async (req, res) => {
|
|
27
|
+
const urlPath = req.url === "/" ? "/index.html" : (req.url ?? "/");
|
|
28
|
+
// Map /privacy-policy → privacy-policy.html or return a stub
|
|
29
|
+
const filePath = urlPath.endsWith(".html")
|
|
30
|
+
? join(FIXTURES_DIR, urlPath)
|
|
31
|
+
: join(FIXTURES_DIR, `${urlPath.slice(1)}.html`);
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
const content = await readFile(filePath);
|
|
35
|
+
const ext = extname(filePath) || ".html";
|
|
36
|
+
res.writeHead(200, { "Content-Type": MIME_TYPES[ext] ?? "text/plain" });
|
|
37
|
+
res.end(content);
|
|
38
|
+
} catch {
|
|
39
|
+
// Return a minimal stub for unknown paths (e.g., /privacy-policy)
|
|
40
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
41
|
+
res.end("<!DOCTYPE html><html><body><h1>Privacy Policy</h1></body></html>");
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
await new Promise<void>((resolve) => server.listen(port, "127.0.0.1", resolve));
|
|
46
|
+
|
|
47
|
+
const address = server.address();
|
|
48
|
+
if (!address || typeof address === "string") {
|
|
49
|
+
throw new Error("Unexpected server address type");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
url: `http://127.0.0.1:${address.port}`,
|
|
54
|
+
close: () =>
|
|
55
|
+
new Promise((resolve, reject) => server.close((err) => (err ? reject(err) : resolve()))),
|
|
56
|
+
};
|
|
57
|
+
}
|