paytrace 2.0.2__tar.gz → 2.0.4__tar.gz
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.
- {paytrace-2.0.2 → paytrace-2.0.4}/CHANGELOG.md +20 -0
- {paytrace-2.0.2 → paytrace-2.0.4}/CITATION.cff +1 -1
- {paytrace-2.0.2 → paytrace-2.0.4}/PKG-INFO +1 -1
- {paytrace-2.0.2 → paytrace-2.0.4}/pyproject.toml +1 -1
- {paytrace-2.0.2 → paytrace-2.0.4}/src/paytrace/_version.py +1 -1
- {paytrace-2.0.2 → paytrace-2.0.4}/src/paytrace/collectors/analytics.py +6 -4
- {paytrace-2.0.2 → paytrace-2.0.4}/src/paytrace/collectors/artifacts.py +7 -3
- {paytrace-2.0.2 → paytrace-2.0.4}/src/paytrace/net.py +59 -13
- {paytrace-2.0.2 → paytrace-2.0.4}/src/paytrace/sellersjson.py +101 -5
- {paytrace-2.0.2 → paytrace-2.0.4}/tests/test_artifacts.py +39 -0
- {paytrace-2.0.2 → paytrace-2.0.4}/tests/test_sellersjson.py +86 -0
- {paytrace-2.0.2 → paytrace-2.0.4}/.github/ISSUE_TEMPLATE/new_collector.md +0 -0
- {paytrace-2.0.2 → paytrace-2.0.4}/.gitignore +0 -0
- {paytrace-2.0.2 → paytrace-2.0.4}/CODE_OF_CONDUCT.md +0 -0
- {paytrace-2.0.2 → paytrace-2.0.4}/CONTRIBUTING.md +0 -0
- {paytrace-2.0.2 → paytrace-2.0.4}/LICENSE +0 -0
- {paytrace-2.0.2 → paytrace-2.0.4}/NOTICE +0 -0
- {paytrace-2.0.2 → paytrace-2.0.4}/README.md +0 -0
- {paytrace-2.0.2 → paytrace-2.0.4}/SECURITY.md +0 -0
- {paytrace-2.0.2 → paytrace-2.0.4}/VERIFYING.md +0 -0
- {paytrace-2.0.2 → paytrace-2.0.4}/adversarial/README.md +0 -0
- {paytrace-2.0.2 → paytrace-2.0.4}/adversarial/TRACE_clean.md +0 -0
- {paytrace-2.0.2 → paytrace-2.0.4}/adversarial/TRACE_defended.md +0 -0
- {paytrace-2.0.2 → paytrace-2.0.4}/adversarial/TRACE_diff.md +0 -0
- {paytrace-2.0.2 → paytrace-2.0.4}/adversarial/TRACE_naive.md +0 -0
- {paytrace-2.0.2 → paytrace-2.0.4}/demo/run_demo.py +0 -0
- {paytrace-2.0.2 → paytrace-2.0.4}/docs/GUIDE.md +0 -0
- {paytrace-2.0.2 → paytrace-2.0.4}/examples/end_to_end_domain.py +0 -0
- {paytrace-2.0.2 → paytrace-2.0.4}/examples/reference_collector.py +0 -0
- {paytrace-2.0.2 → paytrace-2.0.4}/src/paytrace/__init__.py +0 -0
- {paytrace-2.0.2 → paytrace-2.0.4}/src/paytrace/adstxt.py +0 -0
- {paytrace-2.0.2 → paytrace-2.0.4}/src/paytrace/adversarial.py +0 -0
- {paytrace-2.0.2 → paytrace-2.0.4}/src/paytrace/agent/__init__.py +0 -0
- {paytrace-2.0.2 → paytrace-2.0.4}/src/paytrace/agent/agent.py +0 -0
- {paytrace-2.0.2 → paytrace-2.0.4}/src/paytrace/agent/fixture_data/__init__.py +0 -0
- {paytrace-2.0.2 → paytrace-2.0.4}/src/paytrace/agent/fixture_data/ads.clean.txt +0 -0
- {paytrace-2.0.2 → paytrace-2.0.4}/src/paytrace/agent/fixture_data/ads.txt +0 -0
- {paytrace-2.0.2 → paytrace-2.0.4}/src/paytrace/agent/fixture_data/sellers.clean.json +0 -0
- {paytrace-2.0.2 → paytrace-2.0.4}/src/paytrace/agent/fixture_data/sellers.poisoned.json +0 -0
- {paytrace-2.0.2 → paytrace-2.0.4}/src/paytrace/agent/fixtures.py +0 -0
- {paytrace-2.0.2 → paytrace-2.0.4}/src/paytrace/agent/guards.py +0 -0
- {paytrace-2.0.2 → paytrace-2.0.4}/src/paytrace/agent/tools.py +0 -0
- {paytrace-2.0.2 → paytrace-2.0.4}/src/paytrace/catalog.py +0 -0
- {paytrace-2.0.2 → paytrace-2.0.4}/src/paytrace/cli.py +0 -0
- {paytrace-2.0.2 → paytrace-2.0.4}/src/paytrace/collectors/__init__.py +0 -0
- {paytrace-2.0.2 → paytrace-2.0.4}/src/paytrace/collectors/base.py +0 -0
- {paytrace-2.0.2 → paytrace-2.0.4}/src/paytrace/collectors/business.py +0 -0
- {paytrace-2.0.2 → paytrace-2.0.4}/src/paytrace/collectors/disclosure.py +0 -0
- {paytrace-2.0.2 → paytrace-2.0.4}/src/paytrace/collectors/infra.py +0 -0
- {paytrace-2.0.2 → paytrace-2.0.4}/src/paytrace/collectors/lookups.py +0 -0
- {paytrace-2.0.2 → paytrace-2.0.4}/src/paytrace/collectors/persona.py +0 -0
- {paytrace-2.0.2 → paytrace-2.0.4}/src/paytrace/collectors/records.py +0 -0
- {paytrace-2.0.2 → paytrace-2.0.4}/src/paytrace/collectors/registries.py +0 -0
- {paytrace-2.0.2 → paytrace-2.0.4}/src/paytrace/collectors/surface.py +0 -0
- {paytrace-2.0.2 → paytrace-2.0.4}/src/paytrace/data/registries.yaml +0 -0
- {paytrace-2.0.2 → paytrace-2.0.4}/src/paytrace/egress.py +0 -0
- {paytrace-2.0.2 → paytrace-2.0.4}/src/paytrace/enrich.py +0 -0
- {paytrace-2.0.2 → paytrace-2.0.4}/src/paytrace/extract.py +0 -0
- {paytrace-2.0.2 → paytrace-2.0.4}/src/paytrace/fingerprint.py +0 -0
- {paytrace-2.0.2 → paytrace-2.0.4}/src/paytrace/index.py +0 -0
- {paytrace-2.0.2 → paytrace-2.0.4}/src/paytrace/ingest.py +0 -0
- {paytrace-2.0.2 → paytrace-2.0.4}/src/paytrace/netsec.py +0 -0
- {paytrace-2.0.2 → paytrace-2.0.4}/src/paytrace/pinned.py +0 -0
- {paytrace-2.0.2 → paytrace-2.0.4}/src/paytrace/pivot.py +0 -0
- {paytrace-2.0.2 → paytrace-2.0.4}/src/paytrace/portfolio.py +0 -0
- {paytrace-2.0.2 → paytrace-2.0.4}/src/paytrace/robin_ingest.py +0 -0
- {paytrace-2.0.2 → paytrace-2.0.4}/tests/test_adstxt.py +0 -0
- {paytrace-2.0.2 → paytrace-2.0.4}/tests/test_adversarial.py +0 -0
- {paytrace-2.0.2 → paytrace-2.0.4}/tests/test_adversarial_example.py +0 -0
- {paytrace-2.0.2 → paytrace-2.0.4}/tests/test_agent.py +0 -0
- {paytrace-2.0.2 → paytrace-2.0.4}/tests/test_catalog.py +0 -0
- {paytrace-2.0.2 → paytrace-2.0.4}/tests/test_cli.py +0 -0
- {paytrace-2.0.2 → paytrace-2.0.4}/tests/test_collectors.py +0 -0
- {paytrace-2.0.2 → paytrace-2.0.4}/tests/test_disclosure.py +0 -0
- {paytrace-2.0.2 → paytrace-2.0.4}/tests/test_egress.py +0 -0
- {paytrace-2.0.2 → paytrace-2.0.4}/tests/test_enrich.py +0 -0
- {paytrace-2.0.2 → paytrace-2.0.4}/tests/test_extract.py +0 -0
- {paytrace-2.0.2 → paytrace-2.0.4}/tests/test_fingerprint.py +0 -0
- {paytrace-2.0.2 → paytrace-2.0.4}/tests/test_index.py +0 -0
- {paytrace-2.0.2 → paytrace-2.0.4}/tests/test_ingest.py +0 -0
- {paytrace-2.0.2 → paytrace-2.0.4}/tests/test_lookups.py +0 -0
- {paytrace-2.0.2 → paytrace-2.0.4}/tests/test_obfuscation_collectors.py +0 -0
- {paytrace-2.0.2 → paytrace-2.0.4}/tests/test_persona_gate.py +0 -0
- {paytrace-2.0.2 → paytrace-2.0.4}/tests/test_records.py +0 -0
- {paytrace-2.0.2 → paytrace-2.0.4}/tests/test_robin_ingest.py +0 -0
- {paytrace-2.0.2 → paytrace-2.0.4}/tests/test_security.py +0 -0
- {paytrace-2.0.2 → paytrace-2.0.4}/tests/test_surface.py +0 -0
- {paytrace-2.0.2 → paytrace-2.0.4}/tests/test_transport.py +0 -0
|
@@ -6,6 +6,26 @@ versioning follows [SemVer](https://semver.org/).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [2.0.4] - 2026-09-23
|
|
10
|
+
|
|
11
|
+
- sellers.json documents larger than the body cap are now streamed and scanned
|
|
12
|
+
instead of truncated. Google's is 104 MB uncompressed, so the payee behind any
|
|
13
|
+
Google-monetised site could never be named — the ad system serving most
|
|
14
|
+
ad-funded sites. Only the matched record is kept; the retrieval stays
|
|
15
|
+
evidenced by a byte count and a SHA-256 taken over the stream.
|
|
16
|
+
- Scan-mode retrievals bypass the cache, which would otherwise serve the
|
|
17
|
+
truncated copy left by an earlier ordinary fetch.
|
|
18
|
+
|
|
19
|
+
## [2.0.3] - 2026-09-23
|
|
20
|
+
|
|
21
|
+
- One AdSense payee is now one node. `ca-pub-N` in page source and `pub-N` in
|
|
22
|
+
ads.txt were captured in different shapes by different collectors, so a single
|
|
23
|
+
account became two graph nodes with its evidence divided between them — on a
|
|
24
|
+
real domain the same ID resolved STRONG_EVIDENCE under one spelling and
|
|
25
|
+
UNSUPPORTED under the other. The `adsense_pub` scheme is gone with it.
|
|
26
|
+
- UA identifiers now key on the account (`UA-1234`), not the property
|
|
27
|
+
(`UA-1234-2`), in every collector.
|
|
28
|
+
|
|
9
29
|
## [2.0.2] - 2026-09-22
|
|
10
30
|
|
|
11
31
|
- `paytrace run` no longer crashes on a real network. It closed its HTTP
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: paytrace
|
|
3
|
-
Version: 2.0.
|
|
3
|
+
Version: 2.0.4
|
|
4
4
|
Summary: Trace a website to the legal entity that gets paid for it
|
|
5
5
|
Project-URL: Homepage, https://github.com/tusharkarumudi/paytrace
|
|
6
6
|
Project-URL: Issues, https://github.com/tusharkarumudi/paytrace/issues
|
|
@@ -37,10 +37,12 @@ from .base import Collector, register
|
|
|
37
37
|
# above infrastructure.
|
|
38
38
|
|
|
39
39
|
ID_PATTERNS: dict[str, tuple[re.Pattern, Reliability]] = {
|
|
40
|
-
# ca-pub-
|
|
41
|
-
|
|
42
|
-
#
|
|
43
|
-
|
|
40
|
+
# One AdSense payee account, written `ca-pub-N` in page source and `pub-N`
|
|
41
|
+
# in ads.txt. ONE pattern, capturing the digits: two patterns under two
|
|
42
|
+
# scheme names split one payee into separate graph nodes, so its evidence
|
|
43
|
+
# was divided and the same ID could come out STRONG_EVIDENCE under one
|
|
44
|
+
# spelling and UNSUPPORTED under the other.
|
|
45
|
+
"adsense": (re.compile(r"\b(?:ca-)?pub-(\d{16})\b"), Reliability.AUTHORITATIVE),
|
|
44
46
|
# G-XXXXXXXXXX GA4 measurement
|
|
45
47
|
"ga4": (re.compile(r"\b(G-[A-Z0-9]{8,12})\b"), Reliability.STRONG),
|
|
46
48
|
# UA-XXXXXXXX-N legacy Universal Analytics; the account part is UA-XXXXXXXX
|
|
@@ -76,10 +76,14 @@ _STAGING = re.compile(
|
|
|
76
76
|
|
|
77
77
|
#: Service identifier schemes worth extracting from source. Each is a pivot.
|
|
78
78
|
_SERVICE_IDS = {
|
|
79
|
-
|
|
79
|
+
# Digits only, matching analytics.py: the `pub-` prefix is spelling, not
|
|
80
|
+
# identity. Keeping it here made one payee two nodes.
|
|
81
|
+
"adsense": re.compile(r"\b(?:ca-)?pub-(\d{16})\b"),
|
|
80
82
|
"ga4": re.compile(r"\b(G-[A-Z0-9]{8,12})\b"),
|
|
81
|
-
|
|
82
|
-
|
|
83
|
+
# The ACCOUNT part only, matching analytics.py. UA-1234-1 and UA-1234-2 are
|
|
84
|
+
# two properties of one account; keeping the suffix split them.
|
|
85
|
+
"ua": re.compile(r"\b(UA-\d{4,10})-\d{1,4}\b"),
|
|
86
|
+
"gtm": re.compile(r"\b(GTM-[A-Z0-9]{5,9})\b"),
|
|
83
87
|
"fbpixel": re.compile(r"fbq\(['\"]init['\"],\s*['\"](\d{15,16})['\"]"),
|
|
84
88
|
"hotjar": re.compile(r"hjid[:=]\s*(\d{6,8})"),
|
|
85
89
|
"intercom": re.compile(r"app_id[\"']?\s*[:=]\s*[\"']([a-z0-9]{8})[\"']"),
|
|
@@ -174,6 +174,8 @@ class Fetcher:
|
|
|
174
174
|
# of the investigation wrote.
|
|
175
175
|
self.url_policy = url_policy or UrlPolicy()
|
|
176
176
|
self.blocked: list[tuple[str, str]] = []
|
|
177
|
+
#: byte count and digest of the last scan-mode retrieval
|
|
178
|
+
self.last_scan: dict | None = None
|
|
177
179
|
# Content varies by the requester's apparent location, so the exit used
|
|
178
180
|
# is part of the evidence rather than a transport detail. A capture
|
|
179
181
|
# that does not record its egress is not reproducible even in
|
|
@@ -281,7 +283,19 @@ class Fetcher:
|
|
|
281
283
|
headers: dict | None = None,
|
|
282
284
|
allow_html: bool = False,
|
|
283
285
|
egress: str = "",
|
|
286
|
+
scanner=None,
|
|
287
|
+
scan_max_bytes: int | None = None,
|
|
284
288
|
) -> Response | None:
|
|
289
|
+
"""Fetch a URL under the full policy, SSRF and pinning guards.
|
|
290
|
+
|
|
291
|
+
``scanner`` switches to SCAN MODE: each decoded chunk is handed to the
|
|
292
|
+
callable and the body is NOT retained. That is what makes a resource
|
|
293
|
+
too large to hold usable -- Google's sellers.json is 104 MB, and
|
|
294
|
+
buffering it would put that in memory, in the cache, and in every
|
|
295
|
+
evidence package. In scan mode the evidence records the byte count and
|
|
296
|
+
a SHA-256 computed over the stream, so the retrieval stays verifiable
|
|
297
|
+
without storing it.
|
|
298
|
+
"""
|
|
285
299
|
# Policy is evaluated BEFORE the cache is consulted. Serving a cached
|
|
286
300
|
# body skipped the check entirely, so `robots_policy: respect` stopped
|
|
287
301
|
# applying the moment a URL had been fetched once -- including by an
|
|
@@ -313,7 +327,12 @@ class Fetcher:
|
|
|
313
327
|
return None
|
|
314
328
|
|
|
315
329
|
cp = self._cache_path(url, headers, egress)
|
|
316
|
-
|
|
330
|
+
# Scan mode must not be served from the cache. The cache holds whatever
|
|
331
|
+
# the capped read produced -- for an oversized body that is a TRUNCATED
|
|
332
|
+
# copy -- and a scan needs the live stream. Serving the cache here made
|
|
333
|
+
# the streaming lookup silently find nothing whenever the URL had been
|
|
334
|
+
# fetched once already.
|
|
335
|
+
if cp.exists() and scanner is None:
|
|
317
336
|
d = json.loads(cp.read_text())
|
|
318
337
|
# A cache hit still has to appear in this run's evidence package.
|
|
319
338
|
# Otherwise a second run consumed retrieved content and produced a
|
|
@@ -375,7 +394,9 @@ class Fetcher:
|
|
|
375
394
|
outcome="refused", note=note)
|
|
376
395
|
return None
|
|
377
396
|
|
|
378
|
-
result = await self._get_following_redirects(
|
|
397
|
+
result = await self._get_following_redirects(
|
|
398
|
+
url, headers, egress=egress,
|
|
399
|
+
scanner=scanner, scan_max_bytes=scan_max_bytes)
|
|
379
400
|
if result is None:
|
|
380
401
|
self._record_evidence(url, None, b"", egress, outcome="error",
|
|
381
402
|
note="no response")
|
|
@@ -472,7 +493,8 @@ class Fetcher:
|
|
|
472
493
|
"the evidence package misleading."
|
|
473
494
|
) from e
|
|
474
495
|
|
|
475
|
-
async def _read_capped(self, response
|
|
496
|
+
async def _read_capped(self, response, scanner=None,
|
|
497
|
+
cap: int | None = None) -> tuple[bytes, bool]:
|
|
476
498
|
"""Read a response body, stopping at ``max_bytes``.
|
|
477
499
|
|
|
478
500
|
``response.content`` materialises the entire body first, so a hostile
|
|
@@ -483,21 +505,44 @@ class Fetcher:
|
|
|
483
505
|
The cap applies to bytes *after* transfer decoding, so a decompression
|
|
484
506
|
bomb is bounded by the same limit.
|
|
485
507
|
"""
|
|
486
|
-
|
|
508
|
+
cap = cap or self.max_bytes
|
|
509
|
+
if scanner is None:
|
|
510
|
+
buf = bytearray()
|
|
511
|
+
truncated = False
|
|
512
|
+
async for chunk in response.aiter_bytes():
|
|
513
|
+
remaining = self.max_bytes - len(buf)
|
|
514
|
+
if remaining <= 0:
|
|
515
|
+
truncated = True
|
|
516
|
+
break
|
|
517
|
+
buf.extend(chunk[:remaining])
|
|
518
|
+
if len(chunk) > remaining:
|
|
519
|
+
truncated = True
|
|
520
|
+
break
|
|
521
|
+
return bytes(buf), truncated
|
|
522
|
+
|
|
523
|
+
# Scan mode: hand each chunk to the scanner and retain nothing. Memory
|
|
524
|
+
# stays flat regardless of body size, which is the whole point.
|
|
525
|
+
import hashlib
|
|
526
|
+
|
|
527
|
+
digest = hashlib.sha256()
|
|
528
|
+
seen = 0
|
|
487
529
|
truncated = False
|
|
488
530
|
async for chunk in response.aiter_bytes():
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
truncated = True
|
|
492
|
-
break
|
|
493
|
-
buf.extend(chunk[:remaining])
|
|
494
|
-
if len(chunk) > remaining:
|
|
531
|
+
if seen + len(chunk) > cap:
|
|
532
|
+
chunk = chunk[: cap - seen]
|
|
495
533
|
truncated = True
|
|
534
|
+
digest.update(chunk)
|
|
535
|
+
seen += len(chunk)
|
|
536
|
+
scanner(chunk)
|
|
537
|
+
if truncated:
|
|
496
538
|
break
|
|
497
|
-
|
|
539
|
+
self.last_scan = {"bytes": seen, "sha256": digest.hexdigest(),
|
|
540
|
+
"truncated": truncated}
|
|
541
|
+
return b"", truncated
|
|
498
542
|
|
|
499
543
|
async def _get_following_redirects(self, url: str, headers: dict | None,
|
|
500
|
-
max_hops: int = 5, egress: str = ""
|
|
544
|
+
max_hops: int = 5, egress: str = "",
|
|
545
|
+
scanner=None, scan_max_bytes: int | None = None):
|
|
501
546
|
"""Follow redirects manually, validating every hop.
|
|
502
547
|
|
|
503
548
|
Returns ``(status, body, encoding, truncated)``.
|
|
@@ -526,7 +571,8 @@ class Fetcher:
|
|
|
526
571
|
try:
|
|
527
572
|
async with client.stream("GET", url, headers=headers) as r:
|
|
528
573
|
if r.status_code not in (301, 302, 303, 307, 308):
|
|
529
|
-
body, truncated = await self._read_capped(
|
|
574
|
+
body, truncated = await self._read_capped(
|
|
575
|
+
r, scanner=scanner, cap=scan_max_bytes)
|
|
530
576
|
return (r.status_code, body, r.encoding, truncated, url,
|
|
531
577
|
safe_headers(r.request.headers),
|
|
532
578
|
safe_headers(r.headers))
|
|
@@ -248,12 +248,108 @@ def find_seller_in_text(body: str, adsystem: str, seller_id: str,
|
|
|
248
248
|
|
|
249
249
|
|
|
250
250
|
async def resolve_seller(fetcher, adsystem: str, seller_id: str) -> SellerRecord | None:
|
|
251
|
-
"""Fetch and locate a seller, trying every known location for the ad system.
|
|
251
|
+
"""Fetch and locate a seller, trying every known location for the ad system.
|
|
252
|
+
|
|
253
|
+
Falls back to a streaming scan when the document is too large to hold.
|
|
254
|
+
Google's sellers.json is 104 MB, so the ordinary body cap truncated it and
|
|
255
|
+
the payee could never be named -- for the ad system that serves the
|
|
256
|
+
majority of ad-funded sites.
|
|
257
|
+
"""
|
|
252
258
|
for url in candidate_urls(adsystem):
|
|
259
|
+
seen = len(getattr(fetcher, "blocked", ()))
|
|
253
260
|
r = await fetcher.get(url)
|
|
254
|
-
|
|
261
|
+
truncated = any("truncated" in why
|
|
262
|
+
for _u, why in list(getattr(fetcher, "blocked", ()))[seen:])
|
|
263
|
+
if r and r.status == 200 and r.text:
|
|
264
|
+
rec = find_seller_in_text(r.text, adsystem, seller_id, url)
|
|
265
|
+
if rec:
|
|
266
|
+
return rec
|
|
267
|
+
if truncated:
|
|
268
|
+
rec = await _stream_at(fetcher, url, adsystem, seller_id)
|
|
269
|
+
if rec:
|
|
270
|
+
return rec
|
|
271
|
+
return None
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
# --------------------------------------------------------------------------- #
|
|
275
|
+
# Streaming lookup, for sellers.json documents too large to hold
|
|
276
|
+
# --------------------------------------------------------------------------- #
|
|
277
|
+
|
|
278
|
+
#: Ceiling for a streamed sellers.json. Google's is 104 MB uncompressed, so the
|
|
279
|
+
#: ordinary 10 MB body cap truncated it and the payee could never be named.
|
|
280
|
+
#: Nothing is retained in scan mode, so this bounds time, not memory.
|
|
281
|
+
STREAM_MAX_BYTES = 512 * 1024 * 1024
|
|
282
|
+
|
|
283
|
+
#: Bytes kept between chunks so a record split across a boundary is still found.
|
|
284
|
+
#: sellers.json records are a few hundred bytes; this is generous.
|
|
285
|
+
_WINDOW = 64 * 1024
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
class _RecordScanner:
|
|
289
|
+
"""Find one seller record in a stream, without holding the stream."""
|
|
290
|
+
|
|
291
|
+
def __init__(self, seller_id: str) -> None:
|
|
292
|
+
self._needle = f'"{seller_id}"'.encode()
|
|
293
|
+
self._buf = b""
|
|
294
|
+
self.record: str | None = None
|
|
295
|
+
|
|
296
|
+
def feed(self, chunk: bytes) -> None:
|
|
297
|
+
if self.record is not None:
|
|
298
|
+
return
|
|
299
|
+
self._buf += chunk
|
|
300
|
+
i = self._buf.find(self._needle)
|
|
301
|
+
if i != -1:
|
|
302
|
+
found = _enclosing_object(self._buf, i)
|
|
303
|
+
if found is not None:
|
|
304
|
+
self.record = found
|
|
305
|
+
self._buf = b""
|
|
306
|
+
return
|
|
307
|
+
if len(self._buf) > _WINDOW:
|
|
308
|
+
self._buf = self._buf[-_WINDOW:]
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _enclosing_object(buf: bytes, pos: int) -> str | None:
|
|
312
|
+
"""The JSON object containing ``pos``, or None if it is not complete yet."""
|
|
313
|
+
start = buf.rfind(b"{", 0, pos)
|
|
314
|
+
if start == -1:
|
|
315
|
+
return None
|
|
316
|
+
depth, in_str, esc = 0, False, False
|
|
317
|
+
for j in range(start, len(buf)):
|
|
318
|
+
c = buf[j : j + 1]
|
|
319
|
+
if esc:
|
|
320
|
+
esc = False
|
|
255
321
|
continue
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
322
|
+
if c == b"\\":
|
|
323
|
+
esc = True
|
|
324
|
+
continue
|
|
325
|
+
if c == b'"':
|
|
326
|
+
in_str = not in_str
|
|
327
|
+
continue
|
|
328
|
+
if in_str:
|
|
329
|
+
continue
|
|
330
|
+
if c == b"{":
|
|
331
|
+
depth += 1
|
|
332
|
+
elif c == b"}":
|
|
333
|
+
depth -= 1
|
|
334
|
+
if depth == 0:
|
|
335
|
+
return buf[start : j + 1].decode("utf-8", errors="replace")
|
|
259
336
|
return None
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
async def _stream_at(fetcher, url: str, adsystem: str, seller_id: str):
|
|
340
|
+
scanner = _RecordScanner(seller_id)
|
|
341
|
+
await fetcher.get(url, scanner=scanner.feed, scan_max_bytes=STREAM_MAX_BYTES)
|
|
342
|
+
if scanner.record is None:
|
|
343
|
+
return None
|
|
344
|
+
return find_seller_in_text('{"sellers":[' + scanner.record + "]}",
|
|
345
|
+
adsystem, seller_id, url)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
async def stream_find_seller(fetcher, adsystem: str, seller_id: str):
|
|
349
|
+
"""Look up one seller in a sellers.json of any size.
|
|
350
|
+
|
|
351
|
+
Streams the document, matches the record as it passes, and keeps only that
|
|
352
|
+
record. The retrieval stays evidenced: the fetcher records the byte count
|
|
353
|
+
and a SHA-256 taken over the stream.
|
|
354
|
+
"""
|
|
355
|
+
return await _stream_at(fetcher, sellers_json_url(adsystem), adsystem, seller_id)
|
|
@@ -200,3 +200,42 @@ async def test_staging_subdomains_become_pivots():
|
|
|
200
200
|
claims = list(await c.collect(Identifier(IdKind.DOMAIN, "x.example")))
|
|
201
201
|
subs = [x.object.value for x in claims if x.object.kind is IdKind.DOMAIN]
|
|
202
202
|
assert "staging.realsite.example" in subs
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
# ---- one identifier, one node --------------------------------------------- #
|
|
206
|
+
|
|
207
|
+
def test_collectors_agree_on_the_canonical_identifier():
|
|
208
|
+
"""`ca-pub-N` in page source and `pub-N` in ads.txt are one payee account.
|
|
209
|
+
|
|
210
|
+
Two collectors captured it in different shapes — digits in one, `pub-`
|
|
211
|
+
included in the other — so a single account became two graph nodes. Its
|
|
212
|
+
evidence was split between them, and on a real domain the same ID came out
|
|
213
|
+
STRONG_EVIDENCE under one spelling and UNSUPPORTED under the other.
|
|
214
|
+
"""
|
|
215
|
+
from paytrace.collectors.analytics import ID_PATTERNS
|
|
216
|
+
from paytrace.collectors.artifacts import _SERVICE_IDS
|
|
217
|
+
|
|
218
|
+
digits = "5446113378742009"
|
|
219
|
+
for text in (f'src="//x/adsbygoogle.js?client=ca-pub-{digits}"',
|
|
220
|
+
f"google.com, pub-{digits}, DIRECT"):
|
|
221
|
+
a = ID_PATTERNS["adsense"][0].search(text)
|
|
222
|
+
b = _SERVICE_IDS["adsense"].search(text)
|
|
223
|
+
assert a and b and a.group(1) == b.group(1) == digits, text
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def test_ua_identifiers_use_the_account_not_the_property():
|
|
227
|
+
"""UA-1234-1 and UA-1234-2 are two properties of one account. Keeping the
|
|
228
|
+
property suffix in one collector and not the other split the account."""
|
|
229
|
+
from paytrace.collectors.analytics import ID_PATTERNS
|
|
230
|
+
from paytrace.collectors.artifacts import _SERVICE_IDS
|
|
231
|
+
|
|
232
|
+
for pattern in (ID_PATTERNS["ua"][0], _SERVICE_IDS["ua"]):
|
|
233
|
+
assert pattern.search("UA-12345678-3").group(1) == "UA-12345678"
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def test_no_second_scheme_name_for_one_service():
|
|
237
|
+
"""`adsense_pub` was a separate scheme for the same accounts, which splits
|
|
238
|
+
a payee by scheme name rather than by value."""
|
|
239
|
+
from paytrace.collectors.analytics import ID_PATTERNS
|
|
240
|
+
|
|
241
|
+
assert "adsense_pub" not in ID_PATTERNS
|
|
@@ -165,3 +165,89 @@ def test_ext_field_survives_as_untrusted_extra():
|
|
|
165
165
|
rec = find_seller_in_text(body, "x.example", "1")
|
|
166
166
|
assert "ext" in rec.extra
|
|
167
167
|
assert "terminate" in rec.extra["ext"]
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
# ---- sellers.json documents too large to hold ------------------------------ #
|
|
171
|
+
|
|
172
|
+
def test_scanner_finds_a_record_split_across_chunks():
|
|
173
|
+
"""Google's sellers.json is 104 MB; it arrives in chunks and a record can
|
|
174
|
+
straddle any boundary."""
|
|
175
|
+
import json as _json
|
|
176
|
+
|
|
177
|
+
from paytrace.sellersjson import _RecordScanner
|
|
178
|
+
|
|
179
|
+
target = _json.dumps({"seller_id": "156423", "name": "Example Media Holdings Ltd"})
|
|
180
|
+
doc = ('{"sellers":['
|
|
181
|
+
+ ",".join(_json.dumps({"seller_id": str(i), "name": f"Other {i}"})
|
|
182
|
+
for i in range(200))
|
|
183
|
+
+ "," + target + "]}").encode()
|
|
184
|
+
for size in (1, 7, 512, 65536):
|
|
185
|
+
scanner = _RecordScanner("156423")
|
|
186
|
+
for i in range(0, len(doc), size):
|
|
187
|
+
scanner.feed(doc[i:i + size])
|
|
188
|
+
assert scanner.record, f"missed at chunk size {size}"
|
|
189
|
+
assert _json.loads(scanner.record)["name"] == "Example Media Holdings Ltd"
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def test_scanner_reports_nothing_for_an_absent_seller():
|
|
193
|
+
"""It must not return a neighbouring record."""
|
|
194
|
+
import json as _json
|
|
195
|
+
|
|
196
|
+
from paytrace.sellersjson import _RecordScanner
|
|
197
|
+
|
|
198
|
+
doc = ('{"sellers":[' + _json.dumps({"seller_id": "1", "name": "A"}) + "]}").encode()
|
|
199
|
+
scanner = _RecordScanner("999")
|
|
200
|
+
scanner.feed(doc)
|
|
201
|
+
assert scanner.record is None
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def test_oversized_sellers_json_is_streamed_and_resolved(monkeypatch):
|
|
205
|
+
"""The ordinary 10 MB cap truncates Google's sellers.json, so the payee
|
|
206
|
+
could never be named — for the ad system serving most ad-funded sites.
|
|
207
|
+
Nothing is retained: the body is scanned as it arrives."""
|
|
208
|
+
import asyncio
|
|
209
|
+
import json as _json
|
|
210
|
+
|
|
211
|
+
import httpx
|
|
212
|
+
|
|
213
|
+
import paytrace.net as net
|
|
214
|
+
from paytrace.sellersjson import resolve_seller
|
|
215
|
+
|
|
216
|
+
target = _json.dumps({"seller_id": "pub-5446113378742009",
|
|
217
|
+
"name": "Example Media Holdings Ltd",
|
|
218
|
+
"domain": "x.example", "seller_type": "PUBLISHER"})
|
|
219
|
+
filler = ",".join(_json.dumps({"seller_id": str(i), "name": f"F{i}" * 40})
|
|
220
|
+
for i in range(60000))
|
|
221
|
+
doc = ('{"sellers":[' + filler + "," + target + "]}").encode()
|
|
222
|
+
assert len(doc) > 10 * 1024 * 1024, "the body must exceed the cap to be a test"
|
|
223
|
+
|
|
224
|
+
def handler(req):
|
|
225
|
+
if req.url.path == "/robots.txt":
|
|
226
|
+
return httpx.Response(200, text="User-agent: *\nAllow: /\n")
|
|
227
|
+
if req.url.path.endswith("sellers.json"):
|
|
228
|
+
return httpx.Response(200, content=doc,
|
|
229
|
+
headers={"content-type": "application/json"})
|
|
230
|
+
return httpx.Response(404)
|
|
231
|
+
|
|
232
|
+
original = net.Fetcher.__init__
|
|
233
|
+
|
|
234
|
+
def patched(self, *a, **k):
|
|
235
|
+
k["resolver"] = lambda host: ["93.184.216.34"]
|
|
236
|
+
original(self, *a, **k)
|
|
237
|
+
for route in self.egress.egresses:
|
|
238
|
+
self._clients[route.label] = httpx.AsyncClient(
|
|
239
|
+
transport=httpx.MockTransport(handler), follow_redirects=False)
|
|
240
|
+
|
|
241
|
+
monkeypatch.setattr(net.Fetcher, "__init__", patched)
|
|
242
|
+
|
|
243
|
+
async def go():
|
|
244
|
+
f = net.Fetcher(user_agent="t")
|
|
245
|
+
rec = await resolve_seller(f, "google.com", "pub-5446113378742009")
|
|
246
|
+
scan = f.last_scan
|
|
247
|
+
await f.aclose()
|
|
248
|
+
return rec, scan
|
|
249
|
+
|
|
250
|
+
rec, scan = asyncio.run(go())
|
|
251
|
+
assert rec and rec.name == "Example Media Holdings Ltd"
|
|
252
|
+
assert scan and scan["bytes"] == len(doc), "the whole body must be streamed"
|
|
253
|
+
assert scan["sha256"], "the retrieval must stay evidenced by a digest"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|