privalint 0.0.0-stage → 0.2.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/LICENSE +21 -0
- package/README.de.md +89 -0
- package/README.md +222 -2
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +183 -0
- package/dist/cli.js.map +1 -0
- package/dist/consent.d.ts +12 -0
- package/dist/consent.js +185 -0
- package/dist/consent.js.map +1 -0
- package/dist/data/third-parties.d.ts +13 -0
- package/dist/data/third-parties.js +42 -0
- package/dist/data/third-parties.js.map +1 -0
- package/dist/data/trackers.d.ts +21 -0
- package/dist/data/trackers.js +135 -0
- package/dist/data/trackers.js.map +1 -0
- package/dist/i18n.d.ts +5 -0
- package/dist/i18n.js +3 -0
- package/dist/i18n.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -0
- package/dist/legal.d.ts +10 -0
- package/dist/legal.js +99 -0
- package/dist/legal.js.map +1 -0
- package/dist/meta.d.ts +3 -0
- package/dist/meta.js +6 -0
- package/dist/meta.js.map +1 -0
- package/dist/reporters/index.d.ts +11 -0
- package/dist/reporters/index.js +19 -0
- package/dist/reporters/index.js.map +1 -0
- package/dist/reporters/json.d.ts +2 -0
- package/dist/reporters/json.js +2 -0
- package/dist/reporters/json.js.map +1 -0
- package/dist/reporters/markdown.d.ts +9 -0
- package/dist/reporters/markdown.js +49 -0
- package/dist/reporters/markdown.js.map +1 -0
- package/dist/reporters/messages.d.ts +21 -0
- package/dist/reporters/messages.js +62 -0
- package/dist/reporters/messages.js.map +1 -0
- package/dist/reporters/sarif.d.ts +10 -0
- package/dist/reporters/sarif.js +84 -0
- package/dist/reporters/sarif.js.map +1 -0
- package/dist/reporters/shared.d.ts +3 -0
- package/dist/reporters/shared.js +17 -0
- package/dist/reporters/shared.js.map +1 -0
- package/dist/reporters/text.d.ts +2 -0
- package/dist/reporters/text.js +56 -0
- package/dist/reporters/text.js.map +1 -0
- package/dist/rules/google-fonts.d.ts +2 -0
- package/dist/rules/google-fonts.js +68 -0
- package/dist/rules/google-fonts.js.map +1 -0
- package/dist/rules/impressum-link.d.ts +2 -0
- package/dist/rules/impressum-link.js +59 -0
- package/dist/rules/impressum-link.js.map +1 -0
- package/dist/rules/index.d.ts +11 -0
- package/dist/rules/index.js +20 -0
- package/dist/rules/index.js.map +1 -0
- package/dist/rules/links.d.ts +8 -0
- package/dist/rules/links.js +16 -0
- package/dist/rules/links.js.map +1 -0
- package/dist/rules/pre-consent-trackers.d.ts +2 -0
- package/dist/rules/pre-consent-trackers.js +45 -0
- package/dist/rules/pre-consent-trackers.js.map +1 -0
- package/dist/rules/privacy-policy-link.d.ts +2 -0
- package/dist/rules/privacy-policy-link.js +33 -0
- package/dist/rules/privacy-policy-link.js.map +1 -0
- package/dist/rules/reject-option.d.ts +2 -0
- package/dist/rules/reject-option.js +66 -0
- package/dist/rules/reject-option.js.map +1 -0
- package/dist/rules/third-party-cdns.d.ts +2 -0
- package/dist/rules/third-party-cdns.js +30 -0
- package/dist/rules/third-party-cdns.js.map +1 -0
- package/dist/rules/third-party-embeds.d.ts +2 -0
- package/dist/rules/third-party-embeds.js +30 -0
- package/dist/rules/third-party-embeds.js.map +1 -0
- package/dist/rules/third-party-hits.d.ts +4 -0
- package/dist/rules/third-party-hits.js +13 -0
- package/dist/rules/third-party-hits.js.map +1 -0
- package/dist/rules/tracker-hits.d.ts +11 -0
- package/dist/rules/tracker-hits.js +40 -0
- package/dist/rules/tracker-hits.js.map +1 -0
- package/dist/rules/trackers-after-reject.d.ts +2 -0
- package/dist/rules/trackers-after-reject.js +39 -0
- package/dist/rules/trackers-after-reject.js.map +1 -0
- package/dist/scanner.d.ts +39 -0
- package/dist/scanner.js +175 -0
- package/dist/scanner.js.map +1 -0
- package/dist/types.d.ts +105 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/url.d.ts +26 -0
- package/dist/url.js +71 -0
- package/dist/url.js.map +1 -0
- package/package.json +65 -4
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { say } from '../i18n.js';
|
|
2
|
+
import { categoryLabel, findTrackerHits } from './tracker-hits.js';
|
|
3
|
+
const ID = 'pre-consent-trackers';
|
|
4
|
+
export const preConsentTrackers = {
|
|
5
|
+
id: ID,
|
|
6
|
+
title: 'Trackers run before consent',
|
|
7
|
+
description: 'Analytics, advertising and session-replay tools must not load or set cookies until the ' +
|
|
8
|
+
'visitor has opted in. The page is loaded without interacting with any consent banner, so ' +
|
|
9
|
+
'everything reported here happened before consent.',
|
|
10
|
+
references: [
|
|
11
|
+
'§ 25 Abs. 1 TDDDG',
|
|
12
|
+
'Art. 5(3) ePrivacy Directive',
|
|
13
|
+
'Art. 6(1)(a) GDPR',
|
|
14
|
+
'CJEU C-673/17 (Planet49)',
|
|
15
|
+
],
|
|
16
|
+
check({ page, lang }) {
|
|
17
|
+
const hits = findTrackerHits(page.requests, page.cookies, lang);
|
|
18
|
+
return hits.map(({ tracker, evidence }) => {
|
|
19
|
+
const { vendor } = tracker;
|
|
20
|
+
const isTagManager = tracker.category === 'tag-manager';
|
|
21
|
+
const category = categoryLabel(tracker.category, lang);
|
|
22
|
+
return {
|
|
23
|
+
ruleId: ID,
|
|
24
|
+
severity: isTagManager ? 'warning' : 'error',
|
|
25
|
+
message: isTagManager
|
|
26
|
+
? say(lang, {
|
|
27
|
+
en: `${vendor} loads before consent and sends the visitor's IP address to a third party.`,
|
|
28
|
+
de: `${vendor} wird vor der Einwilligung geladen und überträgt die IP-Adresse an einen Dritten.`,
|
|
29
|
+
})
|
|
30
|
+
: say(lang, {
|
|
31
|
+
en: `${vendor} (${category}) runs before the visitor has given consent.`,
|
|
32
|
+
de: `${vendor} (${category}) läuft, bevor eine Einwilligung vorliegt.`,
|
|
33
|
+
}),
|
|
34
|
+
pageUrl: page.finalUrl,
|
|
35
|
+
subject: vendor,
|
|
36
|
+
evidence,
|
|
37
|
+
fix: say(lang, {
|
|
38
|
+
en: `Only load ${vendor} after opt-in, for example by letting your consent manager inject it or by using it in a consent mode that sends no requests until consent is given.`,
|
|
39
|
+
de: `${vendor} erst nach der Einwilligung laden, zum Beispiel über das Consent-Tool, oder einen Consent-Modus nutzen, der vorher keine Anfragen sendet.`,
|
|
40
|
+
}),
|
|
41
|
+
};
|
|
42
|
+
});
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
//# sourceMappingURL=pre-consent-trackers.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"pre-consent-trackers.js","sourceRoot":"","sources":["../../src/rules/pre-consent-trackers.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,GAAG,EAAE,MAAM,YAAY,CAAC;AAEjC,OAAO,EAAE,aAAa,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC;AAEnE,MAAM,EAAE,GAAG,sBAAsB,CAAC;AAElC,MAAM,CAAC,MAAM,kBAAkB,GAAS;IACtC,EAAE,EAAE,EAAE;IACN,KAAK,EAAE,6BAA6B;IACpC,WAAW,EACT,yFAAyF;QACzF,2FAA2F;QAC3F,mDAAmD;IACrD,UAAU,EAAE;QACV,mBAAmB;QACnB,8BAA8B;QAC9B,mBAAmB;QACnB,0BAA0B;KAC3B;IACD,KAAK,CAAC,EAAE,IAAI,EAAE,IAAI,EAAE;QAClB,MAAM,IAAI,GAAG,eAAe,CAAC,IAAI,CAAC,QAAQ,EAAE,IAAI,CAAC,OAAO,EAAE,IAAI,CAAC,CAAC;QAChE,OAAO,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,OAAO,EAAE,QAAQ,EAAE,EAAW,EAAE;YACjD,MAAM,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC;YAC3B,MAAM,YAAY,GAAG,OAAO,CAAC,QAAQ,KAAK,aAAa,CAAC;YACxD,MAAM,QAAQ,GAAG,aAAa,CAAC,OAAO,CAAC,QAAQ,EAAE,IAAI,CAAC,CAAC;YACvD,OAAO;gBACL,MAAM,EAAE,EAAE;gBACV,QAAQ,EAAE,YAAY,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,OAAO;gBAC5C,OAAO,EAAE,YAAY;oBACnB,CAAC,CAAC,GAAG,CAAC,IAAI,EAAE;wBACR,EAAE,EAAE,GAAG,MAAM,4EAA4E;wBACzF,EAAE,EAAE,GAAG,MAAM,mFAAmF;qBACjG,CAAC;oBACJ,CAAC,CAAC,GAAG,CAAC,IAAI,EAAE;wBACR,EAAE,EAAE,GAAG,MAAM,KAAK,QAAQ,8CAA8C;wBACxE,EAAE,EAAE,GAAG,MAAM,KAAK,QAAQ,4CAA4C;qBACvE,CAAC;gBACN,OAAO,EAAE,IAAI,CAAC,QAAQ;gBACtB,OAAO,EAAE,MAAM;gBACf,QAAQ;gBACR,GAAG,EAAE,GAAG,CAAC,IAAI,EAAE;oBACb,EAAE,EAAE,aAAa,MAAM,sJAAsJ;oBAC7K,EAAE,EAAE,GAAG,MAAM,2IAA2I;iBACzJ,CAAC;aACH,CAAC;QACJ,CAAC,CAAC,CAAC;IACL,CAAC;CACF,CAAC"}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { say } from '../i18n.js';
|
|
2
|
+
import { linkMatches, linksChecked } from './links.js';
|
|
3
|
+
const ID = 'privacy-policy-link';
|
|
4
|
+
const PRIVACY_POLICY = /datenschutz|privacy|data[\s_-]?protection|confidentialit|privacidad|protezione[\s_-]?dei[\s_-]?dati|privacybeleid|polityka[\s_-]?prywatno/i;
|
|
5
|
+
export const privacyPolicyLink = {
|
|
6
|
+
id: ID,
|
|
7
|
+
title: 'No link to a privacy policy',
|
|
8
|
+
description: 'Visitors must be told how their data is processed at the time it is collected. Since every ' +
|
|
9
|
+
'page view already involves server logs, the privacy policy has to be reachable from every page.',
|
|
10
|
+
references: ['Art. 12 GDPR', 'Art. 13 GDPR'],
|
|
11
|
+
check({ page, lang }) {
|
|
12
|
+
if (page.links.some(linkMatches(PRIVACY_POLICY))) {
|
|
13
|
+
return [];
|
|
14
|
+
}
|
|
15
|
+
return [
|
|
16
|
+
{
|
|
17
|
+
ruleId: ID,
|
|
18
|
+
severity: 'warning',
|
|
19
|
+
message: say(lang, {
|
|
20
|
+
en: 'No link to a privacy policy was found on this page.',
|
|
21
|
+
de: 'Auf dieser Seite wurde kein Link zur Datenschutzerklärung gefunden.',
|
|
22
|
+
}),
|
|
23
|
+
pageUrl: page.finalUrl,
|
|
24
|
+
evidence: [linksChecked(page.links.length, lang)],
|
|
25
|
+
fix: say(lang, {
|
|
26
|
+
en: 'Add a link labelled "Datenschutz" or "Datenschutzerklärung" next to the Impressum link in the footer.',
|
|
27
|
+
de: 'Einen Link „Datenschutz“ oder „Datenschutzerklärung“ neben dem Impressum-Link in den Footer setzen.',
|
|
28
|
+
}),
|
|
29
|
+
},
|
|
30
|
+
];
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
//# sourceMappingURL=privacy-policy-link.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"privacy-policy-link.js","sourceRoot":"","sources":["../../src/rules/privacy-policy-link.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,GAAG,EAAE,MAAM,YAAY,CAAC;AAEjC,OAAO,EAAE,WAAW,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAEvD,MAAM,EAAE,GAAG,qBAAqB,CAAC;AAEjC,MAAM,cAAc,GAClB,4IAA4I,CAAC;AAE/I,MAAM,CAAC,MAAM,iBAAiB,GAAS;IACrC,EAAE,EAAE,EAAE;IACN,KAAK,EAAE,6BAA6B;IACpC,WAAW,EACT,6FAA6F;QAC7F,iGAAiG;IACnG,UAAU,EAAE,CAAC,cAAc,EAAE,cAAc,CAAC;IAC5C,KAAK,CAAC,EAAE,IAAI,EAAE,IAAI,EAAE;QAClB,IAAI,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,WAAW,CAAC,cAAc,CAAC,CAAC,EAAE,CAAC;YACjD,OAAO,EAAE,CAAC;QACZ,CAAC;QACD,OAAO;YACL;gBACE,MAAM,EAAE,EAAE;gBACV,QAAQ,EAAE,SAAS;gBACnB,OAAO,EAAE,GAAG,CAAC,IAAI,EAAE;oBACjB,EAAE,EAAE,qDAAqD;oBACzD,EAAE,EAAE,qEAAqE;iBAC1E,CAAC;gBACF,OAAO,EAAE,IAAI,CAAC,QAAQ;gBACtB,QAAQ,EAAE,CAAC,YAAY,CAAC,IAAI,CAAC,KAAK,CAAC,MAAM,EAAE,IAAI,CAAC,CAAC;gBACjD,GAAG,EAAE,GAAG,CAAC,IAAI,EAAE;oBACb,EAAE,EAAE,uGAAuG;oBAC3G,EAAE,EAAE,qGAAqG;iBAC1G,CAAC;aACH;SACF,CAAC;IACJ,CAAC;CACF,CAAC"}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { say } from '../i18n.js';
|
|
2
|
+
const ID = 'reject-option';
|
|
3
|
+
export const rejectOption = {
|
|
4
|
+
id: ID,
|
|
5
|
+
title: 'No way to reject on the first layer of the banner',
|
|
6
|
+
description: 'Rejecting cookies must be as easy as accepting them. Most European regulators treat a banner ' +
|
|
7
|
+
'that offers "accept" on its first layer, but hides "reject" in the settings or behind a ' +
|
|
8
|
+
'link, as invalid consent.',
|
|
9
|
+
references: [
|
|
10
|
+
'EDPB Cookie Banner Taskforce report, 17.01.2023',
|
|
11
|
+
'DSK-Beschluss, 22.03.2023: Bewertung von Pur-Abo-Modellen auf Websites',
|
|
12
|
+
'EDPB Opinion 08/2024 on consent or pay models',
|
|
13
|
+
'Art. 4(11) GDPR',
|
|
14
|
+
'Art. 7(3) GDPR',
|
|
15
|
+
'§ 25 Abs. 1 TDDDG',
|
|
16
|
+
],
|
|
17
|
+
check({ page, lang }) {
|
|
18
|
+
const consent = page.consent;
|
|
19
|
+
if (consent?.bannerFound !== true || consent.rejectLabel !== undefined) {
|
|
20
|
+
return [];
|
|
21
|
+
}
|
|
22
|
+
const vendor = consent.cmp === undefined ? '' : ` (${consent.cmp})`;
|
|
23
|
+
const accept = consent.acceptLabel ?? say(lang, { en: 'accept', de: 'Zustimmen' });
|
|
24
|
+
const evidence = [
|
|
25
|
+
say(lang, {
|
|
26
|
+
en: `accept button: ${consent.acceptLabel ?? 'unlabelled'}`,
|
|
27
|
+
de: `Zustimmen-Button: ${consent.acceptLabel ?? 'ohne Beschriftung'}`,
|
|
28
|
+
}),
|
|
29
|
+
];
|
|
30
|
+
if (consent.payOrOk === true) {
|
|
31
|
+
return [
|
|
32
|
+
{
|
|
33
|
+
ruleId: ID,
|
|
34
|
+
severity: 'info',
|
|
35
|
+
message: say(lang, {
|
|
36
|
+
en: `The consent banner${vendor} offers "${accept}" or a subscription instead of a reject button ("pay or OK").`,
|
|
37
|
+
de: `Das Cookie-Banner${vendor} bietet „${accept}“ oder ein Abo statt eines Ablehnen-Buttons (Pur-Abo-Modell).`,
|
|
38
|
+
}),
|
|
39
|
+
pageUrl: page.finalUrl,
|
|
40
|
+
evidence,
|
|
41
|
+
fix: say(lang, {
|
|
42
|
+
en: 'German regulators consider pay-or-OK generally permissible if the paid option is an equivalent, fairly priced alternative (DSK, 22.03.2023). For large platforms the EDPB is much stricter (Opinion 08/2024). privalint cannot judge this; have it assessed legally.',
|
|
43
|
+
de: 'Deutsche Aufsichtsbehörden halten Pur-Abo-Modelle grundsätzlich für zulässig, wenn das Bezahlangebot eine gleichwertige Alternative zu einem angemessenen Preis ist (DSK, 22.03.2023). Für große Plattformen ist der EDSA deutlich strenger (Stellungnahme 08/2024). privalint kann das nicht beurteilen; bitte rechtlich prüfen lassen.',
|
|
44
|
+
}),
|
|
45
|
+
},
|
|
46
|
+
];
|
|
47
|
+
}
|
|
48
|
+
return [
|
|
49
|
+
{
|
|
50
|
+
ruleId: ID,
|
|
51
|
+
severity: 'warning',
|
|
52
|
+
message: say(lang, {
|
|
53
|
+
en: `The consent banner${vendor} offers "${accept}" but no reject button next to it.`,
|
|
54
|
+
de: `Das Cookie-Banner${vendor} bietet „${accept}“, aber keinen Ablehnen-Button daneben.`,
|
|
55
|
+
}),
|
|
56
|
+
pageUrl: page.finalUrl,
|
|
57
|
+
evidence,
|
|
58
|
+
fix: say(lang, {
|
|
59
|
+
en: 'Add a button such as "Alle ablehnen" or "Nur notwendige" on the first layer, with the same size and prominence as the accept button.',
|
|
60
|
+
de: 'Einen Button wie „Alle ablehnen“ oder „Nur notwendige“ auf die erste Ebene setzen, gleich groß und gleich auffällig wie den Zustimmen-Button.',
|
|
61
|
+
}),
|
|
62
|
+
},
|
|
63
|
+
];
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
//# sourceMappingURL=reject-option.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"reject-option.js","sourceRoot":"","sources":["../../src/rules/reject-option.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,GAAG,EAAE,MAAM,YAAY,CAAC;AAGjC,MAAM,EAAE,GAAG,eAAe,CAAC;AAE3B,MAAM,CAAC,MAAM,YAAY,GAAS;IAChC,EAAE,EAAE,EAAE;IACN,KAAK,EAAE,mDAAmD;IAC1D,WAAW,EACT,+FAA+F;QAC/F,0FAA0F;QAC1F,2BAA2B;IAC7B,UAAU,EAAE;QACV,iDAAiD;QACjD,wEAAwE;QACxE,+CAA+C;QAC/C,iBAAiB;QACjB,gBAAgB;QAChB,mBAAmB;KACpB;IACD,KAAK,CAAC,EAAE,IAAI,EAAE,IAAI,EAAE;QAClB,MAAM,OAAO,GAAG,IAAI,CAAC,OAAO,CAAC;QAC7B,IAAI,OAAO,EAAE,WAAW,KAAK,IAAI,IAAI,OAAO,CAAC,WAAW,KAAK,SAAS,EAAE,CAAC;YACvE,OAAO,EAAE,CAAC;QACZ,CAAC;QACD,MAAM,MAAM,GAAG,OAAO,CAAC,GAAG,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,KAAK,OAAO,CAAC,GAAG,GAAG,CAAC;QACpE,MAAM,MAAM,GAAG,OAAO,CAAC,WAAW,IAAI,GAAG,CAAC,IAAI,EAAE,EAAE,EAAE,EAAE,QAAQ,EAAE,EAAE,EAAE,WAAW,EAAE,CAAC,CAAC;QACnF,MAAM,QAAQ,GAAG;YACf,GAAG,CAAC,IAAI,EAAE;gBACR,EAAE,EAAE,kBAAkB,OAAO,CAAC,WAAW,IAAI,YAAY,EAAE;gBAC3D,EAAE,EAAE,qBAAqB,OAAO,CAAC,WAAW,IAAI,mBAAmB,EAAE;aACtE,CAAC;SACH,CAAC;QACF,IAAI,OAAO,CAAC,OAAO,KAAK,IAAI,EAAE,CAAC;YAC7B,OAAO;gBACL;oBACE,MAAM,EAAE,EAAE;oBACV,QAAQ,EAAE,MAAM;oBAChB,OAAO,EAAE,GAAG,CAAC,IAAI,EAAE;wBACjB,EAAE,EAAE,qBAAqB,MAAM,YAAY,MAAM,+DAA+D;wBAChH,EAAE,EAAE,oBAAoB,MAAM,YAAY,MAAM,+DAA+D;qBAChH,CAAC;oBACF,OAAO,EAAE,IAAI,CAAC,QAAQ;oBACtB,QAAQ;oBACR,GAAG,EAAE,GAAG,CAAC,IAAI,EAAE;wBACb,EAAE,EAAE,sQAAsQ;wBAC1Q,EAAE,EAAE,0UAA0U;qBAC/U,CAAC;iBACH;aACF,CAAC;QACJ,CAAC;QACD,OAAO;YACL;gBACE,MAAM,EAAE,EAAE;gBACV,QAAQ,EAAE,SAAS;gBACnB,OAAO,EAAE,GAAG,CAAC,IAAI,EAAE;oBACjB,EAAE,EAAE,qBAAqB,MAAM,YAAY,MAAM,oCAAoC;oBACrF,EAAE,EAAE,oBAAoB,MAAM,YAAY,MAAM,yCAAyC;iBAC1F,CAAC;gBACF,OAAO,EAAE,IAAI,CAAC,QAAQ;gBACtB,QAAQ;gBACR,GAAG,EAAE,GAAG,CAAC,IAAI,EAAE;oBACb,EAAE,EAAE,sIAAsI;oBAC1I,EAAE,EAAE,+IAA+I;iBACpJ,CAAC;aACH;SACF,CAAC;IACJ,CAAC;CACF,CAAC"}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { findCdn } from '../data/third-parties.js';
|
|
2
|
+
import { say } from '../i18n.js';
|
|
3
|
+
import { groupRequestsByVendor } from './third-party-hits.js';
|
|
4
|
+
const ID = 'third-party-cdns';
|
|
5
|
+
export const thirdPartyCdns = {
|
|
6
|
+
id: ID,
|
|
7
|
+
title: 'Scripts or styles loaded from a public CDN',
|
|
8
|
+
description: "Every request to a public CDN sends the visitor's IP address to its operator. The same " +
|
|
9
|
+
'reasoning that made Google Fonts a problem applies, but there is no court ruling on CDNs ' +
|
|
10
|
+
'specifically, so this is only a hint.',
|
|
11
|
+
references: ['Art. 6(1) GDPR', 'LG München I, 20.01.2022, Az. 3 O 17493/20 (by analogy)'],
|
|
12
|
+
check({ page, lang }) {
|
|
13
|
+
return [...groupRequestsByVendor(page.requests, findCdn)].map(([vendor, evidence]) => ({
|
|
14
|
+
ruleId: ID,
|
|
15
|
+
severity: 'info',
|
|
16
|
+
message: say(lang, {
|
|
17
|
+
en: `Files are loaded from ${vendor}.`,
|
|
18
|
+
de: `Dateien werden von ${vendor} geladen.`,
|
|
19
|
+
}),
|
|
20
|
+
pageUrl: page.finalUrl,
|
|
21
|
+
subject: vendor,
|
|
22
|
+
evidence,
|
|
23
|
+
fix: say(lang, {
|
|
24
|
+
en: 'Install the library with your package manager and bundle it, or copy the files to your own server.',
|
|
25
|
+
de: 'Die Bibliothek über den Paketmanager installieren und mitbündeln oder die Dateien auf den eigenen Server kopieren.',
|
|
26
|
+
}),
|
|
27
|
+
}));
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
//# sourceMappingURL=third-party-cdns.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"third-party-cdns.js","sourceRoot":"","sources":["../../src/rules/third-party-cdns.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,0BAA0B,CAAC;AACnD,OAAO,EAAE,GAAG,EAAE,MAAM,YAAY,CAAC;AAEjC,OAAO,EAAE,qBAAqB,EAAE,MAAM,uBAAuB,CAAC;AAE9D,MAAM,EAAE,GAAG,kBAAkB,CAAC;AAE9B,MAAM,CAAC,MAAM,cAAc,GAAS;IAClC,EAAE,EAAE,EAAE;IACN,KAAK,EAAE,4CAA4C;IACnD,WAAW,EACT,yFAAyF;QACzF,2FAA2F;QAC3F,uCAAuC;IACzC,UAAU,EAAE,CAAC,gBAAgB,EAAE,yDAAyD,CAAC;IACzF,KAAK,CAAC,EAAE,IAAI,EAAE,IAAI,EAAE;QAClB,OAAO,CAAC,GAAG,qBAAqB,CAAC,IAAI,CAAC,QAAQ,EAAE,OAAO,CAAC,CAAC,CAAC,GAAG,CAC3D,CAAC,CAAC,MAAM,EAAE,QAAQ,CAAC,EAAW,EAAE,CAAC,CAAC;YAChC,MAAM,EAAE,EAAE;YACV,QAAQ,EAAE,MAAM;YAChB,OAAO,EAAE,GAAG,CAAC,IAAI,EAAE;gBACjB,EAAE,EAAE,yBAAyB,MAAM,GAAG;gBACtC,EAAE,EAAE,sBAAsB,MAAM,WAAW;aAC5C,CAAC;YACF,OAAO,EAAE,IAAI,CAAC,QAAQ;YACtB,OAAO,EAAE,MAAM;YACf,QAAQ;YACR,GAAG,EAAE,GAAG,CAAC,IAAI,EAAE;gBACb,EAAE,EAAE,oGAAoG;gBACxG,EAAE,EAAE,oHAAoH;aACzH,CAAC;SACH,CAAC,CACH,CAAC;IACJ,CAAC;CACF,CAAC"}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { findEmbed } from '../data/third-parties.js';
|
|
2
|
+
import { say } from '../i18n.js';
|
|
3
|
+
import { groupRequestsByVendor } from './third-party-hits.js';
|
|
4
|
+
const ID = 'third-party-embeds';
|
|
5
|
+
export const thirdPartyEmbeds = {
|
|
6
|
+
id: ID,
|
|
7
|
+
title: 'Embedded content loads before consent',
|
|
8
|
+
description: 'Videos, maps, captchas and social embeds contact their provider as soon as the page loads, ' +
|
|
9
|
+
"which sends the visitor's IP address and often sets cookies. Load them only after the " +
|
|
10
|
+
'visitor has agreed, typically with a click-to-load placeholder.',
|
|
11
|
+
references: ['Art. 6(1) GDPR', '§ 25 Abs. 1 TDDDG'],
|
|
12
|
+
check({ page, lang }) {
|
|
13
|
+
return [...groupRequestsByVendor(page.requests, findEmbed)].map(([vendor, evidence]) => ({
|
|
14
|
+
ruleId: ID,
|
|
15
|
+
severity: 'warning',
|
|
16
|
+
message: say(lang, {
|
|
17
|
+
en: `${vendor} loads before the visitor has given consent.`,
|
|
18
|
+
de: `${vendor} wird geladen, bevor eine Einwilligung vorliegt.`,
|
|
19
|
+
}),
|
|
20
|
+
pageUrl: page.finalUrl,
|
|
21
|
+
subject: vendor,
|
|
22
|
+
evidence,
|
|
23
|
+
fix: say(lang, {
|
|
24
|
+
en: `Show a placeholder and load ${vendor} only after a click or consent. youtube-nocookie.com alone is not enough, it still contacts Google on load.`,
|
|
25
|
+
de: `Einen Platzhalter zeigen und ${vendor} erst nach Klick oder Einwilligung laden. youtube-nocookie.com allein reicht nicht, es kontaktiert Google trotzdem beim Laden.`,
|
|
26
|
+
}),
|
|
27
|
+
}));
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
//# sourceMappingURL=third-party-embeds.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"third-party-embeds.js","sourceRoot":"","sources":["../../src/rules/third-party-embeds.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,0BAA0B,CAAC;AACrD,OAAO,EAAE,GAAG,EAAE,MAAM,YAAY,CAAC;AAEjC,OAAO,EAAE,qBAAqB,EAAE,MAAM,uBAAuB,CAAC;AAE9D,MAAM,EAAE,GAAG,oBAAoB,CAAC;AAEhC,MAAM,CAAC,MAAM,gBAAgB,GAAS;IACpC,EAAE,EAAE,EAAE;IACN,KAAK,EAAE,uCAAuC;IAC9C,WAAW,EACT,6FAA6F;QAC7F,wFAAwF;QACxF,iEAAiE;IACnE,UAAU,EAAE,CAAC,gBAAgB,EAAE,mBAAmB,CAAC;IACnD,KAAK,CAAC,EAAE,IAAI,EAAE,IAAI,EAAE;QAClB,OAAO,CAAC,GAAG,qBAAqB,CAAC,IAAI,CAAC,QAAQ,EAAE,SAAS,CAAC,CAAC,CAAC,GAAG,CAC7D,CAAC,CAAC,MAAM,EAAE,QAAQ,CAAC,EAAW,EAAE,CAAC,CAAC;YAChC,MAAM,EAAE,EAAE;YACV,QAAQ,EAAE,SAAS;YACnB,OAAO,EAAE,GAAG,CAAC,IAAI,EAAE;gBACjB,EAAE,EAAE,GAAG,MAAM,8CAA8C;gBAC3D,EAAE,EAAE,GAAG,MAAM,kDAAkD;aAChE,CAAC;YACF,OAAO,EAAE,IAAI,CAAC,QAAQ;YACtB,OAAO,EAAE,MAAM;YACf,QAAQ;YACR,GAAG,EAAE,GAAG,CAAC,IAAI,EAAE;gBACb,EAAE,EAAE,+BAA+B,MAAM,6GAA6G;gBACtJ,EAAE,EAAE,gCAAgC,MAAM,gIAAgI;aAC3K,CAAC;SACH,CAAC,CACH,CAAC;IACJ,CAAC;CACF,CAAC"}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { ThirdParty } from '../data/third-parties.js';
|
|
2
|
+
import type { RecordedRequest } from '../types.js';
|
|
3
|
+
/** Groups requests by the vendor `find` resolves them to. */
|
|
4
|
+
export declare function groupRequestsByVendor(requests: readonly RecordedRequest[], find: (url: string) => ThirdParty | undefined): Map<string, string[]>;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
const MAX_EVIDENCE = 5;
|
|
2
|
+
/** Groups requests by the vendor `find` resolves them to. */
|
|
3
|
+
export function groupRequestsByVendor(requests, find) {
|
|
4
|
+
const byVendor = new Map();
|
|
5
|
+
for (const request of requests) {
|
|
6
|
+
const vendor = find(request.url)?.vendor;
|
|
7
|
+
if (vendor !== undefined) {
|
|
8
|
+
byVendor.set(vendor, (byVendor.get(vendor) ?? new Set()).add(request.url));
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
return new Map([...byVendor].map(([vendor, urls]) => [vendor, [...urls].slice(0, MAX_EVIDENCE)]));
|
|
12
|
+
}
|
|
13
|
+
//# sourceMappingURL=third-party-hits.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"third-party-hits.js","sourceRoot":"","sources":["../../src/rules/third-party-hits.ts"],"names":[],"mappings":"AAGA,MAAM,YAAY,GAAG,CAAC,CAAC;AAEvB,6DAA6D;AAC7D,MAAM,UAAU,qBAAqB,CACnC,QAAoC,EACpC,IAA6C;IAE7C,MAAM,QAAQ,GAAG,IAAI,GAAG,EAAuB,CAAC;IAChD,KAAK,MAAM,OAAO,IAAI,QAAQ,EAAE,CAAC;QAC/B,MAAM,MAAM,GAAG,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC;QACzC,IAAI,MAAM,KAAK,SAAS,EAAE,CAAC;YACzB,QAAQ,CAAC,GAAG,CAAC,MAAM,EAAE,CAAC,QAAQ,CAAC,GAAG,CAAC,MAAM,CAAC,IAAI,IAAI,GAAG,EAAE,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC;QAC7E,CAAC;IACH,CAAC;IACD,OAAO,IAAI,GAAG,CAAC,CAAC,GAAG,QAAQ,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,MAAM,EAAE,IAAI,CAAC,EAAE,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,IAAI,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,YAAY,CAAC,CAAC,CAAC,CAAC,CAAC;AACpG,CAAC"}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type Tracker, type TrackerCategory } from '../data/trackers.js';
|
|
2
|
+
import { type Lang } from '../i18n.js';
|
|
3
|
+
import type { RecordedCookie, RecordedRequest } from '../types.js';
|
|
4
|
+
export interface TrackerHit {
|
|
5
|
+
tracker: Tracker;
|
|
6
|
+
/** Up to five request URLs and cookie names, deduplicated. */
|
|
7
|
+
evidence: string[];
|
|
8
|
+
}
|
|
9
|
+
/** Matches requests and cookies against the tracker list, grouped by vendor. */
|
|
10
|
+
export declare function findTrackerHits(requests: readonly RecordedRequest[], cookies: readonly RecordedCookie[], lang: Lang): TrackerHit[];
|
|
11
|
+
export declare const categoryLabel: (category: TrackerCategory, lang: Lang) => string;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { findTrackerByCookie, findTrackerByUrl, } from '../data/trackers.js';
|
|
2
|
+
import { say } from '../i18n.js';
|
|
3
|
+
const MAX_EVIDENCE = 5;
|
|
4
|
+
/** Matches requests and cookies against the tracker list, grouped by vendor. */
|
|
5
|
+
export function findTrackerHits(requests, cookies, lang) {
|
|
6
|
+
const requestLabel = say(lang, { en: 'request', de: 'Anfrage' });
|
|
7
|
+
const hits = new Map();
|
|
8
|
+
const record = (tracker, evidence) => {
|
|
9
|
+
const hit = hits.get(tracker.vendor) ?? { tracker, evidence: new Set() };
|
|
10
|
+
hit.evidence.add(evidence);
|
|
11
|
+
hits.set(tracker.vendor, hit);
|
|
12
|
+
};
|
|
13
|
+
for (const request of requests) {
|
|
14
|
+
const tracker = findTrackerByUrl(request.url);
|
|
15
|
+
if (tracker !== undefined) {
|
|
16
|
+
record(tracker, `${requestLabel} ${request.url}`);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
for (const cookie of cookies) {
|
|
20
|
+
const tracker = findTrackerByCookie(cookie.name) ??
|
|
21
|
+
findTrackerByUrl(`https://${cookie.domain.replace(/^\./, '')}/`);
|
|
22
|
+
if (tracker !== undefined) {
|
|
23
|
+
record(tracker, `cookie ${cookie.name} (${cookie.domain})`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return [...hits.values()].map(({ tracker, evidence }) => ({
|
|
27
|
+
tracker,
|
|
28
|
+
evidence: [...evidence].slice(0, MAX_EVIDENCE),
|
|
29
|
+
}));
|
|
30
|
+
}
|
|
31
|
+
const CATEGORY_LABELS = {
|
|
32
|
+
analytics: { en: 'analytics', de: 'Analyse' },
|
|
33
|
+
advertising: { en: 'advertising', de: 'Werbung' },
|
|
34
|
+
'session-replay': { en: 'session-replay', de: 'Session-Replay' },
|
|
35
|
+
social: { en: 'social', de: 'Social Media' },
|
|
36
|
+
'marketing-automation': { en: 'marketing-automation', de: 'Marketing-Automatisierung' },
|
|
37
|
+
'tag-manager': { en: 'tag-manager', de: 'Tag-Manager' },
|
|
38
|
+
};
|
|
39
|
+
export const categoryLabel = (category, lang) => say(lang, CATEGORY_LABELS[category]);
|
|
40
|
+
//# sourceMappingURL=tracker-hits.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tracker-hits.js","sourceRoot":"","sources":["../../src/rules/tracker-hits.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,mBAAmB,EACnB,gBAAgB,GAGjB,MAAM,qBAAqB,CAAC;AAC7B,OAAO,EAAa,GAAG,EAAa,MAAM,YAAY,CAAC;AAGvD,MAAM,YAAY,GAAG,CAAC,CAAC;AAQvB,gFAAgF;AAChF,MAAM,UAAU,eAAe,CAC7B,QAAoC,EACpC,OAAkC,EAClC,IAAU;IAEV,MAAM,YAAY,GAAG,GAAG,CAAC,IAAI,EAAE,EAAE,EAAE,EAAE,SAAS,EAAE,EAAE,EAAE,SAAS,EAAE,CAAC,CAAC;IACjE,MAAM,IAAI,GAAG,IAAI,GAAG,EAAuD,CAAC;IAC5E,MAAM,MAAM,GAAG,CAAC,OAAgB,EAAE,QAAgB,EAAE,EAAE;QACpD,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,CAAC,OAAO,CAAC,MAAM,CAAC,IAAI,EAAE,OAAO,EAAE,QAAQ,EAAE,IAAI,GAAG,EAAU,EAAE,CAAC;QACjF,GAAG,CAAC,QAAQ,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC;QAC3B,IAAI,CAAC,GAAG,CAAC,OAAO,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;IAChC,CAAC,CAAC;IAEF,KAAK,MAAM,OAAO,IAAI,QAAQ,EAAE,CAAC;QAC/B,MAAM,OAAO,GAAG,gBAAgB,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC;QAC9C,IAAI,OAAO,KAAK,SAAS,EAAE,CAAC;YAC1B,MAAM,CAAC,OAAO,EAAE,GAAG,YAAY,IAAI,OAAO,CAAC,GAAG,EAAE,CAAC,CAAC;QACpD,CAAC;IACH,CAAC;IACD,KAAK,MAAM,MAAM,IAAI,OAAO,EAAE,CAAC;QAC7B,MAAM,OAAO,GACX,mBAAmB,CAAC,MAAM,CAAC,IAAI,CAAC;YAChC,gBAAgB,CAAC,WAAW,MAAM,CAAC,MAAM,CAAC,OAAO,CAAC,KAAK,EAAE,EAAE,CAAC,GAAG,CAAC,CAAC;QACnE,IAAI,OAAO,KAAK,SAAS,EAAE,CAAC;YAC1B,MAAM,CAAC,OAAO,EAAE,UAAU,MAAM,CAAC,IAAI,KAAK,MAAM,CAAC,MAAM,GAAG,CAAC,CAAC;QAC9D,CAAC;IACH,CAAC;IAED,OAAO,CAAC,GAAG,IAAI,CAAC,MAAM,EAAE,CAAC,CAAC,GAAG,CAAC,CAAC,EAAE,OAAO,EAAE,QAAQ,EAAE,EAAE,EAAE,CAAC,CAAC;QACxD,OAAO;QACP,QAAQ,EAAE,CAAC,GAAG,QAAQ,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,YAAY,CAAC;KAC/C,CAAC,CAAC,CAAC;AACN,CAAC;AAED,MAAM,eAAe,GAAkC;IACrD,SAAS,EAAE,EAAE,EAAE,EAAE,WAAW,EAAE,EAAE,EAAE,SAAS,EAAE;IAC7C,WAAW,EAAE,EAAE,EAAE,EAAE,aAAa,EAAE,EAAE,EAAE,SAAS,EAAE;IACjD,gBAAgB,EAAE,EAAE,EAAE,EAAE,gBAAgB,EAAE,EAAE,EAAE,gBAAgB,EAAE;IAChE,MAAM,EAAE,EAAE,EAAE,EAAE,QAAQ,EAAE,EAAE,EAAE,cAAc,EAAE;IAC5C,sBAAsB,EAAE,EAAE,EAAE,EAAE,sBAAsB,EAAE,EAAE,EAAE,2BAA2B,EAAE;IACvF,aAAa,EAAE,EAAE,EAAE,EAAE,aAAa,EAAE,EAAE,EAAE,aAAa,EAAE;CACxD,CAAC;AAEF,MAAM,CAAC,MAAM,aAAa,GAAG,CAAC,QAAyB,EAAE,IAAU,EAAE,EAAE,CACrE,GAAG,CAAC,IAAI,EAAE,eAAe,CAAC,QAAQ,CAAC,CAAC,CAAC"}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { say } from '../i18n.js';
|
|
2
|
+
import { categoryLabel, findTrackerHits } from './tracker-hits.js';
|
|
3
|
+
const ID = 'trackers-after-reject';
|
|
4
|
+
export const trackersAfterReject = {
|
|
5
|
+
id: ID,
|
|
6
|
+
title: 'Trackers run after the visitor rejected them',
|
|
7
|
+
description: 'privalint clicks the reject button of the consent banner on the start page, reloads the ' +
|
|
8
|
+
'page and records what still loads. Anything on the tracker list that loads or sets new ' +
|
|
9
|
+
'cookies after that ignores the visitor’s explicit refusal.',
|
|
10
|
+
references: ['§ 25 Abs. 1 TDDDG', 'Art. 6(1)(a) GDPR', 'Art. 7(3) GDPR'],
|
|
11
|
+
check({ page, lang }) {
|
|
12
|
+
const after = page.consent?.afterReject;
|
|
13
|
+
if (after === undefined) {
|
|
14
|
+
return [];
|
|
15
|
+
}
|
|
16
|
+
const button = page.consent?.rejectLabel ?? say(lang, { en: 'reject', de: 'Ablehnen' });
|
|
17
|
+
return findTrackerHits(after.requests, after.cookies, lang).map(({ tracker, evidence }) => {
|
|
18
|
+
const { vendor } = tracker;
|
|
19
|
+
const category = categoryLabel(tracker.category, lang);
|
|
20
|
+
return {
|
|
21
|
+
ruleId: ID,
|
|
22
|
+
// Same reasoning as pre-consent-trackers: the container alone may be configured correctly.
|
|
23
|
+
severity: tracker.category === 'tag-manager' ? 'warning' : 'error',
|
|
24
|
+
message: say(lang, {
|
|
25
|
+
en: `${vendor} (${category}) still runs after clicking "${button}".`,
|
|
26
|
+
de: `${vendor} (${category}) läuft weiter, obwohl „${button}“ geklickt wurde.`,
|
|
27
|
+
}),
|
|
28
|
+
pageUrl: page.finalUrl,
|
|
29
|
+
subject: vendor,
|
|
30
|
+
evidence,
|
|
31
|
+
fix: say(lang, {
|
|
32
|
+
en: `Check that ${vendor} is assigned to a consent category in your consent manager and that the category is blocked when the visitor rejects.`,
|
|
33
|
+
de: `Prüfen, ob ${vendor} im Consent-Tool einer Kategorie zugeordnet ist und diese Kategorie beim Ablehnen blockiert wird.`,
|
|
34
|
+
}),
|
|
35
|
+
};
|
|
36
|
+
});
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
//# sourceMappingURL=trackers-after-reject.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"trackers-after-reject.js","sourceRoot":"","sources":["../../src/rules/trackers-after-reject.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,GAAG,EAAE,MAAM,YAAY,CAAC;AAEjC,OAAO,EAAE,aAAa,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC;AAEnE,MAAM,EAAE,GAAG,uBAAuB,CAAC;AAEnC,MAAM,CAAC,MAAM,mBAAmB,GAAS;IACvC,EAAE,EAAE,EAAE;IACN,KAAK,EAAE,8CAA8C;IACrD,WAAW,EACT,0FAA0F;QAC1F,yFAAyF;QACzF,4DAA4D;IAC9D,UAAU,EAAE,CAAC,mBAAmB,EAAE,mBAAmB,EAAE,gBAAgB,CAAC;IACxE,KAAK,CAAC,EAAE,IAAI,EAAE,IAAI,EAAE;QAClB,MAAM,KAAK,GAAG,IAAI,CAAC,OAAO,EAAE,WAAW,CAAC;QACxC,IAAI,KAAK,KAAK,SAAS,EAAE,CAAC;YACxB,OAAO,EAAE,CAAC;QACZ,CAAC;QACD,MAAM,MAAM,GAAG,IAAI,CAAC,OAAO,EAAE,WAAW,IAAI,GAAG,CAAC,IAAI,EAAE,EAAE,EAAE,EAAE,QAAQ,EAAE,EAAE,EAAE,UAAU,EAAE,CAAC,CAAC;QACxF,OAAO,eAAe,CAAC,KAAK,CAAC,QAAQ,EAAE,KAAK,CAAC,OAAO,EAAE,IAAI,CAAC,CAAC,GAAG,CAC7D,CAAC,EAAE,OAAO,EAAE,QAAQ,EAAE,EAAW,EAAE;YACjC,MAAM,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC;YAC3B,MAAM,QAAQ,GAAG,aAAa,CAAC,OAAO,CAAC,QAAQ,EAAE,IAAI,CAAC,CAAC;YACvD,OAAO;gBACL,MAAM,EAAE,EAAE;gBACV,2FAA2F;gBAC3F,QAAQ,EAAE,OAAO,CAAC,QAAQ,KAAK,aAAa,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,OAAO;gBAClE,OAAO,EAAE,GAAG,CAAC,IAAI,EAAE;oBACjB,EAAE,EAAE,GAAG,MAAM,KAAK,QAAQ,gCAAgC,MAAM,IAAI;oBACpE,EAAE,EAAE,GAAG,MAAM,KAAK,QAAQ,2BAA2B,MAAM,mBAAmB;iBAC/E,CAAC;gBACF,OAAO,EAAE,IAAI,CAAC,QAAQ;gBACtB,OAAO,EAAE,MAAM;gBACf,QAAQ;gBACR,GAAG,EAAE,GAAG,CAAC,IAAI,EAAE;oBACb,EAAE,EAAE,cAAc,MAAM,uHAAuH;oBAC/I,EAAE,EAAE,cAAc,MAAM,mGAAmG;iBAC5H,CAAC;aACH,CAAC;QACJ,CAAC,CACF,CAAC;IACJ,CAAC;CACF,CAAC"}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { Lang } from './i18n.js';
|
|
2
|
+
import { type Region } from './legal.js';
|
|
3
|
+
import type { PageSnapshot, Rule, ScanResult } from './types.js';
|
|
4
|
+
export interface ScanOptions {
|
|
5
|
+
/** Maximum number of pages on the same site to visit, starting with the given URL. */
|
|
6
|
+
maxPages?: number | undefined;
|
|
7
|
+
/** Extra time to wait after the network is idle, to catch delayed tags. */
|
|
8
|
+
settleMs?: number | undefined;
|
|
9
|
+
/** Navigation timeout per page. */
|
|
10
|
+
timeoutMs?: number | undefined;
|
|
11
|
+
locale?: string | undefined;
|
|
12
|
+
/** Language of messages and fixes in the findings. */
|
|
13
|
+
lang?: Lang | undefined;
|
|
14
|
+
/** Country whose law the findings are judged by. */
|
|
15
|
+
region?: Region | undefined;
|
|
16
|
+
rules?: readonly Rule[];
|
|
17
|
+
/**
|
|
18
|
+
* Answer every third-party request with an empty response instead of fetching it.
|
|
19
|
+
* Requests are still recorded. Used by the test suite to stay off the network.
|
|
20
|
+
*/
|
|
21
|
+
offline?: boolean;
|
|
22
|
+
/**
|
|
23
|
+
* Click "reject" on the start page's consent banner and record what still loads.
|
|
24
|
+
* On by default.
|
|
25
|
+
*/
|
|
26
|
+
consentCheck?: boolean | undefined;
|
|
27
|
+
/**
|
|
28
|
+
* Open a visible browser window instead of running headless. Some sites only show their
|
|
29
|
+
* consent banner to regular browsers. Needs a display, so it doesn't work on CI runners.
|
|
30
|
+
*/
|
|
31
|
+
headed?: boolean | undefined;
|
|
32
|
+
/**
|
|
33
|
+
* Use an installed browser instead of Playwright's bundled Chromium,
|
|
34
|
+
* e.g. `chrome` or `msedge`.
|
|
35
|
+
*/
|
|
36
|
+
channel?: string | undefined;
|
|
37
|
+
onPage?: (snapshot: PageSnapshot) => void;
|
|
38
|
+
}
|
|
39
|
+
export declare function scan(target: string, options?: ScanOptions): Promise<ScanResult>;
|
package/dist/scanner.js
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { chromium } from 'playwright';
|
|
2
|
+
import { observeConsent } from './consent.js';
|
|
3
|
+
import { applyRegion } from './legal.js';
|
|
4
|
+
import { defaultRules } from './rules/index.js';
|
|
5
|
+
import { crawlTarget, isFirstParty, withoutHash } from './url.js';
|
|
6
|
+
const DEFAULTS = {
|
|
7
|
+
maxPages: 1,
|
|
8
|
+
settleMs: 2_000,
|
|
9
|
+
timeoutMs: 30_000,
|
|
10
|
+
locale: 'de-DE',
|
|
11
|
+
lang: 'en',
|
|
12
|
+
region: 'de',
|
|
13
|
+
};
|
|
14
|
+
export async function scan(target, options = {}) {
|
|
15
|
+
const startedAt = Date.now();
|
|
16
|
+
const rules = options.rules ?? defaultRules;
|
|
17
|
+
const maxPages = Math.max(1, options.maxPages ?? DEFAULTS.maxPages);
|
|
18
|
+
const lang = options.lang ?? DEFAULTS.lang;
|
|
19
|
+
const region = options.region ?? DEFAULTS.region;
|
|
20
|
+
let siteUrl = new URL(target);
|
|
21
|
+
const browser = await chromium.launch({
|
|
22
|
+
headless: options.headed !== true,
|
|
23
|
+
...(options.channel === undefined ? {} : { channel: options.channel }),
|
|
24
|
+
});
|
|
25
|
+
try {
|
|
26
|
+
const snapshots = [];
|
|
27
|
+
const queue = [siteUrl.href];
|
|
28
|
+
const seen = new Set(queue);
|
|
29
|
+
const captured = new Set();
|
|
30
|
+
let url = queue.shift();
|
|
31
|
+
while (url !== undefined && snapshots.length < maxPages) {
|
|
32
|
+
const isStart = snapshots.length === 0;
|
|
33
|
+
const snapshot = await capturePage(browser, url, siteUrl, options, isStart);
|
|
34
|
+
if (isStart) {
|
|
35
|
+
if (snapshot.error !== undefined) {
|
|
36
|
+
throw new Error(`Could not load ${url}: ${snapshot.error}`);
|
|
37
|
+
}
|
|
38
|
+
// Follow redirects like http -> https or apex -> www for the rest of the crawl.
|
|
39
|
+
siteUrl = new URL(snapshot.finalUrl);
|
|
40
|
+
}
|
|
41
|
+
// Several links can redirect to the same page; only lint it once.
|
|
42
|
+
const finalUrl = withoutHash(snapshot.finalUrl);
|
|
43
|
+
if (!captured.has(finalUrl)) {
|
|
44
|
+
captured.add(finalUrl);
|
|
45
|
+
seen.add(finalUrl);
|
|
46
|
+
snapshots.push(snapshot);
|
|
47
|
+
options.onPage?.(snapshot);
|
|
48
|
+
for (const link of snapshot.links) {
|
|
49
|
+
const next = crawlTarget(link.href, siteUrl);
|
|
50
|
+
if (next !== undefined && !seen.has(next)) {
|
|
51
|
+
seen.add(next);
|
|
52
|
+
queue.push(next);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
url = queue.shift();
|
|
57
|
+
}
|
|
58
|
+
// Pages that failed, returned an error status or redirected off-site are listed but not linted.
|
|
59
|
+
const observed = snapshots
|
|
60
|
+
.filter((page) => page.error === undefined &&
|
|
61
|
+
(page.status === null || page.status < 400) &&
|
|
62
|
+
isFirstParty(page.finalUrl, siteUrl))
|
|
63
|
+
.flatMap((page) => rules.flatMap((rule) => rule.check({ siteUrl, page, lang })));
|
|
64
|
+
const findings = applyRegion(observed, region);
|
|
65
|
+
return {
|
|
66
|
+
siteUrl: siteUrl.href,
|
|
67
|
+
lang,
|
|
68
|
+
region,
|
|
69
|
+
scannedAt: new Date(startedAt).toISOString(),
|
|
70
|
+
durationMs: Date.now() - startedAt,
|
|
71
|
+
pages: snapshots.map((page) => ({
|
|
72
|
+
url: page.url,
|
|
73
|
+
finalUrl: page.finalUrl,
|
|
74
|
+
status: page.status,
|
|
75
|
+
requestCount: page.requests.length,
|
|
76
|
+
cookieCount: page.cookies.length,
|
|
77
|
+
...(page.error === undefined ? {} : { error: page.error }),
|
|
78
|
+
})),
|
|
79
|
+
...consentSummary(snapshots[0]),
|
|
80
|
+
findings,
|
|
81
|
+
rules: rules.map(({ check: _check, ...meta }) => meta),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
finally {
|
|
85
|
+
await browser.close();
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
function consentSummary(start) {
|
|
89
|
+
if (start?.consent === undefined) {
|
|
90
|
+
return {};
|
|
91
|
+
}
|
|
92
|
+
const { afterReject: _afterReject, ...summary } = start.consent;
|
|
93
|
+
return { consent: summary };
|
|
94
|
+
}
|
|
95
|
+
async function capturePage(browser, url, siteUrl, options, checkConsent) {
|
|
96
|
+
// A fresh context per page means no cookies or storage carry over:
|
|
97
|
+
// every page is seen the way a first-time visitor sees it.
|
|
98
|
+
const context = await browser.newContext({ locale: options.locale ?? DEFAULTS.locale });
|
|
99
|
+
const requests = [];
|
|
100
|
+
try {
|
|
101
|
+
if (options.offline) {
|
|
102
|
+
await context.route('**/*', (route) => isFirstParty(route.request().url(), siteUrl)
|
|
103
|
+
? route.continue()
|
|
104
|
+
: route.fulfill({ status: 200, body: '' }));
|
|
105
|
+
}
|
|
106
|
+
const page = await context.newPage();
|
|
107
|
+
page.on('request', (request) => {
|
|
108
|
+
requests.push({ url: request.url(), resourceType: request.resourceType() });
|
|
109
|
+
});
|
|
110
|
+
const response = await page.goto(url, {
|
|
111
|
+
waitUntil: 'load',
|
|
112
|
+
timeout: options.timeoutMs ?? DEFAULTS.timeoutMs,
|
|
113
|
+
});
|
|
114
|
+
await page
|
|
115
|
+
.waitForLoadState('networkidle', { timeout: options.timeoutMs ?? DEFAULTS.timeoutMs })
|
|
116
|
+
.catch(() => {
|
|
117
|
+
// Long-polling or streaming pages never go idle; carry on with what we have.
|
|
118
|
+
});
|
|
119
|
+
await page.waitForTimeout(options.settleMs ?? DEFAULTS.settleMs);
|
|
120
|
+
const links = await page.$$eval('a[href]', (anchors) => anchors.map((a) => ({
|
|
121
|
+
href: a.href,
|
|
122
|
+
text: [a.textContent, a.getAttribute('aria-label'), a.getAttribute('title')]
|
|
123
|
+
.filter(Boolean)
|
|
124
|
+
.join(' ')
|
|
125
|
+
.replace(/\s+/g, ' ')
|
|
126
|
+
.trim()
|
|
127
|
+
.slice(0, 200),
|
|
128
|
+
})));
|
|
129
|
+
const resourceHints = await page.$$eval('link[rel][href]', (elements) => elements.map((link) => ({
|
|
130
|
+
rel: (link.getAttribute('rel') ?? '').toLowerCase(),
|
|
131
|
+
href: link.href,
|
|
132
|
+
})));
|
|
133
|
+
const cookies = (await context.cookies()).map(({ name, domain, path, expires }) => ({
|
|
134
|
+
name,
|
|
135
|
+
domain,
|
|
136
|
+
path,
|
|
137
|
+
expires,
|
|
138
|
+
}));
|
|
139
|
+
const snapshot = {
|
|
140
|
+
url,
|
|
141
|
+
finalUrl: page.url(),
|
|
142
|
+
status: response?.status() ?? null,
|
|
143
|
+
// Copy: the listener keeps writing to `requests` during the consent check.
|
|
144
|
+
requests: [...requests],
|
|
145
|
+
cookies,
|
|
146
|
+
links,
|
|
147
|
+
resourceHints,
|
|
148
|
+
};
|
|
149
|
+
if (checkConsent && options.consentCheck !== false) {
|
|
150
|
+
snapshot.consent = await observeConsent(page, context, requests, {
|
|
151
|
+
settleMs: options.settleMs ?? DEFAULTS.settleMs,
|
|
152
|
+
timeoutMs: options.timeoutMs ?? DEFAULTS.timeoutMs,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
return snapshot;
|
|
156
|
+
}
|
|
157
|
+
catch (error) {
|
|
158
|
+
// Downloads, timeouts and crashed pages should not end the whole crawl.
|
|
159
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
160
|
+
return {
|
|
161
|
+
url,
|
|
162
|
+
finalUrl: url,
|
|
163
|
+
status: null,
|
|
164
|
+
requests,
|
|
165
|
+
cookies: [],
|
|
166
|
+
links: [],
|
|
167
|
+
resourceHints: [],
|
|
168
|
+
error: message.split('\n')[0] ?? message,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
finally {
|
|
172
|
+
await context.close();
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
//# sourceMappingURL=scanner.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"scanner.js","sourceRoot":"","sources":["../src/scanner.ts"],"names":[],"mappings":"AAAA,OAAO,EAAgB,QAAQ,EAAE,MAAM,YAAY,CAAC;AACpD,OAAO,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AAE9C,OAAO,EAAE,WAAW,EAAe,MAAM,YAAY,CAAC;AACtD,OAAO,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAC;AAEhD,OAAO,EAAE,WAAW,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,UAAU,CAAC;AAsClE,MAAM,QAAQ,GAAG;IACf,QAAQ,EAAE,CAAC;IACX,QAAQ,EAAE,KAAK;IACf,SAAS,EAAE,MAAM;IACjB,MAAM,EAAE,OAAO;IACf,IAAI,EAAE,IAAI;IACV,MAAM,EAAE,IAAI;CACS,CAAC;AAExB,MAAM,CAAC,KAAK,UAAU,IAAI,CAAC,MAAc,EAAE,OAAO,GAAgB,EAAE;IAClE,MAAM,SAAS,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;IAC7B,MAAM,KAAK,GAAG,OAAO,CAAC,KAAK,IAAI,YAAY,CAAC;IAC5C,MAAM,QAAQ,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,OAAO,CAAC,QAAQ,IAAI,QAAQ,CAAC,QAAQ,CAAC,CAAC;IACpE,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,IAAI,QAAQ,CAAC,IAAI,CAAC;IAC3C,MAAM,MAAM,GAAG,OAAO,CAAC,MAAM,IAAI,QAAQ,CAAC,MAAM,CAAC;IACjD,IAAI,OAAO,GAAG,IAAI,GAAG,CAAC,MAAM,CAAC,CAAC;IAE9B,MAAM,OAAO,GAAG,MAAM,QAAQ,CAAC,MAAM,CAAC;QACpC,QAAQ,EAAE,OAAO,CAAC,MAAM,KAAK,IAAI;QACjC,GAAG,CAAC,OAAO,CAAC,OAAO,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,OAAO,EAAE,OAAO,CAAC,OAAO,EAAE,CAAC;KACvE,CAAC,CAAC;IACH,IAAI,CAAC;QACH,MAAM,SAAS,GAAmB,EAAE,CAAC;QACrC,MAAM,KAAK,GAAG,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC;QAC7B,MAAM,IAAI,GAAG,IAAI,GAAG,CAAC,KAAK,CAAC,CAAC;QAC5B,MAAM,QAAQ,GAAG,IAAI,GAAG,EAAU,CAAC;QAEnC,IAAI,GAAG,GAAG,KAAK,CAAC,KAAK,EAAE,CAAC;QACxB,OAAO,GAAG,KAAK,SAAS,IAAI,SAAS,CAAC,MAAM,GAAG,QAAQ,EAAE,CAAC;YACxD,MAAM,OAAO,GAAG,SAAS,CAAC,MAAM,KAAK,CAAC,CAAC;YACvC,MAAM,QAAQ,GAAG,MAAM,WAAW,CAAC,OAAO,EAAE,GAAG,EAAE,OAAO,EAAE,OAAO,EAAE,OAAO,CAAC,CAAC;YAC5E,IAAI,OAAO,EAAE,CAAC;gBACZ,IAAI,QAAQ,CAAC,KAAK,KAAK,SAAS,EAAE,CAAC;oBACjC,MAAM,IAAI,KAAK,CAAC,kBAAkB,GAAG,KAAK,QAAQ,CAAC,KAAK,EAAE,CAAC,CAAC;gBAC9D,CAAC;gBACD,gFAAgF;gBAChF,OAAO,GAAG,IAAI,GAAG,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC;YACvC,CAAC;YAED,kEAAkE;YAClE,MAAM,QAAQ,GAAG,WAAW,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC;YAChD,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,QAAQ,CAAC,EAAE,CAAC;gBAC5B,QAAQ,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC;gBACvB,IAAI,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC;gBACnB,SAAS,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;gBACzB,OAAO,CAAC,MAAM,EAAE,CAAC,QAAQ,CAAC,CAAC;gBAC3B,KAAK,MAAM,IAAI,IAAI,QAAQ,CAAC,KAAK,EAAE,CAAC;oBAClC,MAAM,IAAI,GAAG,WAAW,CAAC,IAAI,CAAC,IAAI,EAAE,OAAO,CAAC,CAAC;oBAC7C,IAAI,IAAI,KAAK,SAAS,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,EAAE,CAAC;wBAC1C,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;wBACf,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;oBACnB,CAAC;gBACH,CAAC;YACH,CAAC;YACD,GAAG,GAAG,KAAK,CAAC,KAAK,EAAE,CAAC;QACtB,CAAC;QAED,gGAAgG;QAChG,MAAM,QAAQ,GAAc,SAAS;aAClC,MAAM,CACL,CAAC,IAAI,EAAE,EAAE,CACP,IAAI,CAAC,KAAK,KAAK,SAAS;YACxB,CAAC,IAAI,CAAC,MAAM,KAAK,IAAI,IAAI,IAAI,CAAC,MAAM,GAAG,GAAG,CAAC;YAC3C,YAAY,CAAC,IAAI,CAAC,QAAQ,EAAE,OAAO,CAAC,CACvC;aACA,OAAO,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,KAAK,CAAC,EAAE,OAAO,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC,CAAC;QACnF,MAAM,QAAQ,GAAG,WAAW,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC;QAE/C,OAAO;YACL,OAAO,EAAE,OAAO,CAAC,IAAI;YACrB,IAAI;YACJ,MAAM;YACN,SAAS,EAAE,IAAI,IAAI,CAAC,SAAS,CAAC,CAAC,WAAW,EAAE;YAC5C,UAAU,EAAE,IAAI,CAAC,GAAG,EAAE,GAAG,SAAS;YAClC,KAAK,EAAE,SAAS,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC;gBAC9B,GAAG,EAAE,IAAI,CAAC,GAAG;gBACb,QAAQ,EAAE,IAAI,CAAC,QAAQ;gBACvB,MAAM,EAAE,IAAI,CAAC,MAAM;gBACnB,YAAY,EAAE,IAAI,CAAC,QAAQ,CAAC,MAAM;gBAClC,WAAW,EAAE,IAAI,CAAC,OAAO,CAAC,MAAM;gBAChC,GAAG,CAAC,IAAI,CAAC,KAAK,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,IAAI,CAAC,KAAK,EAAE,CAAC;aAC3D,CAAC,CAAC;YACH,GAAG,cAAc,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC;YAC/B,QAAQ;YACR,KAAK,EAAE,KAAK,CAAC,GAAG,CAAC,CAAC,EAAE,KAAK,EAAE,MAAM,EAAE,GAAG,IAAI,EAAE,EAAE,EAAE,CAAC,IAAI,CAAC;SACvD,CAAC;IACJ,CAAC;YAAS,CAAC;QACT,MAAM,OAAO,CAAC,KAAK,EAAE,CAAC;IACxB,CAAC;AACH,CAAC;AAED,SAAS,cAAc,CAAC,KAA+B;IACrD,IAAI,KAAK,EAAE,OAAO,KAAK,SAAS,EAAE,CAAC;QACjC,OAAO,EAAE,CAAC;IACZ,CAAC;IACD,MAAM,EAAE,WAAW,EAAE,YAAY,EAAE,GAAG,OAAO,EAAE,GAAG,KAAK,CAAC,OAAO,CAAC;IAChE,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,CAAC;AAC9B,CAAC;AAED,KAAK,UAAU,WAAW,CACxB,OAAgB,EAChB,GAAW,EACX,OAAY,EACZ,OAAoB,EACpB,YAAqB;IAErB,mEAAmE;IACnE,2DAA2D;IAC3D,MAAM,OAAO,GAAG,MAAM,OAAO,CAAC,UAAU,CAAC,EAAE,MAAM,EAAE,OAAO,CAAC,MAAM,IAAI,QAAQ,CAAC,MAAM,EAAE,CAAC,CAAC;IACxF,MAAM,QAAQ,GAA6B,EAAE,CAAC;IAC9C,IAAI,CAAC;QACH,IAAI,OAAO,CAAC,OAAO,EAAE,CAAC;YACpB,MAAM,OAAO,CAAC,KAAK,CAAC,MAAM,EAAE,CAAC,KAAK,EAAE,EAAE,CACpC,YAAY,CAAC,KAAK,CAAC,OAAO,EAAE,CAAC,GAAG,EAAE,EAAE,OAAO,CAAC;gBAC1C,CAAC,CAAC,KAAK,CAAC,QAAQ,EAAE;gBAClB,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,EAAE,MAAM,EAAE,GAAG,EAAE,IAAI,EAAE,EAAE,EAAE,CAAC,CAC7C,CAAC;QACJ,CAAC;QAED,MAAM,IAAI,GAAG,MAAM,OAAO,CAAC,OAAO,EAAE,CAAC;QACrC,IAAI,CAAC,EAAE,CAAC,SAAS,EAAE,CAAC,OAAO,EAAE,EAAE;YAC7B,QAAQ,CAAC,IAAI,CAAC,EAAE,GAAG,EAAE,OAAO,CAAC,GAAG,EAAE,EAAE,YAAY,EAAE,OAAO,CAAC,YAAY,EAAE,EAAE,CAAC,CAAC;QAC9E,CAAC,CAAC,CAAC;QAEH,MAAM,QAAQ,GAAG,MAAM,IAAI,CAAC,IAAI,CAAC,GAAG,EAAE;YACpC,SAAS,EAAE,MAAM;YACjB,OAAO,EAAE,OAAO,CAAC,SAAS,IAAI,QAAQ,CAAC,SAAS;SACjD,CAAC,CAAC;QACH,MAAM,IAAI;aACP,gBAAgB,CAAC,aAAa,EAAE,EAAE,OAAO,EAAE,OAAO,CAAC,SAAS,IAAI,QAAQ,CAAC,SAAS,EAAE,CAAC;aACrF,KAAK,CAAC,GAAG,EAAE;YACV,6EAA6E;QAC/E,CAAC,CAAC,CAAC;QACL,MAAM,IAAI,CAAC,cAAc,CAAC,OAAO,CAAC,QAAQ,IAAI,QAAQ,CAAC,QAAQ,CAAC,CAAC;QAEjE,MAAM,KAAK,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,SAAS,EAAE,CAAC,OAAO,EAAE,EAAE,CACrD,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;YAClB,IAAI,EAAG,CAAuB,CAAC,IAAI;YACnC,IAAI,EAAE,CAAC,CAAC,CAAC,WAAW,EAAE,CAAC,CAAC,YAAY,CAAC,YAAY,CAAC,EAAE,CAAC,CAAC,YAAY,CAAC,OAAO,CAAC,CAAC;iBACzE,MAAM,CAAC,OAAO,CAAC;iBACf,IAAI,CAAC,GAAG,CAAC;iBACT,OAAO,CAAC,MAAM,EAAE,GAAG,CAAC;iBACpB,IAAI,EAAE;iBACN,KAAK,CAAC,CAAC,EAAE,GAAG,CAAC;SACjB,CAAC,CAAC,CACJ,CAAC;QACF,MAAM,aAAa,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,iBAAiB,EAAE,CAAC,QAAQ,EAAE,EAAE,CACtE,QAAQ,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC;YACtB,GAAG,EAAE,CAAC,IAAI,CAAC,YAAY,CAAC,KAAK,CAAC,IAAI,EAAE,CAAC,CAAC,WAAW,EAAE;YACnD,IAAI,EAAG,IAAwB,CAAC,IAAI;SACrC,CAAC,CAAC,CACJ,CAAC;QACF,MAAM,OAAO,GAAG,CAAC,MAAM,OAAO,CAAC,OAAO,EAAE,CAAC,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,OAAO,EAAE,EAAE,EAAE,CAAC,CAAC;YAClF,IAAI;YACJ,MAAM;YACN,IAAI;YACJ,OAAO;SACR,CAAC,CAAC,CAAC;QAEJ,MAAM,QAAQ,GAAiB;YAC7B,GAAG;YACH,QAAQ,EAAE,IAAI,CAAC,GAAG,EAAE;YACpB,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,IAAI,IAAI;YAClC,2EAA2E;YAC3E,QAAQ,EAAE,CAAC,GAAG,QAAQ,CAAC;YACvB,OAAO;YACP,KAAK;YACL,aAAa;SACd,CAAC;QACF,IAAI,YAAY,IAAI,OAAO,CAAC,YAAY,KAAK,KAAK,EAAE,CAAC;YACnD,QAAQ,CAAC,OAAO,GAAG,MAAM,cAAc,CAAC,IAAI,EAAE,OAAO,EAAE,QAAQ,EAAE;gBAC/D,QAAQ,EAAE,OAAO,CAAC,QAAQ,IAAI,QAAQ,CAAC,QAAQ;gBAC/C,SAAS,EAAE,OAAO,CAAC,SAAS,IAAI,QAAQ,CAAC,SAAS;aACnD,CAAC,CAAC;QACL,CAAC;QACD,OAAO,QAAQ,CAAC;IAClB,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,wEAAwE;QACxE,MAAM,OAAO,GAAG,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;QACvE,OAAO;YACL,GAAG;YACH,QAAQ,EAAE,GAAG;YACb,MAAM,EAAE,IAAI;YACZ,QAAQ;YACR,OAAO,EAAE,EAAE;YACX,KAAK,EAAE,EAAE;YACT,aAAa,EAAE,EAAE;YACjB,KAAK,EAAE,OAAO,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,IAAI,OAAO;SACzC,CAAC;IACJ,CAAC;YAAS,CAAC;QACT,MAAM,OAAO,CAAC,KAAK,EAAE,CAAC;IACxB,CAAC;AACH,CAAC"}
|