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
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import type { Lang } from './i18n.js';
|
|
2
|
+
import type { Region } from './legal.js';
|
|
3
|
+
export type Severity = 'error' | 'warning' | 'info';
|
|
4
|
+
export interface RecordedRequest {
|
|
5
|
+
url: string;
|
|
6
|
+
resourceType: string;
|
|
7
|
+
}
|
|
8
|
+
export interface RecordedCookie {
|
|
9
|
+
name: string;
|
|
10
|
+
domain: string;
|
|
11
|
+
path: string;
|
|
12
|
+
/** Unix time in seconds, -1 for session cookies. */
|
|
13
|
+
expires: number;
|
|
14
|
+
}
|
|
15
|
+
export interface PageLink {
|
|
16
|
+
href: string;
|
|
17
|
+
text: string;
|
|
18
|
+
}
|
|
19
|
+
/** A `<link>` element such as preconnect, dns-prefetch or preload. */
|
|
20
|
+
export interface ResourceHint {
|
|
21
|
+
rel: string;
|
|
22
|
+
href: string;
|
|
23
|
+
}
|
|
24
|
+
/** What happened when privalint looked for a consent banner and clicked "reject". */
|
|
25
|
+
export interface ConsentObservation {
|
|
26
|
+
bannerFound: boolean;
|
|
27
|
+
/** Name of the consent manager, if it was recognised. */
|
|
28
|
+
cmp?: string;
|
|
29
|
+
acceptLabel?: string;
|
|
30
|
+
rejectLabel?: string;
|
|
31
|
+
/** The banner offers "accept" or "subscribe" instead of "reject". */
|
|
32
|
+
payOrOk?: boolean;
|
|
33
|
+
/** True once the reject button was clicked and the page reloaded. */
|
|
34
|
+
rejected: boolean;
|
|
35
|
+
/** Requests made and cookies newly set after rejecting, including the reload. */
|
|
36
|
+
afterReject?: {
|
|
37
|
+
requests: RecordedRequest[];
|
|
38
|
+
cookies: RecordedCookie[];
|
|
39
|
+
};
|
|
40
|
+
error?: string;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Everything observed while loading one page in a fresh browser context,
|
|
44
|
+
* without touching any consent banner.
|
|
45
|
+
*/
|
|
46
|
+
export interface PageSnapshot {
|
|
47
|
+
url: string;
|
|
48
|
+
finalUrl: string;
|
|
49
|
+
status: number | null;
|
|
50
|
+
requests: RecordedRequest[];
|
|
51
|
+
cookies: RecordedCookie[];
|
|
52
|
+
links: PageLink[];
|
|
53
|
+
resourceHints: ResourceHint[];
|
|
54
|
+
/** Only present on the start page, unless the consent check was skipped. */
|
|
55
|
+
consent?: ConsentObservation;
|
|
56
|
+
/** Set when the page could not be loaded, e.g. a download or a timeout. */
|
|
57
|
+
error?: string;
|
|
58
|
+
}
|
|
59
|
+
export interface Finding {
|
|
60
|
+
ruleId: string;
|
|
61
|
+
severity: Severity;
|
|
62
|
+
message: string;
|
|
63
|
+
pageUrl: string;
|
|
64
|
+
evidence: string[];
|
|
65
|
+
fix?: string;
|
|
66
|
+
/** What the finding is about, e.g. a vendor. Keeps fingerprints stable across runs. */
|
|
67
|
+
subject?: string;
|
|
68
|
+
/** Legal basis in the country the scan was run for. */
|
|
69
|
+
references?: string[];
|
|
70
|
+
}
|
|
71
|
+
export interface RuleContext {
|
|
72
|
+
siteUrl: URL;
|
|
73
|
+
page: PageSnapshot;
|
|
74
|
+
/** Language for messages and fixes. */
|
|
75
|
+
lang: Lang;
|
|
76
|
+
}
|
|
77
|
+
export interface Rule {
|
|
78
|
+
id: string;
|
|
79
|
+
title: string;
|
|
80
|
+
description: string;
|
|
81
|
+
/** Laws, rulings or guidelines the rule is based on. */
|
|
82
|
+
references: string[];
|
|
83
|
+
check(context: RuleContext): Finding[];
|
|
84
|
+
}
|
|
85
|
+
export interface PageSummary {
|
|
86
|
+
url: string;
|
|
87
|
+
finalUrl: string;
|
|
88
|
+
status: number | null;
|
|
89
|
+
requestCount: number;
|
|
90
|
+
cookieCount: number;
|
|
91
|
+
error?: string;
|
|
92
|
+
}
|
|
93
|
+
export type ConsentSummary = Omit<ConsentObservation, 'afterReject'>;
|
|
94
|
+
export interface ScanResult {
|
|
95
|
+
siteUrl: string;
|
|
96
|
+
lang: Lang;
|
|
97
|
+
region: Region;
|
|
98
|
+
scannedAt: string;
|
|
99
|
+
durationMs: number;
|
|
100
|
+
pages: PageSummary[];
|
|
101
|
+
/** Outcome of the consent check on the start page. */
|
|
102
|
+
consent?: ConsentSummary;
|
|
103
|
+
findings: Finding[];
|
|
104
|
+
rules: Array<Omit<Rule, 'check'>>;
|
|
105
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":""}
|
package/dist/url.d.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export declare function hostMatches(host: string, domain: string): boolean;
|
|
2
|
+
/**
|
|
3
|
+
* Treats `example.com`, `www.example.com` and `cdn.example.com` as the same site.
|
|
4
|
+
* This is a simplification: it does not consult the public suffix list, so
|
|
5
|
+
* sibling subdomains on shared hosts (e.g. `*.github.io`) count as first party.
|
|
6
|
+
*/
|
|
7
|
+
export declare function isFirstParty(url: string, siteUrl: URL): boolean;
|
|
8
|
+
/** Anything listed by host, optionally narrowed to some paths. */
|
|
9
|
+
export interface HostPattern {
|
|
10
|
+
/** Hostnames; subdomains match too. */
|
|
11
|
+
domains: string[];
|
|
12
|
+
/**
|
|
13
|
+
* If set, only these paths count. A path ending in `/` matches everything below it,
|
|
14
|
+
* otherwise it must match exactly or be followed by `/`.
|
|
15
|
+
*/
|
|
16
|
+
paths?: string[];
|
|
17
|
+
}
|
|
18
|
+
/** Returns the first entry whose hosts and paths match the URL. */
|
|
19
|
+
export declare function findByUrl<T extends HostPattern>(entries: readonly T[], url: string): T | undefined;
|
|
20
|
+
export declare function safeHost(url: string): string | undefined;
|
|
21
|
+
export declare function withoutHash(url: string): string;
|
|
22
|
+
/**
|
|
23
|
+
* Returns a normalized URL to visit next, or `undefined` if the link should not be crawled.
|
|
24
|
+
* Query strings are dropped so calendars and filter pages don't eat the page budget.
|
|
25
|
+
*/
|
|
26
|
+
export declare function crawlTarget(href: string, siteUrl: URL): string | undefined;
|
package/dist/url.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
const SKIPPED_EXTENSIONS = /\.(pdf|zip|gz|rar|7z|jpe?g|png|gif|webp|avif|svg|ico|mp4|webm|mp3|wav|docx?|xlsx?|pptx?|csv|xml|json|txt)$/i;
|
|
2
|
+
export function hostMatches(host, domain) {
|
|
3
|
+
return host === domain || host.endsWith(`.${domain}`);
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Treats `example.com`, `www.example.com` and `cdn.example.com` as the same site.
|
|
7
|
+
* This is a simplification: it does not consult the public suffix list, so
|
|
8
|
+
* sibling subdomains on shared hosts (e.g. `*.github.io`) count as first party.
|
|
9
|
+
*/
|
|
10
|
+
export function isFirstParty(url, siteUrl) {
|
|
11
|
+
const host = safeHost(url);
|
|
12
|
+
if (host === undefined) {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
const siteHost = siteUrl.hostname.replace(/^www\./, '');
|
|
16
|
+
return hostMatches(host, siteHost) || hostMatches(siteHost, host);
|
|
17
|
+
}
|
|
18
|
+
/** Returns the first entry whose hosts and paths match the URL. */
|
|
19
|
+
export function findByUrl(entries, url) {
|
|
20
|
+
let parsed;
|
|
21
|
+
try {
|
|
22
|
+
parsed = new URL(url);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
return entries.find((entry) => entry.domains.some((domain) => hostMatches(parsed.hostname, domain)) &&
|
|
28
|
+
(entry.paths === undefined || entry.paths.some((path) => pathMatches(parsed.pathname, path))));
|
|
29
|
+
}
|
|
30
|
+
function pathMatches(pathname, path) {
|
|
31
|
+
if (path.endsWith('/')) {
|
|
32
|
+
return pathname.startsWith(path);
|
|
33
|
+
}
|
|
34
|
+
return pathname === path || pathname.startsWith(`${path}/`);
|
|
35
|
+
}
|
|
36
|
+
export function safeHost(url) {
|
|
37
|
+
try {
|
|
38
|
+
return new URL(url).hostname;
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
export function withoutHash(url) {
|
|
45
|
+
const parsed = new URL(url);
|
|
46
|
+
parsed.hash = '';
|
|
47
|
+
return parsed.href;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Returns a normalized URL to visit next, or `undefined` if the link should not be crawled.
|
|
51
|
+
* Query strings are dropped so calendars and filter pages don't eat the page budget.
|
|
52
|
+
*/
|
|
53
|
+
export function crawlTarget(href, siteUrl) {
|
|
54
|
+
let url;
|
|
55
|
+
try {
|
|
56
|
+
url = new URL(href, siteUrl);
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
if (!isFirstParty(url.href, siteUrl) || SKIPPED_EXTENSIONS.test(url.pathname)) {
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
url.hash = '';
|
|
68
|
+
url.search = '';
|
|
69
|
+
return url.href;
|
|
70
|
+
}
|
|
71
|
+
//# sourceMappingURL=url.js.map
|
package/dist/url.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"url.js","sourceRoot":"","sources":["../src/url.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,67 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "privalint",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Lint your website for GDPR problems: trackers before consent and after reject, Google Fonts, embeds, missing Impressum. Real browser, CLI and GitHub Action.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"privalint": "dist/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"main": "dist/index.js",
|
|
10
|
+
"types": "dist/index.d.ts",
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"types": "./dist/index.d.ts",
|
|
14
|
+
"default": "./dist/index.js"
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"dist",
|
|
19
|
+
"README.de.md"
|
|
20
|
+
],
|
|
21
|
+
"engines": {
|
|
22
|
+
"node": ">=20.12"
|
|
23
|
+
},
|
|
24
|
+
"scripts": {
|
|
25
|
+
"build": "tsc -p tsconfig.build.json",
|
|
26
|
+
"typecheck": "tsc --noEmit",
|
|
27
|
+
"test": "vitest run",
|
|
28
|
+
"test:watch": "vitest",
|
|
29
|
+
"prepublishOnly": "npm run build",
|
|
30
|
+
"lint": "biome check .",
|
|
31
|
+
"lint:fix": "biome check --write .",
|
|
32
|
+
"demo": "npm run build && node scripts/render-demo.mjs"
|
|
33
|
+
},
|
|
34
|
+
"keywords": [
|
|
35
|
+
"gdpr",
|
|
36
|
+
"dsgvo",
|
|
37
|
+
"privacy",
|
|
38
|
+
"cookies",
|
|
39
|
+
"consent",
|
|
40
|
+
"tracker",
|
|
41
|
+
"google-fonts",
|
|
42
|
+
"impressum",
|
|
43
|
+
"lint",
|
|
44
|
+
"playwright",
|
|
45
|
+
"github-action",
|
|
46
|
+
"tdddg",
|
|
47
|
+
"dsg",
|
|
48
|
+
"oesterreich",
|
|
49
|
+
"schweiz"
|
|
50
|
+
],
|
|
51
|
+
"license": "MIT",
|
|
52
|
+
"dependencies": {
|
|
53
|
+
"playwright": "^1.64.1"
|
|
54
|
+
},
|
|
55
|
+
"devDependencies": {
|
|
56
|
+
"@biomejs/biome": "2.6.3",
|
|
57
|
+
"@types/node": "^20.19.43",
|
|
58
|
+
"typescript": "^7.0.2",
|
|
59
|
+
"vitest": "^4.1.12"
|
|
60
|
+
},
|
|
61
|
+
"repository": {
|
|
62
|
+
"type": "git",
|
|
63
|
+
"url": "git+https://github.com/JExcellence/privalint.git"
|
|
64
|
+
},
|
|
65
|
+
"homepage": "https://github.com/JExcellence/privalint#readme",
|
|
66
|
+
"bugs": "https://github.com/JExcellence/privalint/issues"
|
|
67
|
+
}
|