scanprivacy 0.1.1__py3-none-any.whl
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.
- scanprivacy/__init__.py +6 -0
- scanprivacy/analyze/__init__.py +0 -0
- scanprivacy/analyze/context.py +524 -0
- scanprivacy/analyze/diff.py +153 -0
- scanprivacy/analyze/dpia.py +189 -0
- scanprivacy/analyze/engine.py +250 -0
- scanprivacy/analyze/fleet.py +216 -0
- scanprivacy/analyze/graph.py +167 -0
- scanprivacy/analyze/predicates.py +394 -0
- scanprivacy/analyze/subjects.py +186 -0
- scanprivacy/analyze/verdict.py +55 -0
- scanprivacy/analyze/verify.py +85 -0
- scanprivacy/api.py +548 -0
- scanprivacy/auth.py +128 -0
- scanprivacy/batch.py +140 -0
- scanprivacy/cli.py +690 -0
- scanprivacy/config.py +168 -0
- scanprivacy/core/__init__.py +1 -0
- scanprivacy/core/enums.py +235 -0
- scanprivacy/core/graph.py +130 -0
- scanprivacy/core/models.py +314 -0
- scanprivacy/core/target.py +149 -0
- scanprivacy/i18n/__init__.py +5 -0
- scanprivacy/i18n/locales/en/references.yml +96 -0
- scanprivacy/i18n/locales/en/rules.yml +530 -0
- scanprivacy/i18n/locales/en/ui.yml +985 -0
- scanprivacy/i18n/locales/en/vendors.yml +448 -0
- scanprivacy/i18n/locales/it/references.yml +90 -0
- scanprivacy/i18n/locales/it/rules.yml +538 -0
- scanprivacy/i18n/locales/it/ui.yml +1003 -0
- scanprivacy/i18n/locales/it/vendors.yml +448 -0
- scanprivacy/i18n/translator.py +211 -0
- scanprivacy/knowledge/__init__.py +0 -0
- scanprivacy/knowledge/consent.yml +126 -0
- scanprivacy/knowledge/frameworks.yml +323 -0
- scanprivacy/knowledge/platforms.yml +51 -0
- scanprivacy/knowledge/registry.py +319 -0
- scanprivacy/knowledge/remediation.py +226 -0
- scanprivacy/knowledge/vendors.yml +1339 -0
- scanprivacy/observe/__init__.py +0 -0
- scanprivacy/observe/browser.py +600 -0
- scanprivacy/observe/consent.py +273 -0
- scanprivacy/observe/discover.py +195 -0
- scanprivacy/observe/docs.py +463 -0
- scanprivacy/observe/egress.py +178 -0
- scanprivacy/observe/guard.py +92 -0
- scanprivacy/observe/pages.py +258 -0
- scanprivacy/observe/rate.py +35 -0
- scanprivacy/observe/robots.py +47 -0
- scanprivacy/observe/tls.py +121 -0
- scanprivacy/observe/vantage.py +68 -0
- scanprivacy/report/__init__.py +0 -0
- scanprivacy/report/anchor.py +219 -0
- scanprivacy/report/attestation.py +289 -0
- scanprivacy/report/badge.py +70 -0
- scanprivacy/report/fleet_markdown.py +101 -0
- scanprivacy/report/fleet_pdf.py +84 -0
- scanprivacy/report/markdown.py +227 -0
- scanprivacy/report/pdf.py +273 -0
- scanprivacy/report/record.py +174 -0
- scanprivacy/report/render.py +449 -0
- scanprivacy/report/sarif.py +251 -0
- scanprivacy/report/templates/base.css +346 -0
- scanprivacy/report/templates/fleet.html +167 -0
- scanprivacy/report/templates/fonts/OFL-Inter.txt +93 -0
- scanprivacy/report/templates/fonts/OFL-JetBrainsMono.txt +93 -0
- scanprivacy/report/templates/fonts/README.md +8 -0
- scanprivacy/report/templates/fonts/inter-400-italic.woff2 +0 -0
- scanprivacy/report/templates/fonts/inter-400.woff2 +0 -0
- scanprivacy/report/templates/fonts/inter-500.woff2 +0 -0
- scanprivacy/report/templates/fonts/inter-600.woff2 +0 -0
- scanprivacy/report/templates/fonts/inter-700.woff2 +0 -0
- scanprivacy/report/templates/fonts/jetbrains-mono-400.woff2 +0 -0
- scanprivacy/report/templates/fonts/jetbrains-mono-500.woff2 +0 -0
- scanprivacy/report/templates/report.html +567 -0
- scanprivacy/rules/PM-AI-01.yml +25 -0
- scanprivacy/rules/PM-CNS-01.yml +39 -0
- scanprivacy/rules/PM-CNS-02.yml +24 -0
- scanprivacy/rules/PM-CNS-03.yml +26 -0
- scanprivacy/rules/PM-COK-01.yml +23 -0
- scanprivacy/rules/PM-COK-02.yml +23 -0
- scanprivacy/rules/PM-COK-03.yml +21 -0
- scanprivacy/rules/PM-COK-04.yml +23 -0
- scanprivacy/rules/PM-COST-01.yml +22 -0
- scanprivacy/rules/PM-DIS-01.yml +28 -0
- scanprivacy/rules/PM-DOC-01.yml +21 -0
- scanprivacy/rules/PM-DOC-02.yml +22 -0
- scanprivacy/rules/PM-DOC-03.yml +24 -0
- scanprivacy/rules/PM-DPIA-01.yml +23 -0
- scanprivacy/rules/PM-FRM-01.yml +23 -0
- scanprivacy/rules/PM-IAM-01.yml +26 -0
- scanprivacy/rules/PM-IAM-02.yml +26 -0
- scanprivacy/rules/PM-IAM-03.yml +27 -0
- scanprivacy/rules/PM-IAM-04.yml +27 -0
- scanprivacy/rules/PM-IAM-05.yml +26 -0
- scanprivacy/rules/PM-REJ-01.yml +41 -0
- scanprivacy/rules/PM-REJ-02.yml +27 -0
- scanprivacy/rules/PM-REJ-03.yml +26 -0
- scanprivacy/rules/PM-REJ-04.yml +24 -0
- scanprivacy/rules/PM-SEC-01.yml +22 -0
- scanprivacy/rules/PM-SEC-02.yml +23 -0
- scanprivacy/rules/PM-SEC-03.yml +23 -0
- scanprivacy/rules/PM-STO-01.yml +23 -0
- scanprivacy/rules/PM-STO-02.yml +23 -0
- scanprivacy/rules/PM-TRF-01.yml +30 -0
- scanprivacy/rules/PM-TRF-02.yml +29 -0
- scanprivacy/rules/README.md +132 -0
- scanprivacy/scanner.py +468 -0
- scanprivacy-0.1.1.dist-info/METADATA +556 -0
- scanprivacy-0.1.1.dist-info/RECORD +114 -0
- scanprivacy-0.1.1.dist-info/WHEEL +5 -0
- scanprivacy-0.1.1.dist-info/entry_points.txt +2 -0
- scanprivacy-0.1.1.dist-info/licenses/LICENSE +235 -0
- scanprivacy-0.1.1.dist-info/top_level.txt +1 -0
scanprivacy/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,524 @@
|
|
|
1
|
+
"""Derived facts: everything the rules are allowed to ask about.
|
|
2
|
+
|
|
3
|
+
The observation layer records what happened. This layer turns it into legal
|
|
4
|
+
facts — who the third parties are, which of them the notice declares, which
|
|
5
|
+
cookies are tracking cookies, which Art. 13 elements are missing — and nothing
|
|
6
|
+
else. Rules see only this, and only through predicates.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import re
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from typing import Any
|
|
15
|
+
from urllib.parse import urlsplit
|
|
16
|
+
|
|
17
|
+
from ..core.enums import DataClass, Detection, TransferScope
|
|
18
|
+
from ..knowledge.registry import (
|
|
19
|
+
Vendor,
|
|
20
|
+
VendorRemediation,
|
|
21
|
+
identify_platform,
|
|
22
|
+
is_first_party,
|
|
23
|
+
lookup_by_id,
|
|
24
|
+
registered_domain,
|
|
25
|
+
transfer_scope,
|
|
26
|
+
vendor_for,
|
|
27
|
+
)
|
|
28
|
+
from ..observe.browser import CookieSet, Observation, Request, StorageEntry
|
|
29
|
+
from ..observe.docs import ART13_CONDITIONAL, DocumentEvidence
|
|
30
|
+
|
|
31
|
+
# Cookie and storage keys that are tracking by construction. Name-based, so it
|
|
32
|
+
# still works when the setting host is first-party: server-side GA4, proxied tag
|
|
33
|
+
# managers, plugins that write the identifier themselves.
|
|
34
|
+
TRACKING_KEY_PATTERNS: list[tuple[re.Pattern[str], str]] = [
|
|
35
|
+
(re.compile(r"^_ga($|_)|^_gid$|^_gat|^_gcl_", re.I), "google-analytics"),
|
|
36
|
+
(re.compile(r"^__utm", re.I), "google-analytics"),
|
|
37
|
+
(re.compile(r"^_fbp$|^_fbc$|^fr$", re.I), "meta-pixel"),
|
|
38
|
+
(re.compile(r"^_hj", re.I), "hotjar"),
|
|
39
|
+
(re.compile(r"^_clck$|^_clsk$|^MUID$|^CLID$", re.I), "microsoft-clarity"),
|
|
40
|
+
(re.compile(r"^_pk_(id|ses)", re.I), "matomo-cloud"),
|
|
41
|
+
(re.compile(r"^IDE$|^test_cookie$|^NID$|^DSID$", re.I), "google-ads"),
|
|
42
|
+
(re.compile(r"^bcookie$|^li_(sugr|gc)$|^UserMatchHistory$|^lidc$", re.I), "linkedin-insight"),
|
|
43
|
+
(re.compile(r"^_uet(sid|vid)", re.I), "microsoft-clarity"),
|
|
44
|
+
(re.compile(r"^__hs|^hubspotutk$", re.I), "hubspot"),
|
|
45
|
+
(re.compile(r"^intercom-", re.I), "intercom"),
|
|
46
|
+
(re.compile(r"^tk_(ai|qs|r3d)|^wordpress_test", re.I), "wordpress-stats"),
|
|
47
|
+
(re.compile(r"^AMP_|^ajs_", re.I), None),
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
# Consent-proof, security and preference keys: their presence is not a finding.
|
|
51
|
+
FUNCTIONAL_KEY_PATTERNS: list[re.Pattern[str]] = [
|
|
52
|
+
re.compile(r"cookie[-_]?(consent|notice|law|yes|bot)|iub|CookieConsent|cmplz|borlabs|klaro|euconsent", re.I),
|
|
53
|
+
re.compile(r"^(cf_clearance|__cf_bm|_cfuvid|__cflb|AWSALB|AWSALBCORS|incap_ses|visid_incap)", re.I),
|
|
54
|
+
re.compile(r"^(theme|lang|locale|i18n|color[-_]?scheme|font[-_]?size|sidebar|darkmode)", re.I),
|
|
55
|
+
re.compile(
|
|
56
|
+
r"^(PHPSESSID|JSESSIONID|ASP\.NET_SessionId|csrftoken|XSRF-TOKEN|__Host-|__Secure-|wordpress_sec)",
|
|
57
|
+
re.I,
|
|
58
|
+
),
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
# Processors that never appear as a subresource host because they *are* the
|
|
62
|
+
# first-party origin: CDN, edge, static hosting. They receive the visitor's IP
|
|
63
|
+
# all the same, and the notice has to name them.
|
|
64
|
+
HEADER_SIGNATURES: list[tuple[re.Pattern[str], str, str]] = [
|
|
65
|
+
(re.compile(r"^cloudflare$", re.I), "server", "cloudflare-cdn"),
|
|
66
|
+
(re.compile(r".+"), "cf-ray", "cloudflare-cdn"),
|
|
67
|
+
(re.compile(r"github\.com", re.I), "server", "github-pages"),
|
|
68
|
+
(re.compile(r".+"), "x-github-request-id", "github-pages"),
|
|
69
|
+
(re.compile(r".+"), "x-vercel-id", "vercel"),
|
|
70
|
+
(re.compile(r"^Vercel$", re.I), "server", "vercel"),
|
|
71
|
+
(re.compile(r"Netlify", re.I), "server", "netlify"),
|
|
72
|
+
(re.compile(r".+"), "x-nf-request-id", "netlify"),
|
|
73
|
+
(re.compile(r".+"), "x-amz-cf-id", "aws-cloudfront"),
|
|
74
|
+
(re.compile(r"AmazonS3|CloudFront", re.I), "server", "aws-cloudfront"),
|
|
75
|
+
(re.compile(r".+"), "fly-request-id", "fly-io"),
|
|
76
|
+
(re.compile(r"Fastly|Varnish", re.I), "x-served-by", "fastly"),
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def infra_from_headers(headers: dict[str, str]) -> list[str]:
|
|
81
|
+
"""Vendor ids inferred from the main response headers."""
|
|
82
|
+
out: list[str] = []
|
|
83
|
+
for pattern, header, vendor_id in HEADER_SIGNATURES:
|
|
84
|
+
value = headers.get(header)
|
|
85
|
+
if value and pattern.search(value) and vendor_id not in out:
|
|
86
|
+
out.append(vendor_id)
|
|
87
|
+
return out
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def classify_key(name: str) -> tuple[DataClass, str | None]:
|
|
91
|
+
"""Return (class, vendor id hint) for a cookie name or storage key."""
|
|
92
|
+
for pattern, vendor_id in TRACKING_KEY_PATTERNS:
|
|
93
|
+
if pattern.search(name):
|
|
94
|
+
return DataClass.TRACKING, vendor_id
|
|
95
|
+
for pattern in FUNCTIONAL_KEY_PATTERNS:
|
|
96
|
+
if pattern.search(name):
|
|
97
|
+
return DataClass.FUNCTIONAL, None
|
|
98
|
+
return DataClass.UNKNOWN, None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass(slots=True)
|
|
102
|
+
class VendorFact:
|
|
103
|
+
vendor: Vendor
|
|
104
|
+
detection: Detection = Detection.REQUEST
|
|
105
|
+
hosts: list[str] = field(default_factory=list)
|
|
106
|
+
sample_urls: list[str] = field(default_factory=list)
|
|
107
|
+
resource_types: list[str] = field(default_factory=list)
|
|
108
|
+
pages: list[str] = field(default_factory=list)
|
|
109
|
+
"""Which observed pages contacted this vendor. "on every page" and "only on
|
|
110
|
+
the contact form" call for different fixes, and the difference is only
|
|
111
|
+
visible once more than one page has been observed."""
|
|
112
|
+
first_party: bool = False
|
|
113
|
+
declared: bool = False
|
|
114
|
+
declared_in: list[str] = field(default_factory=list)
|
|
115
|
+
transfer: TransferScope = TransferScope.EEA
|
|
116
|
+
via_embed: str | None = None
|
|
117
|
+
"""Host of the embedded document this vendor was only ever reached through.
|
|
118
|
+
|
|
119
|
+
A site that embeds YouTube has chosen YouTube, and answers for that. It has
|
|
120
|
+
not chosen the fonts YouTube then loads inside its own player: it cannot see
|
|
121
|
+
them, configure them or prevent them, and the only fix available to it is
|
|
122
|
+
the one it already has for the embed. Reporting them as processors the site
|
|
123
|
+
selected produces findings whose owner has no way to act on them — on the
|
|
124
|
+
site that exposed this, "the operator chose Google Fonts" about a site whose
|
|
125
|
+
own CSP says `font-src 'self'`.
|
|
126
|
+
|
|
127
|
+
`None` the moment the page itself contacts the vendor directly, even once:
|
|
128
|
+
then it *is* a choice, and the fact that an embed also reaches it changes
|
|
129
|
+
nothing."""
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def id(self) -> str:
|
|
133
|
+
return self.vendor.id
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@dataclass(slots=True)
|
|
137
|
+
class CookieFact:
|
|
138
|
+
cookie: CookieSet
|
|
139
|
+
data_class: DataClass
|
|
140
|
+
vendor_id: str | None
|
|
141
|
+
third_party: bool
|
|
142
|
+
declared: bool
|
|
143
|
+
first_seen_page: str = ""
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@dataclass(slots=True)
|
|
147
|
+
class FormFact:
|
|
148
|
+
"""One form, however many pages carry it.
|
|
149
|
+
|
|
150
|
+
A login form in a shared header is one form, not one per page — counting it
|
|
151
|
+
per page inflates the report the same way counting a vendor per page would.
|
|
152
|
+
The identity is what the form *is* (its destination and its fields), which
|
|
153
|
+
also makes the finding id stable across scans: an index would shift as soon
|
|
154
|
+
as page order changed, and every SARIF alert would be recreated.
|
|
155
|
+
"""
|
|
156
|
+
|
|
157
|
+
form: Any
|
|
158
|
+
pages: list[str] = field(default_factory=list)
|
|
159
|
+
|
|
160
|
+
@property
|
|
161
|
+
def slug(self) -> str:
|
|
162
|
+
"""Identity from what the form *is*, never from field names.
|
|
163
|
+
|
|
164
|
+
Field names are not stable: a CSRF token is a fresh hex string on every
|
|
165
|
+
load, and anti-spam honeypots rotate their name from a word list. On the
|
|
166
|
+
pilot site that produced five findings for one login form, each with a
|
|
167
|
+
different id — which would also have meant five new SARIF alerts on
|
|
168
|
+
every scan. The type profile is stable and still distinguishes a login
|
|
169
|
+
form from a search box.
|
|
170
|
+
"""
|
|
171
|
+
profile: dict[str, int] = {}
|
|
172
|
+
for field_ in self.form.fields:
|
|
173
|
+
kind = field_.split(":", 1)[0]
|
|
174
|
+
profile[kind] = profile.get(kind, 0) + 1
|
|
175
|
+
signature = "|".join(
|
|
176
|
+
[self.form.action, self.form.method, "pw" if self.form.has_password else "nopw"]
|
|
177
|
+
+ [f"{kind}={count}" for kind, count in sorted(profile.items())]
|
|
178
|
+
)
|
|
179
|
+
return hashlib.sha256(signature.encode("utf-8")).hexdigest()[:12]
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@dataclass(slots=True)
|
|
183
|
+
class StorageFact:
|
|
184
|
+
entry: StorageEntry
|
|
185
|
+
data_class: DataClass
|
|
186
|
+
vendor_id: str | None
|
|
187
|
+
first_seen_page: str = ""
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
@dataclass(slots=True)
|
|
191
|
+
class Context:
|
|
192
|
+
obs: Observation
|
|
193
|
+
"""The entry page. Header, TLS and document rules are measured here: they
|
|
194
|
+
describe the origin, and mixing per-page differences into them would turn
|
|
195
|
+
one finding into one finding per page."""
|
|
196
|
+
pages: list[Observation] = field(default_factory=list)
|
|
197
|
+
documents: list[DocumentEvidence] = field(default_factory=list)
|
|
198
|
+
tls: Any | None = None
|
|
199
|
+
site_host: str = ""
|
|
200
|
+
site_domain: str = ""
|
|
201
|
+
platform_id: str | None = None
|
|
202
|
+
platform_name: str | None = None
|
|
203
|
+
vendors: dict[str, VendorFact] = field(default_factory=dict)
|
|
204
|
+
cookies: list[CookieFact] = field(default_factory=list)
|
|
205
|
+
storage: list[StorageFact] = field(default_factory=list)
|
|
206
|
+
declared_vendor_ids: set[str] = field(default_factory=set)
|
|
207
|
+
docs_by_kind: dict[str, DocumentEvidence] = field(default_factory=dict)
|
|
208
|
+
refusal: Any | None = None
|
|
209
|
+
"""What the engine did to the consent mechanism, when the refusal pass ran.
|
|
210
|
+
`None` in every passive scan, which is every scan by default."""
|
|
211
|
+
rule_errors: list[str] = field(default_factory=list)
|
|
212
|
+
"""Predicates that raised or were unknown while rules were evaluated. A rule
|
|
213
|
+
that could not run has not found nothing, and the verdict says so."""
|
|
214
|
+
before: "Context | None" = None
|
|
215
|
+
"""Facts observed before the refusal, so a finding can say a vendor was
|
|
216
|
+
present before *and* survived the refusal."""
|
|
217
|
+
|
|
218
|
+
@property
|
|
219
|
+
def observed_pages(self) -> list[Observation]:
|
|
220
|
+
return [page for page in self.pages if page.load_ok]
|
|
221
|
+
|
|
222
|
+
@property
|
|
223
|
+
def forms(self) -> list[FormFact]:
|
|
224
|
+
"""Every distinct form, with the pages that carry it."""
|
|
225
|
+
by_signature: dict[str, FormFact] = {}
|
|
226
|
+
for page in self.observed_pages:
|
|
227
|
+
for form in page.forms:
|
|
228
|
+
fact = FormFact(form=form)
|
|
229
|
+
existing = by_signature.get(fact.slug)
|
|
230
|
+
if existing is None:
|
|
231
|
+
fact.pages = [page.final_url]
|
|
232
|
+
by_signature[fact.slug] = fact
|
|
233
|
+
elif page.final_url not in existing.pages:
|
|
234
|
+
existing.pages.append(page.final_url)
|
|
235
|
+
return sorted(by_signature.values(), key=lambda f: f.slug)
|
|
236
|
+
|
|
237
|
+
@property
|
|
238
|
+
def third_party_vendors(self) -> list[VendorFact]:
|
|
239
|
+
return [v for v in self.vendors.values() if not v.first_party]
|
|
240
|
+
|
|
241
|
+
@property
|
|
242
|
+
def consent_required_vendors(self) -> list[VendorFact]:
|
|
243
|
+
from ..core.enums import ConsentClass
|
|
244
|
+
|
|
245
|
+
return [
|
|
246
|
+
v for v in self.third_party_vendors if v.vendor.consent_class is ConsentClass.REQUIRED
|
|
247
|
+
]
|
|
248
|
+
|
|
249
|
+
@property
|
|
250
|
+
def cmp_detected(self) -> list[str]:
|
|
251
|
+
names: list[str] = []
|
|
252
|
+
for page in self.observed_pages:
|
|
253
|
+
for name in page.cmp_detected:
|
|
254
|
+
if name not in names:
|
|
255
|
+
names.append(name)
|
|
256
|
+
if any(page.tcf_api_present for page in self.observed_pages) and "TCF API" not in names:
|
|
257
|
+
names.append("TCF API")
|
|
258
|
+
return names
|
|
259
|
+
|
|
260
|
+
@property
|
|
261
|
+
def ai_markers(self) -> list[str]:
|
|
262
|
+
markers: list[str] = []
|
|
263
|
+
for page in self.observed_pages:
|
|
264
|
+
for marker in page.ai_markers:
|
|
265
|
+
if marker not in markers:
|
|
266
|
+
markers.append(marker)
|
|
267
|
+
return markers
|
|
268
|
+
|
|
269
|
+
@property
|
|
270
|
+
def has_cmp(self) -> bool:
|
|
271
|
+
return bool(self.cmp_detected)
|
|
272
|
+
|
|
273
|
+
def art13_missing(self, kind: str = "privacy") -> list[str]:
|
|
274
|
+
"""Only elements due in every case; see ART13_CONDITIONAL."""
|
|
275
|
+
doc = self.docs_by_kind.get(kind)
|
|
276
|
+
if doc is None or not doc.ok:
|
|
277
|
+
return []
|
|
278
|
+
return [k for k, present in doc.elements.items() if not present and k not in ART13_CONDITIONAL]
|
|
279
|
+
|
|
280
|
+
def art13_missing_conditional(self, kind: str = "privacy") -> list[str]:
|
|
281
|
+
doc = self.docs_by_kind.get(kind)
|
|
282
|
+
if doc is None or not doc.ok:
|
|
283
|
+
return []
|
|
284
|
+
return [k for k, present in doc.elements.items() if not present and k in ART13_CONDITIONAL]
|
|
285
|
+
|
|
286
|
+
def has_document(self, kind: str) -> bool:
|
|
287
|
+
doc = self.docs_by_kind.get(kind)
|
|
288
|
+
return bool(doc and doc.ok)
|
|
289
|
+
|
|
290
|
+
def header(self, name: str) -> str | None:
|
|
291
|
+
return self.obs.response_headers.get(name.lower())
|
|
292
|
+
|
|
293
|
+
@property
|
|
294
|
+
def csp_report_only(self) -> str | None:
|
|
295
|
+
"""A `Content-Security-Policy-Report-Only` header, if one is served.
|
|
296
|
+
|
|
297
|
+
It enforces nothing, so a site that has only this one has no policy in
|
|
298
|
+
force and `PM-SEC-03` is right to fire. But telling an operator "no
|
|
299
|
+
Content-Security-Policy" when their response carries a header by very
|
|
300
|
+
nearly that name is how a true finding gets dismissed as a false one —
|
|
301
|
+
they check, they see a CSP, they stop reading. The finding stands; the
|
|
302
|
+
evidence says which of the two situations it is."""
|
|
303
|
+
return self.header("content-security-policy-report-only")
|
|
304
|
+
|
|
305
|
+
def effective_csp(self) -> tuple[str | None, str]:
|
|
306
|
+
"""The policy actually in force, and where it comes from.
|
|
307
|
+
|
|
308
|
+
A meta element is a weaker instrument than a header — it cannot carry
|
|
309
|
+
`frame-ancestors` or `report-uri`, and it applies only from the point
|
|
310
|
+
the parser reaches it — but it is a real policy, and on hosting that
|
|
311
|
+
forbids custom headers it is the only one available.
|
|
312
|
+
"""
|
|
313
|
+
header = self.header("content-security-policy")
|
|
314
|
+
if header:
|
|
315
|
+
return header, "header"
|
|
316
|
+
if self.obs.meta_csp:
|
|
317
|
+
return self.obs.meta_csp, "meta"
|
|
318
|
+
return None, "none"
|
|
319
|
+
|
|
320
|
+
def effective_referrer_policy(self) -> tuple[str | None, str]:
|
|
321
|
+
header = self.header("referrer-policy")
|
|
322
|
+
if header:
|
|
323
|
+
return header, "header"
|
|
324
|
+
if self.obs.meta_referrer:
|
|
325
|
+
return self.obs.meta_referrer, "meta"
|
|
326
|
+
return None, "none"
|
|
327
|
+
|
|
328
|
+
@property
|
|
329
|
+
def fixed_header_platform(self) -> str | None:
|
|
330
|
+
"""The vendor id of hosting that serves response headers nobody can
|
|
331
|
+
change, or None.
|
|
332
|
+
|
|
333
|
+
The fact lives in `vendors.yml` rather than in a literal here: which
|
|
334
|
+
platforms permit a header is knowledge about a vendor, and it changes
|
|
335
|
+
when the vendor changes rather than when this file does."""
|
|
336
|
+
for vendor_id, fact in self.vendors.items():
|
|
337
|
+
if fact.vendor.response_headers_fixed:
|
|
338
|
+
return vendor_id
|
|
339
|
+
return None
|
|
340
|
+
|
|
341
|
+
@property
|
|
342
|
+
def headers_configurable(self) -> bool:
|
|
343
|
+
"""False on hosting that serves fixed response headers. Sending someone
|
|
344
|
+
to "set a header" where the platform does not allow it wastes their
|
|
345
|
+
afternoon and leaves the finding open forever."""
|
|
346
|
+
return self.fixed_header_platform is None
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _embed_origin(req: Request, site_host: str) -> str | None:
|
|
350
|
+
"""The third-party document this request was made inside, or None.
|
|
351
|
+
|
|
352
|
+
None covers three cases that are all "the site did this": the page issued
|
|
353
|
+
the request itself, the frame belongs to the site, and the frame is the
|
|
354
|
+
vendor loading its own resources — a YouTube player fetching from YouTube is
|
|
355
|
+
still the embed the site chose, and the request that fetches the frame's own
|
|
356
|
+
document is issued by that frame before it has a URL to be judged by.
|
|
357
|
+
"""
|
|
358
|
+
if not req.frame_depth or not req.frame_url:
|
|
359
|
+
return None
|
|
360
|
+
embed_host = urlsplit(req.frame_url).netloc.lower()
|
|
361
|
+
if not embed_host or is_first_party(embed_host, site_host):
|
|
362
|
+
return None
|
|
363
|
+
if is_first_party(req.host, embed_host):
|
|
364
|
+
return None
|
|
365
|
+
return embed_host
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _first_party_vendor(domain: str) -> Vendor:
|
|
369
|
+
from ..core.enums import ConsentClass, VendorCategory
|
|
370
|
+
|
|
371
|
+
return Vendor(
|
|
372
|
+
id=f"self-{domain}",
|
|
373
|
+
vendor=domain,
|
|
374
|
+
product=domain,
|
|
375
|
+
category=VendorCategory.INFRA,
|
|
376
|
+
jurisdiction="self",
|
|
377
|
+
transfer=TransferScope.EEA,
|
|
378
|
+
storage_access=True,
|
|
379
|
+
consent_class=ConsentClass.TECHNICAL,
|
|
380
|
+
data_types=(),
|
|
381
|
+
remediation=VendorRemediation(pattern="declare_processor"),
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def build_context(
|
|
386
|
+
observations: Observation | list[Observation],
|
|
387
|
+
documents: list[DocumentEvidence] | None = None,
|
|
388
|
+
tls: Any | None = None,
|
|
389
|
+
) -> Context:
|
|
390
|
+
"""Facts from one or more pages of the same site.
|
|
391
|
+
|
|
392
|
+
Accumulation is by identity, not by count: a vendor contacted on four pages
|
|
393
|
+
is one vendor with four pages attributed to it, and a cookie present
|
|
394
|
+
everywhere is one cookie that was first seen somewhere. Counting the same
|
|
395
|
+
fact once per page would inflate every number in the report.
|
|
396
|
+
"""
|
|
397
|
+
pages = [observations] if isinstance(observations, Observation) else list(observations)
|
|
398
|
+
if not pages:
|
|
399
|
+
raise ValueError("build_context needs at least one observation")
|
|
400
|
+
entry = pages[0]
|
|
401
|
+
documents = documents or []
|
|
402
|
+
|
|
403
|
+
site_host = entry.host or urlsplit(entry.url).netloc
|
|
404
|
+
ctx = Context(
|
|
405
|
+
obs=entry,
|
|
406
|
+
pages=pages,
|
|
407
|
+
documents=documents,
|
|
408
|
+
tls=tls,
|
|
409
|
+
site_host=site_host,
|
|
410
|
+
site_domain=registered_domain(site_host),
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
platform = identify_platform(entry.generator_meta, entry.html or "")
|
|
414
|
+
if platform:
|
|
415
|
+
ctx.platform_id, ctx.platform_name = platform.id, platform.name
|
|
416
|
+
|
|
417
|
+
for doc in documents:
|
|
418
|
+
if doc.ok and doc.kind not in ctx.docs_by_kind:
|
|
419
|
+
ctx.docs_by_kind[doc.kind] = doc
|
|
420
|
+
ctx.declared_vendor_ids.update(doc.declared_vendors)
|
|
421
|
+
|
|
422
|
+
# --- hosts contacted, across every page observed
|
|
423
|
+
#
|
|
424
|
+
# Two attributions are collected per vendor and reconciled afterwards: was
|
|
425
|
+
# it ever contacted by the page itself, and which embedded document reached
|
|
426
|
+
# it otherwise. A vendor seen both ways is a vendor the site chose.
|
|
427
|
+
contacted_directly: set[str] = set()
|
|
428
|
+
embed_by_vendor: dict[str, str] = {}
|
|
429
|
+
|
|
430
|
+
for page in ctx.observed_pages:
|
|
431
|
+
page_url = page.final_url or page.url
|
|
432
|
+
for req in page.requests:
|
|
433
|
+
host = req.host
|
|
434
|
+
if not host:
|
|
435
|
+
continue
|
|
436
|
+
first_party = is_first_party(host, site_host)
|
|
437
|
+
key = f"self-{ctx.site_domain}" if first_party else vendor_for(host).id
|
|
438
|
+
|
|
439
|
+
embed = _embed_origin(req, site_host)
|
|
440
|
+
if embed is None:
|
|
441
|
+
contacted_directly.add(key)
|
|
442
|
+
else:
|
|
443
|
+
embed_by_vendor.setdefault(key, embed)
|
|
444
|
+
|
|
445
|
+
fact = ctx.vendors.get(key)
|
|
446
|
+
if fact is None:
|
|
447
|
+
vendor = _first_party_vendor(ctx.site_domain) if first_party else vendor_for(host)
|
|
448
|
+
fact = VendorFact(vendor=vendor, first_party=first_party)
|
|
449
|
+
ctx.vendors[key] = fact
|
|
450
|
+
if host not in fact.hosts:
|
|
451
|
+
fact.hosts.append(host)
|
|
452
|
+
if page_url not in fact.pages:
|
|
453
|
+
fact.pages.append(page_url)
|
|
454
|
+
if len(fact.sample_urls) < 5 and req.url not in fact.sample_urls:
|
|
455
|
+
fact.sample_urls.append(req.url)
|
|
456
|
+
if req.resource_type not in fact.resource_types:
|
|
457
|
+
fact.resource_types.append(req.resource_type)
|
|
458
|
+
|
|
459
|
+
for vendor_key, embed in embed_by_vendor.items():
|
|
460
|
+
if vendor_key not in contacted_directly and vendor_key in ctx.vendors:
|
|
461
|
+
ctx.vendors[vendor_key].via_embed = embed
|
|
462
|
+
|
|
463
|
+
# --- processors visible only in the response headers of the entry page
|
|
464
|
+
for vendor_id in infra_from_headers(entry.response_headers):
|
|
465
|
+
if vendor_id in ctx.vendors:
|
|
466
|
+
continue
|
|
467
|
+
vendor = lookup_by_id(vendor_id)
|
|
468
|
+
if vendor is None:
|
|
469
|
+
continue
|
|
470
|
+
ctx.vendors[vendor_id] = VendorFact(
|
|
471
|
+
vendor=vendor,
|
|
472
|
+
detection=Detection.HEADER,
|
|
473
|
+
first_party=False,
|
|
474
|
+
pages=[entry.final_url or entry.url],
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
for fact in ctx.vendors.values():
|
|
478
|
+
if fact.first_party:
|
|
479
|
+
fact.transfer = TransferScope.EEA
|
|
480
|
+
fact.declared = True
|
|
481
|
+
continue
|
|
482
|
+
fact.transfer = transfer_scope(fact.hosts[0]) if fact.hosts else fact.vendor.transfer
|
|
483
|
+
fact.declared = fact.vendor.id in ctx.declared_vendor_ids
|
|
484
|
+
fact.declared_in = [d.kind for d in documents if fact.vendor.id in d.declared_vendors]
|
|
485
|
+
|
|
486
|
+
# --- cookies and terminal storage, deduplicated across pages
|
|
487
|
+
seen_cookies: set[tuple[str, str]] = set()
|
|
488
|
+
seen_storage: set[tuple[str, str]] = set()
|
|
489
|
+
for page in ctx.observed_pages:
|
|
490
|
+
page_url = page.final_url or page.url
|
|
491
|
+
for cookie in page.cookies:
|
|
492
|
+
identity = (cookie.domain, cookie.name)
|
|
493
|
+
if identity in seen_cookies:
|
|
494
|
+
continue
|
|
495
|
+
seen_cookies.add(identity)
|
|
496
|
+
data_class, hint = classify_key(cookie.name)
|
|
497
|
+
third_party = not is_first_party(cookie.domain.lstrip("."), site_host)
|
|
498
|
+
vendor_id = hint or (vendor_for(cookie.domain.lstrip(".")).id if third_party else None)
|
|
499
|
+
ctx.cookies.append(
|
|
500
|
+
CookieFact(
|
|
501
|
+
cookie=cookie,
|
|
502
|
+
data_class=data_class,
|
|
503
|
+
vendor_id=vendor_id,
|
|
504
|
+
third_party=third_party,
|
|
505
|
+
declared=bool(vendor_id and vendor_id in ctx.declared_vendor_ids),
|
|
506
|
+
first_seen_page=page_url,
|
|
507
|
+
)
|
|
508
|
+
)
|
|
509
|
+
for entry_key in page.storage:
|
|
510
|
+
identity = (entry_key.kind, entry_key.key)
|
|
511
|
+
if identity in seen_storage:
|
|
512
|
+
continue
|
|
513
|
+
seen_storage.add(identity)
|
|
514
|
+
data_class, hint = classify_key(entry_key.key)
|
|
515
|
+
ctx.storage.append(
|
|
516
|
+
StorageFact(
|
|
517
|
+
entry=entry_key,
|
|
518
|
+
data_class=data_class,
|
|
519
|
+
vendor_id=hint,
|
|
520
|
+
first_seen_page=page_url,
|
|
521
|
+
)
|
|
522
|
+
)
|
|
523
|
+
|
|
524
|
+
return ctx
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Comparing two scans of the same target.
|
|
2
|
+
|
|
3
|
+
A single scan says what is wrong. Two scans say whether anyone did anything —
|
|
4
|
+
and that is the question every steering committee actually asks. Findings that
|
|
5
|
+
survive carry their age; findings that disappear are evidence that a fix landed;
|
|
6
|
+
findings that come back are the interesting ones, because they mean the fix was
|
|
7
|
+
undone by a deploy or by a supplier.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
_SEVERITIES = ("critical", "high", "medium", "low", "info")
|
|
16
|
+
_GRADE_RANK = {"A": 0, "B": 1, "C": 2, "D": 3, "F": 4, "INCONCLUSIVE": 5}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _index(findings: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
|
|
20
|
+
return {f["id"]: f for f in findings}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _days_between(a: str | None, b: str | None) -> int | None:
|
|
24
|
+
if not a or not b:
|
|
25
|
+
return None
|
|
26
|
+
try:
|
|
27
|
+
first = datetime.fromisoformat(a.replace("Z", "+00:00"))
|
|
28
|
+
second = datetime.fromisoformat(b.replace("Z", "+00:00"))
|
|
29
|
+
except ValueError:
|
|
30
|
+
return None
|
|
31
|
+
return abs((second - first).days)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _rank(grade: str | None) -> int:
|
|
35
|
+
return _GRADE_RANK.get(grade or "", 6)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _configuration(scan: dict[str, Any]) -> dict[str, str | None]:
|
|
39
|
+
digests = ((scan.get("record") or {}).get("digests")) or {}
|
|
40
|
+
return {
|
|
41
|
+
"ruleset": digests.get("ruleset"),
|
|
42
|
+
"knowledge": digests.get("knowledge"),
|
|
43
|
+
"catalogues": digests.get("catalogues"),
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _comparability(before: dict[str, Any], after: dict[str, Any]) -> dict[str, Any]:
|
|
48
|
+
"""Were these two scans produced by the same engine?
|
|
49
|
+
|
|
50
|
+
Without this the comparison silently answers a different question than the
|
|
51
|
+
one asked. Change a rule and every finding it used to produce shows up under
|
|
52
|
+
"resolved" — which reads as somebody fixed something, and nobody did. That
|
|
53
|
+
is this project's own cardinal error aimed at its most quotable output: a
|
|
54
|
+
steering committee is shown a number that describes a code change and calls
|
|
55
|
+
it remediation.
|
|
56
|
+
|
|
57
|
+
The record already carries configuration digests; the comparison simply was
|
|
58
|
+
not looking at them. A scan too old to carry any is `unknown` rather than
|
|
59
|
+
`comparable`, because absence of a digest is not evidence of sameness.
|
|
60
|
+
"""
|
|
61
|
+
old, new = _configuration(before), _configuration(after)
|
|
62
|
+
if not any(old.values()) or not any(new.values()):
|
|
63
|
+
return {"status": "unknown", "changed": [], "note_key": "diff.configuration_unknown"}
|
|
64
|
+
changed = sorted(k for k in old if old[k] != new[k])
|
|
65
|
+
if not changed:
|
|
66
|
+
return {"status": "same", "changed": [], "note_key": None}
|
|
67
|
+
return {
|
|
68
|
+
"status": "changed",
|
|
69
|
+
"changed": changed,
|
|
70
|
+
"before": old,
|
|
71
|
+
"after": new,
|
|
72
|
+
"note_key": "diff.configuration_changed",
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _vantage(scan: dict[str, Any]) -> dict[str, Any]:
|
|
77
|
+
return ((scan.get("record") or {}).get("vantage")) or {}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _vantage_comparability(before: dict[str, Any], after: dict[str, Any]) -> dict[str, Any]:
|
|
81
|
+
"""Were these two scans made from the same place?
|
|
82
|
+
|
|
83
|
+
The same reasoning as the engine digests above, applied to the other half of
|
|
84
|
+
what a measurement depends on. A consent banner shown only in the EU, a CDN
|
|
85
|
+
answering from a different edge, a host that serves nothing outside its
|
|
86
|
+
market: the site did not change, the observer did. Read as remediation, that
|
|
87
|
+
is a fix nobody made; read as a regression, a fault nobody introduced.
|
|
88
|
+
|
|
89
|
+
Compared on what identifies a place — the declared string, or the country
|
|
90
|
+
and network from a lookup — and not on the whole fragment, because an IP
|
|
91
|
+
address or a timestamp differing between two scans from the same city is not
|
|
92
|
+
a difference worth warning about.
|
|
93
|
+
"""
|
|
94
|
+
old, new = _vantage(before), _vantage(after)
|
|
95
|
+
if not old or not new or "undetermined" in (old.get("source"), new.get("source")):
|
|
96
|
+
return {"status": "unknown", "note_key": "diff.vantage_unknown"}
|
|
97
|
+
|
|
98
|
+
def place(v: dict[str, Any]) -> tuple:
|
|
99
|
+
if v.get("source") == "declared":
|
|
100
|
+
return ("declared", v.get("declared"))
|
|
101
|
+
return ("looked_up", v.get("country"), v.get("org") or v.get("asn"))
|
|
102
|
+
|
|
103
|
+
if place(old) != place(new):
|
|
104
|
+
return {
|
|
105
|
+
"status": "changed",
|
|
106
|
+
"before": place(old)[1:],
|
|
107
|
+
"after": place(new)[1:],
|
|
108
|
+
"note_key": "diff.vantage_changed",
|
|
109
|
+
}
|
|
110
|
+
return {"status": "same", "note_key": None}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def diff(before: dict[str, Any], after: dict[str, Any]) -> dict[str, Any]:
|
|
114
|
+
"""`before` and `after` are two `ScanResult.to_dict()` payloads."""
|
|
115
|
+
old, new = _index(before.get("findings", [])), _index(after.get("findings", []))
|
|
116
|
+
|
|
117
|
+
resolved = [old[k] for k in old.keys() - new.keys()]
|
|
118
|
+
appeared = [new[k] for k in new.keys() - old.keys()]
|
|
119
|
+
persisting = []
|
|
120
|
+
for key in old.keys() & new.keys():
|
|
121
|
+
finding = dict(new[key])
|
|
122
|
+
finding["first_seen"] = old[key].get("first_seen") or before.get("started_at")
|
|
123
|
+
finding["last_seen"] = after.get("started_at")
|
|
124
|
+
finding["age_days"] = _days_between(finding["first_seen"], finding["last_seen"])
|
|
125
|
+
persisting.append(finding)
|
|
126
|
+
|
|
127
|
+
def counts(items: list[dict[str, Any]]) -> dict[str, int]:
|
|
128
|
+
return {s: sum(1 for f in items if f.get("severity") == s) for s in _SEVERITIES}
|
|
129
|
+
|
|
130
|
+
grade_before = (before.get("verdict") or {}).get("grade")
|
|
131
|
+
grade_after = (after.get("verdict") or {}).get("grade")
|
|
132
|
+
return {
|
|
133
|
+
"target": after.get("final_url") or after.get("url"),
|
|
134
|
+
"from": before.get("started_at"),
|
|
135
|
+
"to": after.get("started_at"),
|
|
136
|
+
"days_elapsed": _days_between(before.get("started_at"), after.get("started_at")),
|
|
137
|
+
"verdict": {
|
|
138
|
+
"before": grade_before,
|
|
139
|
+
"after": grade_after,
|
|
140
|
+
"improved": _rank(grade_after) < _rank(grade_before),
|
|
141
|
+
},
|
|
142
|
+
"counts": {
|
|
143
|
+
"resolved": counts(resolved),
|
|
144
|
+
"new": counts(appeared),
|
|
145
|
+
"persisting": counts(persisting),
|
|
146
|
+
},
|
|
147
|
+
"resolved": sorted(resolved, key=lambda f: f["id"]),
|
|
148
|
+
"new": sorted(appeared, key=lambda f: f["id"]),
|
|
149
|
+
"persisting": sorted(persisting, key=lambda f: (-(f.get("age_days") or 0), f["id"])),
|
|
150
|
+
"configuration": _comparability(before, after),
|
|
151
|
+
"vantage": _vantage_comparability(before, after),
|
|
152
|
+
"note_key": "diff.note",
|
|
153
|
+
}
|