@slashgear/gdpr-cookie-scanner 3.6.0 → 3.8.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 +3 -0
- package/.gitattributes +1 -0
- package/.github/workflows/website.yml +80 -0
- package/CHANGELOG.md +52 -0
- package/CLAUDE.md +12 -1
- package/CONTRIBUTING.md +32 -4
- package/NEXT_STEPS.md +37 -3
- package/README.md +23 -0
- package/dist/analyzers/colour.d.ts +36 -0
- package/dist/analyzers/colour.d.ts.map +1 -0
- package/dist/analyzers/colour.js +75 -0
- package/dist/analyzers/colour.js.map +1 -0
- package/dist/analyzers/compliance.d.ts.map +1 -1
- package/dist/analyzers/compliance.js +24 -6
- package/dist/analyzers/compliance.js.map +1 -1
- package/dist/analyzers/tcf-decoder.d.ts +9 -0
- package/dist/analyzers/tcf-decoder.d.ts.map +1 -0
- package/dist/analyzers/tcf-decoder.js +123 -0
- package/dist/analyzers/tcf-decoder.js.map +1 -0
- package/dist/analyzers/wording.d.ts +1 -0
- package/dist/analyzers/wording.d.ts.map +1 -1
- package/dist/analyzers/wording.js +39 -0
- package/dist/analyzers/wording.js.map +1 -1
- package/dist/report/generator.d.ts +1 -0
- package/dist/report/generator.d.ts.map +1 -1
- package/dist/report/generator.js +71 -1
- package/dist/report/generator.js.map +1 -1
- package/dist/report/html.d.ts.map +1 -1
- package/dist/report/html.js +123 -0
- package/dist/report/html.js.map +1 -1
- package/dist/scanner/consent-modal.d.ts.map +1 -1
- package/dist/scanner/consent-modal.js +4 -2
- package/dist/scanner/consent-modal.js.map +1 -1
- package/dist/scanner/index.d.ts.map +1 -1
- package/dist/scanner/index.js +4 -0
- package/dist/scanner/index.js.map +1 -1
- package/dist/scanner/tcf.d.ts +9 -0
- package/dist/scanner/tcf.d.ts.map +1 -0
- package/dist/scanner/tcf.js +72 -0
- package/dist/scanner/tcf.js.map +1 -0
- package/dist/types.d.ts +26 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +7 -3
- package/pnpm-workspace.yaml +3 -0
- package/scripts/build-showcase.mjs +113 -0
- package/src/analyzers/colour.ts +89 -0
- package/src/analyzers/compliance.ts +35 -10
- package/src/analyzers/tcf-decoder.ts +130 -0
- package/src/analyzers/wording.ts +44 -0
- package/src/report/generator.ts +83 -1
- package/src/report/html.ts +146 -0
- package/src/scanner/consent-modal.ts +3 -1
- package/src/scanner/index.ts +5 -0
- package/src/scanner/tcf.ts +80 -0
- package/src/types.ts +29 -0
- package/tests/analyzers/colour.test.ts +187 -0
- package/tests/analyzers/compliance.test.ts +102 -0
- package/tests/analyzers/tcf-decoder.test.ts +292 -0
- package/tests/analyzers/wording.test.ts +38 -0
- package/tests/scanner/button-classification.test.ts +32 -0
- package/website/Dockerfile +55 -0
- package/website/node_modules/.bin/oxfmt +21 -0
- package/website/node_modules/.bin/oxlint +21 -0
- package/website/node_modules/.bin/tsc +21 -0
- package/website/node_modules/.bin/tsserver +21 -0
- package/website/node_modules/.bin/tsx +21 -0
- package/website/package.json +29 -0
- package/{docs → website/public}/index.html +88 -50
- package/website/public/reports/www.20minutes.fr/after-accept.png +3 -0
- package/website/public/reports/www.20minutes.fr/after-reject.png +3 -0
- package/website/public/reports/www.20minutes.fr/gdpr-report-20minutes.fr-2026-02-22.html +907 -0
- package/website/public/reports/www.20minutes.fr/modal-initial.png +3 -0
- package/website/public/reports/www.arte.tv/after-accept.png +3 -0
- package/website/public/reports/www.arte.tv/after-reject.png +3 -0
- package/website/public/reports/www.arte.tv/gdpr-report-arte.tv-2026-02-24.html +998 -0
- package/website/public/reports/www.arte.tv/modal-initial.png +3 -0
- package/website/public/reports/www.backmarket.fr/after-accept.png +3 -0
- package/website/public/reports/www.backmarket.fr/after-reject.png +3 -0
- package/website/public/reports/www.backmarket.fr/gdpr-report-backmarket.fr-2026-02-24.html +1530 -0
- package/website/public/reports/www.backmarket.fr/modal-initial.png +3 -0
- package/website/public/reports/www.deezer.com/after-accept.png +3 -0
- package/website/public/reports/www.deezer.com/after-reject.png +3 -0
- package/website/public/reports/www.deezer.com/gdpr-report-deezer.com-2026-02-22.html +1668 -0
- package/website/public/reports/www.deezer.com/modal-initial.png +3 -0
- package/website/public/reports/www.france.tv/after-accept.png +3 -0
- package/website/public/reports/www.france.tv/after-reject.png +3 -0
- package/website/public/reports/www.france.tv/gdpr-report-france.tv-2026-02-23.html +977 -0
- package/website/public/reports/www.france.tv/modal-initial.png +3 -0
- package/website/public/reports/www.m6.fr/after-accept.png +3 -0
- package/website/public/reports/www.m6.fr/after-reject.png +3 -0
- package/website/public/reports/www.m6.fr/gdpr-report-m6.fr-2026-02-28.html +1862 -0
- package/website/public/reports/www.m6.fr/modal-initial.png +3 -0
- package/website/public/reports/www.netflix.com/after-accept.png +3 -0
- package/website/public/reports/www.netflix.com/after-reject.png +3 -0
- package/website/public/reports/www.netflix.com/gdpr-report-netflix.com-2026-02-23.html +1051 -0
- package/website/public/reports/www.netflix.com/modal-initial.png +3 -0
- package/website/public/reports/www.radiofrance.fr/after-accept.png +3 -0
- package/website/public/reports/www.radiofrance.fr/after-reject.png +3 -0
- package/website/public/reports/www.radiofrance.fr/gdpr-report-radiofrance.fr-2026-02-24.html +1146 -0
- package/website/public/reports/www.radiofrance.fr/modal-initial.png +3 -0
- package/website/public/reports/www.tf1.fr/after-accept.png +3 -0
- package/website/public/reports/www.tf1.fr/after-reject.png +3 -0
- package/website/public/reports/www.tf1.fr/gdpr-report-tf1.fr-2026-02-23.html +1512 -0
- package/website/public/reports/www.tf1.fr/modal-initial.png +3 -0
- package/website/src/index.ts +15 -0
- package/website/src/security.ts +26 -0
- package/website/tsconfig.json +14 -0
- package/.github/workflows/pages.yml +0 -40
- 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 +0 -44
- package/docs/reports/github.com/gdpr-cookies-github.com-2026-02-22.md +0 -29
- package/docs/reports/github.com/gdpr-report-github.com-2026-02-22.md +0 -102
- 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 +0 -44
- package/docs/reports/gitlab.com/gdpr-cookies-gitlab.com-2026-02-22.md +0 -55
- package/docs/reports/gitlab.com/gdpr-report-gitlab.com-2026-02-22.md +0 -200
- 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 +0 -44
- package/docs/reports/npmjs.com/gdpr-cookies-npmjs.com-2026-02-22.md +0 -25
- package/docs/reports/npmjs.com/gdpr-report-npmjs.com-2026-02-22.md +0 -88
- 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 +0 -44
- package/docs/reports/reddit.com/gdpr-cookies-reddit.com-2026-02-22.md +0 -33
- package/docs/reports/reddit.com/gdpr-report-reddit.com-2026-02-22.md +0 -148
- 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 +0 -44
- package/docs/reports/stackoverflow.com/gdpr-cookies-stackoverflow.com-2026-02-22.md +0 -67
- package/docs/reports/stackoverflow.com/gdpr-report-stackoverflow.com-2026-02-22.md +0 -206
- 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 +0 -44
- package/docs/reports/www.afp.com/gdpr-cookies-afp.com-2026-02-22.md +0 -42
- package/docs/reports/www.afp.com/gdpr-report-afp.com-2026-02-22.md +0 -202
- 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 → website/public}/style.css +0 -0
package/src/report/html.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { basename } from "path";
|
|
2
2
|
import type { ScanResult, ScannedCookie, DarkPatternIssue, ConsentButton } from "../types.js";
|
|
3
3
|
import { lookupCookie } from "../classifiers/cookie-lookup.js";
|
|
4
|
+
import { IAB_PURPOSES, IAB_SPECIAL_FEATURES } from "../analyzers/tcf-decoder.js";
|
|
4
5
|
|
|
5
6
|
const GRADE_COLOR: Record<string, string> = {
|
|
6
7
|
A: "#16a34a",
|
|
@@ -418,6 +419,8 @@ export function generateHtmlReport(result: ScanResult): string {
|
|
|
418
419
|
|
|
419
420
|
${buildNetworkSection(result)}
|
|
420
421
|
|
|
422
|
+
${buildTcfSection(result)}
|
|
423
|
+
|
|
421
424
|
${buildRecommendationsSection(result)}
|
|
422
425
|
|
|
423
426
|
${buildChecklistSection(result)}
|
|
@@ -754,6 +757,149 @@ function buildNetworkSection(result: ScanResult): string {
|
|
|
754
757
|
</div>`;
|
|
755
758
|
}
|
|
756
759
|
|
|
760
|
+
// ── IAB TCF ───────────────────────────────────────────────────────────────────
|
|
761
|
+
|
|
762
|
+
function buildTcfSection(result: ScanResult): string {
|
|
763
|
+
const { tcf } = result;
|
|
764
|
+
|
|
765
|
+
const infoNote = `<p style="margin:0 0 16px;font-size:12px;color:var(--text-muted);font-style:italic">Informational only — does not affect the compliance score.</p>`;
|
|
766
|
+
|
|
767
|
+
if (!tcf.detected) {
|
|
768
|
+
return `<div class="section">
|
|
769
|
+
<div class="section-header">
|
|
770
|
+
<h2>IAB TCF</h2>
|
|
771
|
+
<span class="badge badge-muted">Not detected</span>
|
|
772
|
+
</div>
|
|
773
|
+
<div class="section-body">
|
|
774
|
+
${infoNote}
|
|
775
|
+
<p style="color:var(--text-muted);font-size:13px;margin:0">No IAB Transparency & Consent Framework implementation found on this page.</p>
|
|
776
|
+
</div>
|
|
777
|
+
</div>`;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
const versionBadge = tcf.version
|
|
781
|
+
? `<span class="badge badge-ok">TCF v${tcf.version}</span>`
|
|
782
|
+
: `<span class="badge badge-ok">Detected</span>`;
|
|
783
|
+
|
|
784
|
+
const metaRows = [
|
|
785
|
+
[
|
|
786
|
+
"CMP API (<code>__tcfapi</code>)",
|
|
787
|
+
tcf.apiPresent
|
|
788
|
+
? `<span class="status-ok">✓ Present</span>`
|
|
789
|
+
: `<span class="status-warn">✗ Not present</span>`,
|
|
790
|
+
],
|
|
791
|
+
[
|
|
792
|
+
"Locator frame (<code>__tcfapiLocator</code>)",
|
|
793
|
+
tcf.locatorFramePresent
|
|
794
|
+
? `<span class="status-ok">✓ Present</span>`
|
|
795
|
+
: `<span class="status-warn">✗ Not present</span>`,
|
|
796
|
+
],
|
|
797
|
+
[
|
|
798
|
+
"Consent string cookie",
|
|
799
|
+
tcf.cookieName
|
|
800
|
+
? `<code>${esc(tcf.cookieName)}</code>`
|
|
801
|
+
: `<span style="color:var(--text-muted)">—</span>`,
|
|
802
|
+
],
|
|
803
|
+
[
|
|
804
|
+
"CMP ID",
|
|
805
|
+
tcf.cmpId !== null ? String(tcf.cmpId) : `<span style="color:var(--text-muted)">—</span>`,
|
|
806
|
+
],
|
|
807
|
+
]
|
|
808
|
+
.map(
|
|
809
|
+
([label, value]) =>
|
|
810
|
+
`<tr><td style="font-weight:500;width:40%">${label}</td><td>${value}</td></tr>`,
|
|
811
|
+
)
|
|
812
|
+
.join("\n");
|
|
813
|
+
|
|
814
|
+
const cs = tcf.consentString;
|
|
815
|
+
|
|
816
|
+
let consentStringHtml = `<p style="color:var(--text-muted);font-size:13px;margin-top:16px">No consent string could be decoded.</p>`;
|
|
817
|
+
|
|
818
|
+
if (cs) {
|
|
819
|
+
const decodedRows = [
|
|
820
|
+
["Version", `TCF v${cs.version}`],
|
|
821
|
+
["Created", cs.created.toISOString().split("T")[0]],
|
|
822
|
+
["Last updated", cs.lastUpdated.toISOString().split("T")[0]],
|
|
823
|
+
["CMP ID", String(cs.cmpId)],
|
|
824
|
+
["CMP version", String(cs.cmpVersion)],
|
|
825
|
+
["Consent language", cs.consentLanguage],
|
|
826
|
+
["Vendor list version", String(cs.vendorListVersion)],
|
|
827
|
+
...(cs.tcfPolicyVersion !== undefined
|
|
828
|
+
? [["TCF policy version", String(cs.tcfPolicyVersion)]]
|
|
829
|
+
: []),
|
|
830
|
+
...(cs.isServiceSpecific !== undefined
|
|
831
|
+
? [["Service specific", cs.isServiceSpecific ? "Yes" : "No"]]
|
|
832
|
+
: []),
|
|
833
|
+
...(cs.publisherCC ? [["Publisher country", cs.publisherCC]] : []),
|
|
834
|
+
]
|
|
835
|
+
.map(
|
|
836
|
+
([label, value]) =>
|
|
837
|
+
`<tr><td style="font-weight:500;width:40%">${esc(label)}</td><td>${esc(value)}</td></tr>`,
|
|
838
|
+
)
|
|
839
|
+
.join("\n");
|
|
840
|
+
|
|
841
|
+
const purposesConsentHtml =
|
|
842
|
+
cs.purposesConsent.length > 0
|
|
843
|
+
? `<div style="margin-top:20px">
|
|
844
|
+
<div style="font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:.04em;color:var(--text-muted);margin-bottom:8px">Purposes with consent</div>
|
|
845
|
+
<table class="data-table">
|
|
846
|
+
<thead><tr><th>ID</th><th>Purpose</th></tr></thead>
|
|
847
|
+
<tbody>${cs.purposesConsent.map((id) => `<tr><td>${id}</td><td>${esc(IAB_PURPOSES[id] ?? "Unknown")}</td></tr>`).join("")}</tbody>
|
|
848
|
+
</table>
|
|
849
|
+
</div>`
|
|
850
|
+
: `<p style="margin-top:16px;font-size:13px;color:var(--text-muted)">No purposes with explicit consent.</p>`;
|
|
851
|
+
|
|
852
|
+
const purposesLiHtml =
|
|
853
|
+
cs.purposesLegitimateInterest.length > 0
|
|
854
|
+
? `<div style="margin-top:20px">
|
|
855
|
+
<div style="font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:.04em;color:var(--text-muted);margin-bottom:8px">Purposes with legitimate interest</div>
|
|
856
|
+
<table class="data-table">
|
|
857
|
+
<thead><tr><th>ID</th><th>Purpose</th></tr></thead>
|
|
858
|
+
<tbody>${cs.purposesLegitimateInterest.map((id) => `<tr><td>${id}</td><td>${esc(IAB_PURPOSES[id] ?? "Unknown")}</td></tr>`).join("")}</tbody>
|
|
859
|
+
</table>
|
|
860
|
+
</div>`
|
|
861
|
+
: "";
|
|
862
|
+
|
|
863
|
+
const specialFeaturesHtml =
|
|
864
|
+
cs.specialFeatureOptins.length > 0
|
|
865
|
+
? `<div style="margin-top:20px">
|
|
866
|
+
<div style="font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:.04em;color:var(--text-muted);margin-bottom:8px">Special features opted in</div>
|
|
867
|
+
<table class="data-table">
|
|
868
|
+
<thead><tr><th>ID</th><th>Feature</th></tr></thead>
|
|
869
|
+
<tbody>${cs.specialFeatureOptins.map((id) => `<tr><td>${id}</td><td>${esc(IAB_SPECIAL_FEATURES[id] ?? "Unknown")}</td></tr>`).join("")}</tbody>
|
|
870
|
+
</table>
|
|
871
|
+
</div>`
|
|
872
|
+
: "";
|
|
873
|
+
|
|
874
|
+
consentStringHtml = `<div style="margin-top:20px">
|
|
875
|
+
<div style="font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:.04em;color:var(--text-muted);margin-bottom:8px">Decoded consent string</div>
|
|
876
|
+
<table class="data-table">
|
|
877
|
+
<thead><tr><th>Field</th><th>Value</th></tr></thead>
|
|
878
|
+
<tbody>${decodedRows}</tbody>
|
|
879
|
+
</table>
|
|
880
|
+
</div>
|
|
881
|
+
${purposesConsentHtml}
|
|
882
|
+
${purposesLiHtml}
|
|
883
|
+
${specialFeaturesHtml}`;
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
return `<div class="section">
|
|
887
|
+
<div class="section-header">
|
|
888
|
+
<h2>IAB TCF</h2>
|
|
889
|
+
${versionBadge}
|
|
890
|
+
${tcf.cmpId !== null ? `<span class="badge badge-muted">CMP ${tcf.cmpId}</span>` : ""}
|
|
891
|
+
</div>
|
|
892
|
+
<div class="section-body">
|
|
893
|
+
${infoNote}
|
|
894
|
+
<table class="data-table">
|
|
895
|
+
<thead><tr><th>Property</th><th>Value</th></tr></thead>
|
|
896
|
+
<tbody>${metaRows}</tbody>
|
|
897
|
+
</table>
|
|
898
|
+
${consentStringHtml}
|
|
899
|
+
</div>
|
|
900
|
+
</div>`;
|
|
901
|
+
}
|
|
902
|
+
|
|
757
903
|
// ── Recommendations ───────────────────────────────────────────────────────────
|
|
758
904
|
|
|
759
905
|
function buildRecommendationsSection(result: ScanResult): string {
|
|
@@ -467,8 +467,10 @@ function classifyButtonType(
|
|
|
467
467
|
reject: RegExp[],
|
|
468
468
|
preferences: RegExp[],
|
|
469
469
|
): ConsentButtonType {
|
|
470
|
-
|
|
470
|
+
// Reject is tested first: phrases like "continuer sans accepter" contain "accepter"
|
|
471
|
+
// which would otherwise match the accept pattern before reaching the reject pattern.
|
|
471
472
|
if (reject.some((p) => p.test(text))) return "reject";
|
|
473
|
+
if (accept.some((p) => p.test(text))) return "accept";
|
|
472
474
|
if (preferences.some((p) => p.test(text))) return "preferences";
|
|
473
475
|
// Close buttons: word-form match OR standalone symbol (× ✕ ✗ ✖ ✘)
|
|
474
476
|
if (
|
package/src/scanner/index.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { captureCookies } from "./cookies.js";
|
|
|
6
6
|
import { createNetworkInterceptor } from "./network.js";
|
|
7
7
|
import { detectConsentModal, findPrivacyPolicyUrl } from "./consent-modal.js";
|
|
8
8
|
import { analyzeCompliance } from "../analyzers/compliance.js";
|
|
9
|
+
import { detectTcf } from "./tcf.js";
|
|
9
10
|
|
|
10
11
|
type PhaseCallback = (message: string) => void;
|
|
11
12
|
|
|
@@ -52,6 +53,9 @@ export class Scanner {
|
|
|
52
53
|
const networkBeforeInteraction = interceptor1.getRequests();
|
|
53
54
|
interceptor1.stop();
|
|
54
55
|
|
|
56
|
+
// Detect IAB TCF implementation (informational, does not affect score)
|
|
57
|
+
const tcfInfo = await detectTcf(session1.page, cookiesBeforeInteraction);
|
|
58
|
+
|
|
55
59
|
// Look for a privacy policy link anywhere on the page (typically footer/nav)
|
|
56
60
|
const privacyPolicyUrl = await findPrivacyPolicyUrl(session1.page);
|
|
57
61
|
|
|
@@ -202,6 +206,7 @@ export class Scanner {
|
|
|
202
206
|
compliance,
|
|
203
207
|
screenshotPaths,
|
|
204
208
|
errors,
|
|
209
|
+
tcf: tcfInfo,
|
|
205
210
|
};
|
|
206
211
|
}
|
|
207
212
|
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { Page } from "playwright";
|
|
2
|
+
import type { ScannedCookie, TcfInfo } from "../types.js";
|
|
3
|
+
import { decodeTcfConsentString } from "../analyzers/tcf-decoder.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Detect the IAB TCF (Transparency and Consent Framework) implementation on the page.
|
|
7
|
+
* Called at the end of Phase 1, after the initial page load and timeout.
|
|
8
|
+
* Purely informational — results do not affect the compliance score.
|
|
9
|
+
*/
|
|
10
|
+
export async function detectTcf(page: Page, cookies: ScannedCookie[]): Promise<TcfInfo> {
|
|
11
|
+
// Step 1: Check for TCF v2 (__tcfapi) and v1 (__cmp) API
|
|
12
|
+
let apiPresent = false;
|
|
13
|
+
try {
|
|
14
|
+
apiPresent = await page.evaluate(() => {
|
|
15
|
+
const w = window as unknown as Record<string, unknown>;
|
|
16
|
+
return typeof w["__tcfapi"] === "function" || typeof w["__cmp"] === "function";
|
|
17
|
+
});
|
|
18
|
+
} catch {
|
|
19
|
+
// Page context may have been destroyed
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Step 2: Check for __tcfapiLocator iframe
|
|
23
|
+
const locatorFramePresent = page.frames().some((f) => f.name() === "__tcfapiLocator");
|
|
24
|
+
|
|
25
|
+
// Step 3: Look for euconsent cookies already captured
|
|
26
|
+
const euconsentV2 = cookies.find((c) => c.name === "euconsent-v2");
|
|
27
|
+
const euconsentV1 = cookies.find((c) => c.name === "euconsent");
|
|
28
|
+
const consentCookie = euconsentV2 ?? euconsentV1;
|
|
29
|
+
const cookieName = consentCookie?.name ?? null;
|
|
30
|
+
|
|
31
|
+
// Step 4: Try to obtain the consent string via __tcfapi if the API is present
|
|
32
|
+
let tcString: string | null = consentCookie?.value ?? null;
|
|
33
|
+
|
|
34
|
+
if (apiPresent && !tcString) {
|
|
35
|
+
try {
|
|
36
|
+
const result = await page.evaluate(() => {
|
|
37
|
+
return new Promise<string | null>((resolve) => {
|
|
38
|
+
const timeout = setTimeout(() => resolve(null), 3000);
|
|
39
|
+
try {
|
|
40
|
+
const w = window as unknown as Record<string, unknown>;
|
|
41
|
+
const tcfApi = w["__tcfapi"] as (
|
|
42
|
+
command: string,
|
|
43
|
+
version: number,
|
|
44
|
+
callback: (tcData: Record<string, unknown>, success: boolean) => void,
|
|
45
|
+
) => void;
|
|
46
|
+
tcfApi("getTCData", 2, (tcData, success) => {
|
|
47
|
+
clearTimeout(timeout);
|
|
48
|
+
if (success && typeof tcData["tcString"] === "string") {
|
|
49
|
+
resolve(tcData["tcString"] as string);
|
|
50
|
+
} else {
|
|
51
|
+
resolve(null);
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
} catch {
|
|
55
|
+
clearTimeout(timeout);
|
|
56
|
+
resolve(null);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
if (result) tcString = result;
|
|
61
|
+
} catch {
|
|
62
|
+
// page.evaluate() may throw if the page navigated
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Step 5: Decode the consent string
|
|
67
|
+
const consentString = tcString ? decodeTcfConsentString(tcString) : null;
|
|
68
|
+
|
|
69
|
+
const detected = apiPresent || locatorFramePresent || cookieName !== null;
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
detected,
|
|
73
|
+
version: consentString?.version ?? (euconsentV2 ? 2 : euconsentV1 ? 1 : null),
|
|
74
|
+
apiPresent,
|
|
75
|
+
locatorFramePresent,
|
|
76
|
+
cookieName,
|
|
77
|
+
cmpId: consentString?.cmpId ?? null,
|
|
78
|
+
consentString,
|
|
79
|
+
};
|
|
80
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -130,6 +130,34 @@ export interface ScanOptions {
|
|
|
130
130
|
strict?: boolean;
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
+
export interface TcfConsentString {
|
|
134
|
+
raw: string;
|
|
135
|
+
version: 1 | 2;
|
|
136
|
+
created: Date;
|
|
137
|
+
lastUpdated: Date;
|
|
138
|
+
cmpId: number;
|
|
139
|
+
cmpVersion: number;
|
|
140
|
+
consentLanguage: string;
|
|
141
|
+
vendorListVersion: number;
|
|
142
|
+
// TCF v2 only:
|
|
143
|
+
tcfPolicyVersion?: number;
|
|
144
|
+
isServiceSpecific?: boolean;
|
|
145
|
+
specialFeatureOptins: number[]; // IDs of opted-in special features
|
|
146
|
+
purposesConsent: number[]; // IDs of purposes with consent
|
|
147
|
+
purposesLegitimateInterest: number[]; // IDs of purposes with legitimate interest
|
|
148
|
+
publisherCC?: string;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export interface TcfInfo {
|
|
152
|
+
detected: boolean;
|
|
153
|
+
version: 1 | 2 | null;
|
|
154
|
+
apiPresent: boolean; // window.__tcfapi found
|
|
155
|
+
locatorFramePresent: boolean; // iframe __tcfapiLocator found
|
|
156
|
+
cookieName: string | null; // "euconsent-v2" or "euconsent"
|
|
157
|
+
cmpId: number | null;
|
|
158
|
+
consentString: TcfConsentString | null;
|
|
159
|
+
}
|
|
160
|
+
|
|
133
161
|
export interface ScanResult {
|
|
134
162
|
url: string;
|
|
135
163
|
scanDate: string;
|
|
@@ -145,4 +173,5 @@ export interface ScanResult {
|
|
|
145
173
|
compliance: ComplianceScore;
|
|
146
174
|
screenshotPaths: string[];
|
|
147
175
|
errors: string[];
|
|
176
|
+
tcf: TcfInfo;
|
|
148
177
|
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { rgbToHsl, classifyHue, detectColourNudging } from "../../src/analyzers/colour.js";
|
|
3
|
+
|
|
4
|
+
// ── rgbToHsl ──────────────────────────────────────────────────────────────────
|
|
5
|
+
|
|
6
|
+
describe("rgbToHsl", () => {
|
|
7
|
+
it("converts pure red", () => {
|
|
8
|
+
const [h, s, l] = rgbToHsl(255, 0, 0);
|
|
9
|
+
expect(h).toBe(0);
|
|
10
|
+
expect(s).toBe(100);
|
|
11
|
+
expect(l).toBe(50);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("converts pure green", () => {
|
|
15
|
+
const [h, s, l] = rgbToHsl(0, 255, 0);
|
|
16
|
+
expect(h).toBe(120);
|
|
17
|
+
expect(s).toBe(100);
|
|
18
|
+
expect(l).toBe(50);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("converts pure blue", () => {
|
|
22
|
+
const [h, s, l] = rgbToHsl(0, 0, 255);
|
|
23
|
+
expect(h).toBe(240);
|
|
24
|
+
expect(s).toBe(100);
|
|
25
|
+
expect(l).toBe(50);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("converts white", () => {
|
|
29
|
+
const [_h, s, l] = rgbToHsl(255, 255, 255);
|
|
30
|
+
expect(s).toBe(0);
|
|
31
|
+
expect(l).toBe(100);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("converts black", () => {
|
|
35
|
+
const [_h, s, l] = rgbToHsl(0, 0, 0);
|
|
36
|
+
expect(s).toBe(0);
|
|
37
|
+
expect(l).toBe(0);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("converts medium grey", () => {
|
|
41
|
+
const [, s, l] = rgbToHsl(128, 128, 128);
|
|
42
|
+
expect(s).toBe(0);
|
|
43
|
+
expect(l).toBeCloseTo(50, 0);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// ── classifyHue ───────────────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
describe("classifyHue", () => {
|
|
50
|
+
describe("green", () => {
|
|
51
|
+
it("classifies a vivid green accept button colour", () => {
|
|
52
|
+
// rgb(34, 197, 94) — typical Tailwind green-500
|
|
53
|
+
expect(classifyHue(34, 197, 94)).toBe("green");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("classifies a darker CMP green", () => {
|
|
57
|
+
// rgb(22, 163, 74) — green-600
|
|
58
|
+
expect(classifyHue(22, 163, 74)).toBe("green");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("classifies a yellow-green as green", () => {
|
|
62
|
+
// h ≈ 100 — still in the green range
|
|
63
|
+
expect(classifyHue(100, 200, 50)).toBe("green");
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe("grey", () => {
|
|
68
|
+
it("classifies a mid-grey reject button", () => {
|
|
69
|
+
expect(classifyHue(160, 160, 160)).toBe("grey");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("classifies a light grey (s very low)", () => {
|
|
73
|
+
expect(classifyHue(220, 222, 220)).toBe("grey");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("classifies a dark grey", () => {
|
|
77
|
+
// l ≈ 25%, s ≈ 0
|
|
78
|
+
expect(classifyHue(60, 60, 60)).toBe("grey");
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe("red", () => {
|
|
83
|
+
it("classifies pure red", () => {
|
|
84
|
+
expect(classifyHue(255, 0, 0)).toBe("red");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("classifies a muted red / crimson", () => {
|
|
88
|
+
// rgb(185, 28, 28) — red-700
|
|
89
|
+
expect(classifyHue(185, 28, 28)).toBe("red");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("classifies a high-hue pink/magenta near 340° as red", () => {
|
|
93
|
+
// h ≈ 348
|
|
94
|
+
expect(classifyHue(220, 38, 100)).toBe("red");
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe("blue", () => {
|
|
99
|
+
it("classifies a standard blue CTA button", () => {
|
|
100
|
+
// rgb(59, 130, 246) — blue-500
|
|
101
|
+
expect(classifyHue(59, 130, 246)).toBe("blue");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("classifies a darker navy blue", () => {
|
|
105
|
+
expect(classifyHue(30, 64, 175)).toBe("blue");
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe("neutral", () => {
|
|
110
|
+
it("returns neutral for white (l > 93)", () => {
|
|
111
|
+
expect(classifyHue(255, 255, 255)).toBe("neutral");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("returns neutral for near-black (l < 10)", () => {
|
|
115
|
+
expect(classifyHue(10, 10, 10)).toBe("neutral");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("returns neutral for an orange hue (not a defined category)", () => {
|
|
119
|
+
// h ≈ 30, s ≈ 90% — orange
|
|
120
|
+
expect(classifyHue(255, 140, 0)).toBe("neutral");
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// ── detectColourNudging ───────────────────────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
describe("detectColourNudging", () => {
|
|
128
|
+
const GREEN = "rgb(34, 197, 94)";
|
|
129
|
+
const GREY = "rgb(160, 160, 160)";
|
|
130
|
+
const RED = "rgb(185, 28, 28)";
|
|
131
|
+
const BLUE = "rgb(59, 130, 246)";
|
|
132
|
+
|
|
133
|
+
describe("nudging detected", () => {
|
|
134
|
+
it("flags green accept + grey reject", () => {
|
|
135
|
+
const { isNudging, acceptHue, rejectHue } = detectColourNudging(GREEN, GREY);
|
|
136
|
+
expect(isNudging).toBe(true);
|
|
137
|
+
expect(acceptHue).toBe("green");
|
|
138
|
+
expect(rejectHue).toBe("grey");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("flags green accept + red reject", () => {
|
|
142
|
+
const { isNudging, acceptHue, rejectHue } = detectColourNudging(GREEN, RED);
|
|
143
|
+
expect(isNudging).toBe(true);
|
|
144
|
+
expect(acceptHue).toBe("green");
|
|
145
|
+
expect(rejectHue).toBe("red");
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
describe("no nudging", () => {
|
|
150
|
+
it("does not flag blue accept + grey reject (blue ≠ green)", () => {
|
|
151
|
+
const { isNudging } = detectColourNudging(BLUE, GREY);
|
|
152
|
+
expect(isNudging).toBe(false);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("does not flag green accept + green reject (same hue, no asymmetry)", () => {
|
|
156
|
+
const { isNudging } = detectColourNudging(GREEN, GREEN);
|
|
157
|
+
expect(isNudging).toBe(false);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("does not flag green accept + blue reject", () => {
|
|
161
|
+
const { isNudging } = detectColourNudging(GREEN, BLUE);
|
|
162
|
+
expect(isNudging).toBe(false);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("returns isNudging false when acceptBg is null", () => {
|
|
166
|
+
const { isNudging, acceptHue } = detectColourNudging(null, GREY);
|
|
167
|
+
expect(isNudging).toBe(false);
|
|
168
|
+
expect(acceptHue).toBeNull();
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("returns isNudging false when rejectBg is null", () => {
|
|
172
|
+
const { isNudging, rejectHue } = detectColourNudging(GREEN, null);
|
|
173
|
+
expect(isNudging).toBe(false);
|
|
174
|
+
expect(rejectHue).toBeNull();
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("returns isNudging false when both are null", () => {
|
|
178
|
+
const { isNudging } = detectColourNudging(null, null);
|
|
179
|
+
expect(isNudging).toBe(false);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("does not flag unparseable CSS strings", () => {
|
|
183
|
+
const { isNudging } = detectColourNudging("transparent", "inherit");
|
|
184
|
+
expect(isNudging).toBe(false);
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
});
|
|
@@ -287,6 +287,108 @@ describe("easyRefusal dimension", () => {
|
|
|
287
287
|
});
|
|
288
288
|
expect(result.issues.some((i) => i.type === "nudging")).toBe(true);
|
|
289
289
|
});
|
|
290
|
+
|
|
291
|
+
it("deducts 5 for indirect reject label ('continuer sans accepter' dark pattern)", () => {
|
|
292
|
+
const modal = makeModal({
|
|
293
|
+
buttons: [
|
|
294
|
+
makeButton("accept", "Tout accepter", 1),
|
|
295
|
+
makeButton("reject", "Continuer sans accepter", 1),
|
|
296
|
+
],
|
|
297
|
+
});
|
|
298
|
+
const result = analyzeCompliance({
|
|
299
|
+
modal,
|
|
300
|
+
privacyPolicyUrl: "https://example.com/privacy",
|
|
301
|
+
cookiesBeforeInteraction: [],
|
|
302
|
+
cookiesAfterAccept: [makeCookie("_ga", true, "after-accept")],
|
|
303
|
+
cookiesAfterReject: [],
|
|
304
|
+
networkBeforeInteraction: [],
|
|
305
|
+
networkAfterAccept: [],
|
|
306
|
+
networkAfterReject: [],
|
|
307
|
+
});
|
|
308
|
+
expect(result.breakdown.easyRefusal).toBeLessThanOrEqual(20);
|
|
309
|
+
expect(
|
|
310
|
+
result.issues.some((i) => i.type === "misleading-wording" && i.severity === "warning"),
|
|
311
|
+
).toBe(true);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it("does NOT deduct for an explicit reject label", () => {
|
|
315
|
+
const modal = makeModal({
|
|
316
|
+
buttons: [makeButton("accept", "Tout accepter", 1), makeButton("reject", "Tout refuser", 1)],
|
|
317
|
+
});
|
|
318
|
+
const result = analyzeCompliance({
|
|
319
|
+
modal,
|
|
320
|
+
privacyPolicyUrl: "https://example.com/privacy",
|
|
321
|
+
cookiesBeforeInteraction: [],
|
|
322
|
+
cookiesAfterAccept: [makeCookie("_ga", true, "after-accept")],
|
|
323
|
+
cookiesAfterReject: [],
|
|
324
|
+
networkBeforeInteraction: [],
|
|
325
|
+
networkAfterAccept: [],
|
|
326
|
+
networkAfterReject: [],
|
|
327
|
+
});
|
|
328
|
+
expect(result.breakdown.easyRefusal).toBe(25);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it("deducts 5 for colour nudging (green accept + grey reject)", () => {
|
|
332
|
+
const modal = makeModal({
|
|
333
|
+
buttons: [
|
|
334
|
+
makeButton("accept", "Accept all", 1, { backgroundColor: "rgb(34, 197, 94)" }),
|
|
335
|
+
makeButton("reject", "Reject all", 1, { backgroundColor: "rgb(160, 160, 160)" }),
|
|
336
|
+
],
|
|
337
|
+
});
|
|
338
|
+
const result = analyzeCompliance({
|
|
339
|
+
modal,
|
|
340
|
+
privacyPolicyUrl: "https://example.com/privacy",
|
|
341
|
+
cookiesBeforeInteraction: [],
|
|
342
|
+
cookiesAfterAccept: [makeCookie("_ga", true, "after-accept")],
|
|
343
|
+
cookiesAfterReject: [],
|
|
344
|
+
networkBeforeInteraction: [],
|
|
345
|
+
networkAfterAccept: [],
|
|
346
|
+
networkAfterReject: [],
|
|
347
|
+
});
|
|
348
|
+
expect(result.breakdown.easyRefusal).toBeLessThanOrEqual(20);
|
|
349
|
+
expect(result.issues.some((i) => i.type === "nudging" && i.severity === "warning")).toBe(true);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
it("deducts 5 for colour nudging (green accept + red reject)", () => {
|
|
353
|
+
const modal = makeModal({
|
|
354
|
+
buttons: [
|
|
355
|
+
makeButton("accept", "Accept all", 1, { backgroundColor: "rgb(34, 197, 94)" }),
|
|
356
|
+
makeButton("reject", "Reject all", 1, { backgroundColor: "rgb(185, 28, 28)" }),
|
|
357
|
+
],
|
|
358
|
+
});
|
|
359
|
+
const result = analyzeCompliance({
|
|
360
|
+
modal,
|
|
361
|
+
privacyPolicyUrl: "https://example.com/privacy",
|
|
362
|
+
cookiesBeforeInteraction: [],
|
|
363
|
+
cookiesAfterAccept: [makeCookie("_ga", true, "after-accept")],
|
|
364
|
+
cookiesAfterReject: [],
|
|
365
|
+
networkBeforeInteraction: [],
|
|
366
|
+
networkAfterAccept: [],
|
|
367
|
+
networkAfterReject: [],
|
|
368
|
+
});
|
|
369
|
+
expect(result.breakdown.easyRefusal).toBeLessThanOrEqual(20);
|
|
370
|
+
expect(result.issues.some((i) => i.type === "nudging")).toBe(true);
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
it("does NOT flag colour nudging when accept is blue (not green)", () => {
|
|
374
|
+
const modal = makeModal({
|
|
375
|
+
buttons: [
|
|
376
|
+
makeButton("accept", "Accept all", 1, { backgroundColor: "rgb(59, 130, 246)" }),
|
|
377
|
+
makeButton("reject", "Reject all", 1, { backgroundColor: "rgb(160, 160, 160)" }),
|
|
378
|
+
],
|
|
379
|
+
});
|
|
380
|
+
const result = analyzeCompliance({
|
|
381
|
+
modal,
|
|
382
|
+
privacyPolicyUrl: "https://example.com/privacy",
|
|
383
|
+
cookiesBeforeInteraction: [],
|
|
384
|
+
cookiesAfterAccept: [makeCookie("_ga", true, "after-accept")],
|
|
385
|
+
cookiesAfterReject: [],
|
|
386
|
+
networkBeforeInteraction: [],
|
|
387
|
+
networkAfterAccept: [],
|
|
388
|
+
networkAfterReject: [],
|
|
389
|
+
});
|
|
390
|
+
expect(result.breakdown.easyRefusal).toBe(25);
|
|
391
|
+
});
|
|
290
392
|
});
|
|
291
393
|
|
|
292
394
|
// ── C. Transparency ───────────────────────────────────────────────────────────
|