@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.
Files changed (78) hide show
  1. package/.dockerignore +13 -0
  2. package/.github/workflows/ci.yml +8 -2
  3. package/.github/workflows/docker.yml +49 -0
  4. package/.github/workflows/pages.yml +40 -0
  5. package/.github/workflows/release.yml +1 -1
  6. package/.nvmrc +1 -0
  7. package/CHANGELOG.md +87 -0
  8. package/Dockerfile +36 -0
  9. package/README.md +44 -63
  10. package/dist/cli.js +21 -3
  11. package/dist/cli.js.map +1 -1
  12. package/dist/report/generator.d.ts +1 -4
  13. package/dist/report/generator.d.ts.map +1 -1
  14. package/dist/report/generator.js +45 -23
  15. package/dist/report/generator.js.map +1 -1
  16. package/dist/report/html.d.ts +3 -0
  17. package/dist/report/html.d.ts.map +1 -0
  18. package/dist/report/html.js +766 -0
  19. package/dist/report/html.js.map +1 -0
  20. package/dist/types.d.ts +2 -0
  21. package/dist/types.d.ts.map +1 -1
  22. package/docs/index.html +314 -0
  23. package/docs/reports/github.com/after-accept.png +0 -0
  24. package/docs/reports/github.com/after-reject.png +0 -0
  25. package/docs/reports/github.com/gdpr-checklist-github.com-2026-02-22.md +44 -0
  26. package/docs/reports/github.com/gdpr-cookies-github.com-2026-02-22.md +29 -0
  27. package/docs/reports/github.com/gdpr-report-github.com-2026-02-22.md +102 -0
  28. package/docs/reports/github.com/gdpr-report-github.com-2026-02-22.pdf +0 -0
  29. package/docs/reports/gitlab.com/after-accept.png +0 -0
  30. package/docs/reports/gitlab.com/after-reject.png +0 -0
  31. package/docs/reports/gitlab.com/gdpr-checklist-gitlab.com-2026-02-22.md +44 -0
  32. package/docs/reports/gitlab.com/gdpr-cookies-gitlab.com-2026-02-22.md +55 -0
  33. package/docs/reports/gitlab.com/gdpr-report-gitlab.com-2026-02-22.md +200 -0
  34. package/docs/reports/gitlab.com/gdpr-report-gitlab.com-2026-02-22.pdf +0 -0
  35. package/docs/reports/gitlab.com/modal-initial.png +0 -0
  36. package/docs/reports/npmjs.com/after-accept.png +0 -0
  37. package/docs/reports/npmjs.com/after-reject.png +0 -0
  38. package/docs/reports/npmjs.com/gdpr-checklist-npmjs.com-2026-02-22.md +44 -0
  39. package/docs/reports/npmjs.com/gdpr-cookies-npmjs.com-2026-02-22.md +25 -0
  40. package/docs/reports/npmjs.com/gdpr-report-npmjs.com-2026-02-22.md +88 -0
  41. package/docs/reports/npmjs.com/gdpr-report-npmjs.com-2026-02-22.pdf +0 -0
  42. package/docs/reports/reddit.com/after-accept.png +0 -0
  43. package/docs/reports/reddit.com/after-reject.png +0 -0
  44. package/docs/reports/reddit.com/gdpr-checklist-reddit.com-2026-02-22.md +44 -0
  45. package/docs/reports/reddit.com/gdpr-cookies-reddit.com-2026-02-22.md +33 -0
  46. package/docs/reports/reddit.com/gdpr-report-reddit.com-2026-02-22.md +148 -0
  47. package/docs/reports/reddit.com/gdpr-report-reddit.com-2026-02-22.pdf +0 -0
  48. package/docs/reports/reddit.com/modal-initial.png +0 -0
  49. package/docs/reports/stackoverflow.com/after-accept.png +0 -0
  50. package/docs/reports/stackoverflow.com/after-reject.png +0 -0
  51. package/docs/reports/stackoverflow.com/gdpr-checklist-stackoverflow.com-2026-02-22.md +44 -0
  52. package/docs/reports/stackoverflow.com/gdpr-cookies-stackoverflow.com-2026-02-22.md +67 -0
  53. package/docs/reports/stackoverflow.com/gdpr-report-stackoverflow.com-2026-02-22.md +206 -0
  54. package/docs/reports/stackoverflow.com/gdpr-report-stackoverflow.com-2026-02-22.pdf +0 -0
  55. package/docs/reports/stackoverflow.com/modal-initial.png +0 -0
  56. package/docs/reports/www.afp.com/after-accept.png +0 -0
  57. package/docs/reports/www.afp.com/after-reject.png +0 -0
  58. package/docs/reports/www.afp.com/gdpr-checklist-afp.com-2026-02-22.md +44 -0
  59. package/docs/reports/www.afp.com/gdpr-cookies-afp.com-2026-02-22.md +42 -0
  60. package/docs/reports/www.afp.com/gdpr-report-afp.com-2026-02-22.md +202 -0
  61. package/docs/reports/www.afp.com/gdpr-report-afp.com-2026-02-22.pdf +0 -0
  62. package/docs/reports/www.afp.com/modal-initial.png +0 -0
  63. package/docs/style.css +439 -0
  64. package/package.json +10 -7
  65. package/src/cli.ts +28 -4
  66. package/src/report/generator.ts +54 -29
  67. package/src/report/html.ts +940 -0
  68. package/src/types.ts +3 -0
  69. package/tests/e2e/fixtures/compliant-site.html +80 -0
  70. package/tests/e2e/fixtures/no-modal-site.html +17 -0
  71. package/tests/e2e/fixtures/non-compliant-site.html +54 -0
  72. package/tests/e2e/scanner.test.ts +135 -0
  73. package/tests/helpers/test-server.ts +57 -0
  74. package/tests/unit/compliance.test.ts +460 -0
  75. package/tests/unit/cookie-classifier.test.ts +192 -0
  76. package/tests/unit/network-classifier.test.ts +91 -0
  77. package/tests/unit/wording.test.ts +162 -0
  78. package/vitest.config.ts +9 -0
