@slashgear/gdpr-cookie-scanner 1.3.0 → 2.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/.dockerignore +13 -0
- package/.github/workflows/ci.yml +8 -2
- package/.github/workflows/docker.yml +49 -0
- package/.github/workflows/pages.yml +40 -0
- package/.github/workflows/release.yml +1 -1
- package/.nvmrc +1 -0
- package/CHANGELOG.md +87 -0
- package/Dockerfile +36 -0
- package/README.md +44 -63
- package/dist/cli.js +21 -3
- package/dist/cli.js.map +1 -1
- package/dist/report/generator.d.ts +1 -4
- package/dist/report/generator.d.ts.map +1 -1
- package/dist/report/generator.js +45 -23
- package/dist/report/generator.js.map +1 -1
- package/dist/report/html.d.ts +3 -0
- package/dist/report/html.d.ts.map +1 -0
- package/dist/report/html.js +766 -0
- package/dist/report/html.js.map +1 -0
- package/dist/types.d.ts +2 -0
- package/dist/types.d.ts.map +1 -1
- package/docs/index.html +314 -0
- 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 +44 -0
- package/docs/reports/github.com/gdpr-cookies-github.com-2026-02-22.md +29 -0
- package/docs/reports/github.com/gdpr-report-github.com-2026-02-22.md +102 -0
- 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 +44 -0
- package/docs/reports/gitlab.com/gdpr-cookies-gitlab.com-2026-02-22.md +55 -0
- package/docs/reports/gitlab.com/gdpr-report-gitlab.com-2026-02-22.md +200 -0
- 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 +44 -0
- package/docs/reports/npmjs.com/gdpr-cookies-npmjs.com-2026-02-22.md +25 -0
- package/docs/reports/npmjs.com/gdpr-report-npmjs.com-2026-02-22.md +88 -0
- 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 +44 -0
- package/docs/reports/reddit.com/gdpr-cookies-reddit.com-2026-02-22.md +33 -0
- package/docs/reports/reddit.com/gdpr-report-reddit.com-2026-02-22.md +148 -0
- 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 +44 -0
- package/docs/reports/stackoverflow.com/gdpr-cookies-stackoverflow.com-2026-02-22.md +67 -0
- package/docs/reports/stackoverflow.com/gdpr-report-stackoverflow.com-2026-02-22.md +206 -0
- 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 +44 -0
- package/docs/reports/www.afp.com/gdpr-cookies-afp.com-2026-02-22.md +42 -0
- package/docs/reports/www.afp.com/gdpr-report-afp.com-2026-02-22.md +202 -0
- 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/style.css +439 -0
- package/package.json +10 -7
- package/src/cli.ts +28 -4
- package/src/report/generator.ts +54 -29
- package/src/report/html.ts +940 -0
- package/src/types.ts +3 -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/src/types.ts
CHANGED
|
@@ -108,6 +108,8 @@ export interface ComplianceScore {
|
|
|
108
108
|
grade: "A" | "B" | "C" | "D" | "F";
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
+
export type ReportFormat = "md" | "html" | "json" | "pdf";
|
|
112
|
+
|
|
111
113
|
export interface ScanOptions {
|
|
112
114
|
url: string;
|
|
113
115
|
outputDir: string;
|
|
@@ -115,6 +117,7 @@ export interface ScanOptions {
|
|
|
115
117
|
screenshots: boolean;
|
|
116
118
|
locale: string;
|
|
117
119
|
verbose: boolean;
|
|
120
|
+
formats: ReportFormat[];
|
|
118
121
|
userAgent?: string;
|
|
119
122
|
}
|
|
120
123
|
|
|
@@ -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
|
+
}
|