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.
Files changed (114) hide show
  1. scanprivacy/__init__.py +6 -0
  2. scanprivacy/analyze/__init__.py +0 -0
  3. scanprivacy/analyze/context.py +524 -0
  4. scanprivacy/analyze/diff.py +153 -0
  5. scanprivacy/analyze/dpia.py +189 -0
  6. scanprivacy/analyze/engine.py +250 -0
  7. scanprivacy/analyze/fleet.py +216 -0
  8. scanprivacy/analyze/graph.py +167 -0
  9. scanprivacy/analyze/predicates.py +394 -0
  10. scanprivacy/analyze/subjects.py +186 -0
  11. scanprivacy/analyze/verdict.py +55 -0
  12. scanprivacy/analyze/verify.py +85 -0
  13. scanprivacy/api.py +548 -0
  14. scanprivacy/auth.py +128 -0
  15. scanprivacy/batch.py +140 -0
  16. scanprivacy/cli.py +690 -0
  17. scanprivacy/config.py +168 -0
  18. scanprivacy/core/__init__.py +1 -0
  19. scanprivacy/core/enums.py +235 -0
  20. scanprivacy/core/graph.py +130 -0
  21. scanprivacy/core/models.py +314 -0
  22. scanprivacy/core/target.py +149 -0
  23. scanprivacy/i18n/__init__.py +5 -0
  24. scanprivacy/i18n/locales/en/references.yml +96 -0
  25. scanprivacy/i18n/locales/en/rules.yml +530 -0
  26. scanprivacy/i18n/locales/en/ui.yml +985 -0
  27. scanprivacy/i18n/locales/en/vendors.yml +448 -0
  28. scanprivacy/i18n/locales/it/references.yml +90 -0
  29. scanprivacy/i18n/locales/it/rules.yml +538 -0
  30. scanprivacy/i18n/locales/it/ui.yml +1003 -0
  31. scanprivacy/i18n/locales/it/vendors.yml +448 -0
  32. scanprivacy/i18n/translator.py +211 -0
  33. scanprivacy/knowledge/__init__.py +0 -0
  34. scanprivacy/knowledge/consent.yml +126 -0
  35. scanprivacy/knowledge/frameworks.yml +323 -0
  36. scanprivacy/knowledge/platforms.yml +51 -0
  37. scanprivacy/knowledge/registry.py +319 -0
  38. scanprivacy/knowledge/remediation.py +226 -0
  39. scanprivacy/knowledge/vendors.yml +1339 -0
  40. scanprivacy/observe/__init__.py +0 -0
  41. scanprivacy/observe/browser.py +600 -0
  42. scanprivacy/observe/consent.py +273 -0
  43. scanprivacy/observe/discover.py +195 -0
  44. scanprivacy/observe/docs.py +463 -0
  45. scanprivacy/observe/egress.py +178 -0
  46. scanprivacy/observe/guard.py +92 -0
  47. scanprivacy/observe/pages.py +258 -0
  48. scanprivacy/observe/rate.py +35 -0
  49. scanprivacy/observe/robots.py +47 -0
  50. scanprivacy/observe/tls.py +121 -0
  51. scanprivacy/observe/vantage.py +68 -0
  52. scanprivacy/report/__init__.py +0 -0
  53. scanprivacy/report/anchor.py +219 -0
  54. scanprivacy/report/attestation.py +289 -0
  55. scanprivacy/report/badge.py +70 -0
  56. scanprivacy/report/fleet_markdown.py +101 -0
  57. scanprivacy/report/fleet_pdf.py +84 -0
  58. scanprivacy/report/markdown.py +227 -0
  59. scanprivacy/report/pdf.py +273 -0
  60. scanprivacy/report/record.py +174 -0
  61. scanprivacy/report/render.py +449 -0
  62. scanprivacy/report/sarif.py +251 -0
  63. scanprivacy/report/templates/base.css +346 -0
  64. scanprivacy/report/templates/fleet.html +167 -0
  65. scanprivacy/report/templates/fonts/OFL-Inter.txt +93 -0
  66. scanprivacy/report/templates/fonts/OFL-JetBrainsMono.txt +93 -0
  67. scanprivacy/report/templates/fonts/README.md +8 -0
  68. scanprivacy/report/templates/fonts/inter-400-italic.woff2 +0 -0
  69. scanprivacy/report/templates/fonts/inter-400.woff2 +0 -0
  70. scanprivacy/report/templates/fonts/inter-500.woff2 +0 -0
  71. scanprivacy/report/templates/fonts/inter-600.woff2 +0 -0
  72. scanprivacy/report/templates/fonts/inter-700.woff2 +0 -0
  73. scanprivacy/report/templates/fonts/jetbrains-mono-400.woff2 +0 -0
  74. scanprivacy/report/templates/fonts/jetbrains-mono-500.woff2 +0 -0
  75. scanprivacy/report/templates/report.html +567 -0
  76. scanprivacy/rules/PM-AI-01.yml +25 -0
  77. scanprivacy/rules/PM-CNS-01.yml +39 -0
  78. scanprivacy/rules/PM-CNS-02.yml +24 -0
  79. scanprivacy/rules/PM-CNS-03.yml +26 -0
  80. scanprivacy/rules/PM-COK-01.yml +23 -0
  81. scanprivacy/rules/PM-COK-02.yml +23 -0
  82. scanprivacy/rules/PM-COK-03.yml +21 -0
  83. scanprivacy/rules/PM-COK-04.yml +23 -0
  84. scanprivacy/rules/PM-COST-01.yml +22 -0
  85. scanprivacy/rules/PM-DIS-01.yml +28 -0
  86. scanprivacy/rules/PM-DOC-01.yml +21 -0
  87. scanprivacy/rules/PM-DOC-02.yml +22 -0
  88. scanprivacy/rules/PM-DOC-03.yml +24 -0
  89. scanprivacy/rules/PM-DPIA-01.yml +23 -0
  90. scanprivacy/rules/PM-FRM-01.yml +23 -0
  91. scanprivacy/rules/PM-IAM-01.yml +26 -0
  92. scanprivacy/rules/PM-IAM-02.yml +26 -0
  93. scanprivacy/rules/PM-IAM-03.yml +27 -0
  94. scanprivacy/rules/PM-IAM-04.yml +27 -0
  95. scanprivacy/rules/PM-IAM-05.yml +26 -0
  96. scanprivacy/rules/PM-REJ-01.yml +41 -0
  97. scanprivacy/rules/PM-REJ-02.yml +27 -0
  98. scanprivacy/rules/PM-REJ-03.yml +26 -0
  99. scanprivacy/rules/PM-REJ-04.yml +24 -0
  100. scanprivacy/rules/PM-SEC-01.yml +22 -0
  101. scanprivacy/rules/PM-SEC-02.yml +23 -0
  102. scanprivacy/rules/PM-SEC-03.yml +23 -0
  103. scanprivacy/rules/PM-STO-01.yml +23 -0
  104. scanprivacy/rules/PM-STO-02.yml +23 -0
  105. scanprivacy/rules/PM-TRF-01.yml +30 -0
  106. scanprivacy/rules/PM-TRF-02.yml +29 -0
  107. scanprivacy/rules/README.md +132 -0
  108. scanprivacy/scanner.py +468 -0
  109. scanprivacy-0.1.1.dist-info/METADATA +556 -0
  110. scanprivacy-0.1.1.dist-info/RECORD +114 -0
  111. scanprivacy-0.1.1.dist-info/WHEEL +5 -0
  112. scanprivacy-0.1.1.dist-info/entry_points.txt +2 -0
  113. scanprivacy-0.1.1.dist-info/licenses/LICENSE +235 -0
  114. scanprivacy-0.1.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,6 @@
1
+ """scanprivacy — passive EU privacy conformance engine.
2
+
3
+ URL in, findings graph + remediation out.
4
+ """
5
+
6
+ __version__ = "0.1.1"
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
+ }