@@ -0,0 +1,91 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { classifyNetworkRequest } from "../../src/classifiers/network-classifier.js";
3
+
4
+ describe("classifyNetworkRequest", () => {
5
+ describe("known trackers in TRACKER_DB", () => {
6
+ it("identifies Google Analytics as analytics tracker", () => {
7
+ const result = classifyNetworkRequest(
8
+ "https://www.google-analytics.com/j/collect?v=1&t=event",
9
+ "xhr",
10
+ );
11
+ expect(result.isThirdParty).toBe(true);
12
+ expect(result.trackerCategory).toBe("analytics");
13
+ expect(result.trackerName).toMatch(/google analytics/i);
14
+ });
15
+
16
+ it("identifies a subdomain of a known tracker domain", () => {
17
+ const result = classifyNetworkRequest("https://stats.google-analytics.com/collect", "xhr");
18
+ expect(result.isThirdParty).toBe(true);
19
+ expect(result.trackerCategory).toBe("analytics");
20
+ });
21
+
22
+ it("identifies Facebook SDK (connect.facebook.net) as social", () => {
23
+ const result = classifyNetworkRequest(
24
+ "https://connect.facebook.net/en_US/fbevents.js",
25
+ "script",
26
+ );
27
+ expect(result.isThirdParty).toBe(true);
28
+ expect(result.trackerCategory).toBe("social");
29
+ });
30
+
31
+ it("identifies DoubleClick as advertising", () => {
32
+ const result = classifyNetworkRequest("https://ad.doubleclick.net/ddm/trackimp", "image");
33
+ expect(result.isThirdParty).toBe(true);
34
+ expect(result.trackerCategory).toBe("advertising");
35
+ });
36
+
37
+ it("identifies Google Tag Manager as analytics", () => {
38
+ const result = classifyNetworkRequest(
39
+ "https://www.googletagmanager.com/gtm.js?id=GTM-XXXX",
40
+ "script",
41
+ );
42
+ expect(result.isThirdParty).toBe(true);
43
+ expect(result.trackerCategory).toBe("analytics");
44
+ });
45
+ });
46
+
47
+ describe("pixel/beacon patterns", () => {
48
+ it("identifies tracking pixel via URL pattern", () => {
49
+ const result = classifyNetworkRequest(
50
+ "https://example.com/pixel.gif?uid=123&ts=1234567890",
51
+ "image",
52
+ );
53
+ expect(result.isThirdParty).toBe(true);
54
+ expect(result.trackerCategory).toBe("pixel");
55
+ });
56
+ });
57
+
58
+ describe("non-tracker requests", () => {
59
+ it("does not flag first-party requests", () => {
60
+ const result = classifyNetworkRequest("https://example.com/api/data", "xhr");
61
+ expect(result.isThirdParty).toBe(false);
62
+ expect(result.trackerCategory).toBeNull();
63
+ expect(result.trackerName).toBeNull();
64
+ });
65
+
66
+ it("does not flag CDN requests for known CDN domains", () => {
67
+ const result = classifyNetworkRequest("https://cdn.cloudflare.com/some-asset.js", "script");
68
+ // cloudflare may or may not be in tracker DB — check it doesn't come back as advertising/analytics
69
+ if (result.isThirdParty) {
70
+ expect(result.trackerCategory).toBe("cdn");
71
+ } else {
72
+ expect(result.isThirdParty).toBe(false);
73
+ }
74
+ });
75
+
76
+ it("returns safe defaults for an invalid URL", () => {
77
+ const result = classifyNetworkRequest("not-a-url", "xhr");
78
+ expect(result.isThirdParty).toBe(false);
79
+ expect(result.trackerCategory).toBeNull();
80
+ expect(result.trackerName).toBeNull();
81
+ });
82
+ });
83
+
84
+ describe("www prefix stripping", () => {
85
+ it("strips www from hostname before matching", () => {
86
+ const result = classifyNetworkRequest("https://www.google-analytics.com/collect", "xhr");
87
+ expect(result.isThirdParty).toBe(true);
88
+ expect(result.trackerCategory).toBe("analytics");
89
+ });
90
+ });
91
+ });
@@ -0,0 +1,162 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { analyzeButtonWording, analyzeModalText } from "../../src/analyzers/wording.js";
3
+ import type { ConsentButton } from "../../src/types.js";
4
+
5
+ function makeButton(
6
+ type: ConsentButton["type"],
7
+ text: string,
8
+ overrides?: Partial<ConsentButton>,
9
+ ): ConsentButton {
10
+ return {
11
+ type,
12
+ text,
13
+ selector: `button:has-text("${text}")`,
14
+ isVisible: true,
15
+ boundingBox: { x: 0, y: 0, width: 120, height: 40 },
16
+ fontSize: 14,
17
+ backgroundColor: "rgb(255, 255, 255)",
18
+ textColor: "rgb(0, 0, 0)",
19
+ contrastRatio: 21,
20
+ clickDepth: 1,
21
+ ...overrides,
22
+ };
23
+ }
24
+
25
+ describe("analyzeButtonWording", () => {
26
+ it("returns no issues for standard accept/reject buttons", () => {
27
+ const buttons = [makeButton("accept", "Accept all"), makeButton("reject", "Reject all")];
28
+ const result = analyzeButtonWording(buttons);
29
+ expect(result.issues).toHaveLength(0);
30
+ expect(result.hasPositiveActionForAccept).toBe(true);
31
+ expect(result.hasExplicitRejectOption).toBe(true);
32
+ });
33
+
34
+ it("flags missing reject button when no reject or preferences button exists", () => {
35
+ const buttons = [makeButton("accept", "Accept")];
36
+ const result = analyzeButtonWording(buttons);
37
+ const noRejectIssue = result.issues.find((i) => i.type === "no-reject-button");
38
+ expect(noRejectIssue).toBeDefined();
39
+ expect(noRejectIssue?.severity).toBe("critical");
40
+ expect(result.hasExplicitRejectOption).toBe(false);
41
+ });
42
+
43
+ it("does NOT flag missing reject when a preferences button exists", () => {
44
+ const buttons = [makeButton("accept", "Accept"), makeButton("preferences", "Manage")];
45
+ const result = analyzeButtonWording(buttons);
46
+ const noRejectIssue = result.issues.find((i) => i.type === "no-reject-button");
47
+ expect(noRejectIssue).toBeUndefined();
48
+ });
49
+
50
+ it("flags ambiguous accept label 'ok'", () => {
51
+ const buttons = [makeButton("accept", "ok"), makeButton("reject", "Refuser")];
52
+ const result = analyzeButtonWording(buttons);
53
+ const misleadingIssue = result.issues.find(
54
+ (i) => i.type === "misleading-wording" && i.description.includes("ok"),
55
+ );
56
+ expect(misleadingIssue).toBeDefined();
57
+ expect(misleadingIssue?.severity).toBe("warning");
58
+ });
59
+
60
+ it("flags ambiguous accept label 'continuer'", () => {
61
+ const buttons = [makeButton("accept", "Continuer"), makeButton("reject", "Refuser")];
62
+ const result = analyzeButtonWording(buttons);
63
+ const misleadingIssue = result.issues.find((i) => i.type === "misleading-wording");
64
+ expect(misleadingIssue).toBeDefined();
65
+ });
66
+
67
+ it("flags fake reject label '×'", () => {
68
+ const buttons = [makeButton("accept", "Accepter"), makeButton("reject", "×")];
69
+ const result = analyzeButtonWording(buttons);
70
+ const misleadingIssue = result.issues.find(
71
+ (i) => i.type === "misleading-wording" && i.severity === "critical",
72
+ );
73
+ expect(misleadingIssue).toBeDefined();
74
+ });
75
+
76
+ it("flags fake reject label 'Fermer'", () => {
77
+ const buttons = [makeButton("accept", "Accepter"), makeButton("reject", "Fermer")];
78
+ const result = analyzeButtonWording(buttons);
79
+ const misleadingIssue = result.issues.find(
80
+ (i) => i.type === "misleading-wording" && i.severity === "critical",
81
+ );
82
+ expect(misleadingIssue).toBeDefined();
83
+ });
84
+
85
+ it("returns empty issues for empty button array", () => {
86
+ const result = analyzeButtonWording([]);
87
+ // No accept button → no misleading accept issue
88
+ // No reject button → no-reject-button issue raised (no prefButton either)
89
+ const noRejectIssue = result.issues.find((i) => i.type === "no-reject-button");
90
+ expect(noRejectIssue).toBeDefined();
91
+ expect(result.hasPositiveActionForAccept).toBe(false);
92
+ expect(result.hasExplicitRejectOption).toBe(false);
93
+ });
94
+ });
95
+
96
+ describe("analyzeModalText", () => {
97
+ it("detects all missing info in an empty text", () => {
98
+ const result = analyzeModalText("");
99
+ expect(result.missingInfo).toContain("purposes");
100
+ expect(result.missingInfo).toContain("third-parties");
101
+ expect(result.missingInfo).toContain("duration");
102
+ expect(result.missingInfo).toContain("withdrawal");
103
+ expect(result.issues).toHaveLength(4);
104
+ });
105
+
106
+ it("returns no missing info for a complete consent text", () => {
107
+ // Use words that precisely match each regex pattern in REQUIRED_INFO_PATTERNS
108
+ const text = [
109
+ "We use cookies for analytics purposes with third-party vendors.",
110
+ "Cookies expire after 13 months.",
111
+ "You may withdraw your consent at any time.",
112
+ ].join(" ");
113
+ const result = analyzeModalText(text);
114
+ expect(result.missingInfo).toHaveLength(0);
115
+ expect(result.issues).toHaveLength(0);
116
+ });
117
+
118
+ it("detects missing 'duration' when text lacks duration keywords", () => {
119
+ // Carefully avoid any "an" substring (matches the `an(s)?` duration pattern)
120
+ // and any month/year/period/expir/durée/conservation/validité keywords
121
+ const text =
122
+ "We use cookies for purposes of tracking. Third-party vendors collect this. Possible to revoke consent.";
123
+ const result = analyzeModalText(text);
124
+ expect(result.missingInfo).toContain("duration");
125
+ expect(result.missingInfo).not.toContain("purposes");
126
+ expect(result.missingInfo).not.toContain("third-parties");
127
+ expect(result.missingInfo).not.toContain("withdrawal");
128
+ });
129
+
130
+ it("matches French keywords for purposes", () => {
131
+ // "fins" doesn't match the pattern — use "finalité" or "utilisation"
132
+ const text = "Nous utilisons des cookies pour des finalités de mesure et d'analyse.";
133
+ const result = analyzeModalText(text);
134
+ expect(result.missingInfo).not.toContain("purposes");
135
+ });
136
+
137
+ it("matches French keywords for third-parties", () => {
138
+ const text = "Vos données sont partagées avec nos partenaires.";
139
+ const result = analyzeModalText(text);
140
+ expect(result.missingInfo).not.toContain("third-parties");
141
+ });
142
+
143
+ it("matches French keywords for duration", () => {
144
+ const text = "La durée de conservation est de 13 mois.";
145
+ const result = analyzeModalText(text);
146
+ expect(result.missingInfo).not.toContain("duration");
147
+ });
148
+
149
+ it("matches French keywords for withdrawal", () => {
150
+ const text = "Vous pouvez retirer votre consentement à tout moment.";
151
+ const result = analyzeModalText(text);
152
+ expect(result.missingInfo).not.toContain("withdrawal");
153
+ });
154
+
155
+ it("creates a missing-info issue with warning severity for each missing item", () => {
156
+ const result = analyzeModalText("");
157
+ for (const issue of result.issues) {
158
+ expect(issue.type).toBe("missing-info");
159
+ expect(issue.severity).toBe("warning");
160
+ }
161
+ });
162
+ });
@@ -0,0 +1,9 @@
1
+ import { defineConfig } from "vitest/config";
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ environment: "node",
6
+ include: ["tests/**/*.test.ts"],
7
+ globals: false,
8
+ },
9
+ });