#!/usr/bin/env python3 """How requests-noquery.csv.gz was made from the raw captures. A record, not a step. YOU CANNOT RUN THIS WITH THE PUBLISHED FILES, AND YOU DO NOT NEED TO. It reads the study's raw captures (one JSON file per load, 100 MB+), which are not published because their full request addresses identify the connection the measurement was run from. It is published so that the way the table was derived and redacted is on the record. The published table was made with: python3 build_requests_table.py --raw RAW_DIR --strict --out requests-noquery.csv.gz --report url_scan_report_noquery.json --strict drops every query string, keeping only npa and tfcd on requests to Google's ad server, and replaces every path segment that contains "=" by "REDACTED" (some ad-measurement addresses carry their parameters in the path, as /name=value;name=value). Without --strict the script writes full URLs with only the sensitive parts redacted (requests.csv.gz, 16.6 MB, also not published). If you make your own captures with measure.cjs you can run it on those. One row per request (data: and blob: URLs left out, as in the study): page, portal, load, page_url, seconds, url, resource_type "seconds" is the time since navigation at which the request finished. REDACTION. Ad servers echo the visitor's IP address and IP-derived location back into URLs. A URL part is treated as sensitive when, after percent-decoding it twice, it contains (1) an IPv4 address literal (except x.0.0.0, which is how browser versions are written), (2) a location or IP parameter (lat, lon, zip, postal code, city, dma, *geo_*, *ip) with a value, or (3) any term in private_terms.txt: the measuring connection's address (also as base64, hex or an integer, and as base64 of its four raw bytes) and its city, state, postcode, coordinates, media market, time zone and carrier. That file is not published. A sensitive query string or fragment is replaced whole by "?REDACTED". A sensitive path segment is replaced by "REDACTED". Scheme, host and the rest of the path are never changed, so every count in this kit is unaffected; the build ends by proving that (it re-reads the file it wrote and re-scans it). Standard library only. """ import argparse, base64, collections, csv, getpass, gzip, io, json, os, re, socket, urllib.parse from studylib import HERE, TABLE_COLUMNS, loads_from_raw, registrable SPLIT = re.compile(r"^([A-Za-z][A-Za-z0-9+.-]*://[^/?#]*)([^?#]*)(.*)$", re.S) IPV4 = re.compile(r"(?= 4] n = changed = 0 buf = io.StringIO() w = csv.writer(buf, lineterminator="\n") w.writerow(TABLE_COLUMNS) for load in loads_from_raw(a.raw): for t_ms, url, rtype in sorted(load["requests"], key=lambda r: r[0]): n += 1 d = twice(url) for name, rx in OTHER.items(): if rx.search(d): other[name] += 1 if any(x in d.lower() for x in local): other["local user or machine name"] += 1 new = redact(url, sc, tally) if a.strict: m = SPLIT.match(new) if m and m.group(3): sp = urllib.parse.urlsplit(url) keep = "" if registrable(sp.hostname or "") == "doubleclick.net" and sp.path == "/gampad/ads": q = urllib.parse.parse_qsl(sp.query, keep_blank_values=True) keep = "?" + urllib.parse.urlencode([(k, v) for k, v in q if k in ("npa", "tfcd")]) new = m.group(1) + m.group(2) + keep m = SPLIT.match(new) if m and "=" in m.group(2): # parameters carried in the path segs = m.group(2).split("/") for i, s in enumerate(segs): if "=" in s: segs[i] = "REDACTED" tally[("path segment", "parameter in the path (strict mode)")] += 1 new = m.group(1) + "/".join(segs) + m.group(3) changed += new != url w.writerow([load["page"], load["portal"], load["load"], load["page_url"], f"{t_ms / 1000:.3f}", new, rtype]) data = buf.getvalue().encode("utf-8") with open(a.out, "wb") as fh: with gzip.GzipFile(filename="", mode="wb", fileobj=fh, compresslevel=9, mtime=0) as gz: gz.write(data) # Independent re-scan of what was actually written. left = collections.Counter() rows = 0 with gzip.open(a.out, "rt", encoding="utf-8", newline="") as fh: for row in csv.DictReader(fh): rows += 1 m = SPLIT.match(row["url"]) if not m: continue for part in ("?" + m.group(2), m.group(3)): for why in sc.reasons(part) - ({"location or ip parameter"} if part.startswith("?/") else set()): left[why] += 1 d = twice(row["url"]).lower() if any(x in d for x in local): left["local user or machine name"] += 1 hosts = tally.pop("hosts") report = { "output": os.path.basename(a.out), "mode": "strict" if a.strict else "redacted", "rows": rows, "bytes_compressed": os.path.getsize(a.out), "bytes_uncompressed": len(data), "urls_changed": changed, "private_terms_applied": sc.private_loaded, "redactions": {f"{where}: {why}": c for (where, why), c in sorted(tally.items())}, "redacted_urls_by_host_top20": dict(hosts.most_common(20)), "redacted_urls_distinct_hosts": len(hosts), "other_checks_on_raw_urls": {k: other.get(k, 0) for k in list(OTHER) + ["local user or machine name"]}, "left_after_redaction": dict(left), } json.dump(report, open(a.report or os.path.join(HERE, "url_scan_report.json"), "w"), indent=1) print(json.dumps(report, indent=1)) if left: raise SystemExit("FAILED: sensitive patterns remain in the output; do not publish it.") print(f"OK: nothing matching the redaction rules remains. {a.out}: {os.path.getsize(a.out) / 1e6:.1f} MB") if __name__ == "__main__": main()