Local cookie-store counter-intelligence. Parses browser cookie databases, reconstructs what the ad-tech ecosystem can infer about you, and flags concrete threats - all fully offline, on your machine.
Named after the Building from Stanisław Lem's Memoirs Found in a Bathtub: an environment of total surveillance where every artifact may be a message and every message may be a cipher. The tool is named after the adversary it maps.
python gmach.py # scan Chrome / Edge / Brave / Firefox profiles
# -> gmach_report.json, gmach_purge.json,
# gmach_snapshots/<run>.json
python gmach.py --install-timer launchd # weekly snapshot job (written, not enabled)
open dashboard.html # drag & drop the report, or serve both via http.server
Requires Python 3.10+, standard library only. No report yet? The dashboard ships with a seeded synthetic-data preview.
A single scan tells you what is in the cookie store right now. The questions that matter for a tracking audit are longitudinal: has this tracker held the same identifier for months, did a cookie you deleted come back unchanged, is the footprint growing. Those need repeated runs, which is what the snapshot store is for - see Continuity below.
- Python 3.10 or newer, standard library only (
python3 -Vto check). - Optional, only if you want them:
publicsuffix2for exact eTLD+1,cryptographyfor Chromium value decryption on some platforms. Everything runs without them, with the reduced-capability paths noted in the output. - Close nothing. The tool copies the cookie databases before reading, so it works while browsers are open.
python3 gmach.py
This scans the default profile locations for Chrome, Edge, Brave and Firefox and writes three things next to the script:
gmach_report.json- the full report, drag it intodashboard.html.gmach_purge.json- cleanup candidates (stale and tracker cookies).gmach_snapshots/<timestamp>.json- the first snapshot (0600), the baseline for continuity.
No raw cookie values are written. Firefox values are read in memory to compute derived signals and then dropped; Chromium values are not read at all in this mode. This run is safe to do on any machine.
open dashboard.html # macOS; on Linux: xdg-open, or just open the file
Drag gmach_report.json onto the drop zone. Everything is local, no network
call is made. If you prefer a served context so the report auto-loads:
python3 -m http.server 8000 # then visit http://localhost:8000/dashboard.html
Navigate with the tabs or Alt+1..6; / jumps to domain search.
The default scan classifies Chromium cookies by name only, because it never reads their values. That is deliberately conservative, and it is why some findings come back marked name-only with low confidence: the tool is telling you it is guessing from the name. To turn those guesses into measured verdicts:
python3 gmach.py --decrypt
--decrypt reads Chromium values through your own login keychain
(Keychain / DPAPI / libsecret) - it only works as you, on your machine, and asks
the OS credential store for permission. This unlocks: JWT decoding, real token
classification by value shape, PII scanning, entropy, and identifier continuity
for Chromium. Firefox is plaintext and always fully analysed regardless.
On Chrome 127+ with App-Bound Encryption the key is unavailable by design; those values are skipped and reported as such rather than failing.
One scan is a snapshot. Continuity - durable identifiers, respawns, footprint
growth - needs at least two. Either run gmach.py again yourself over the coming
weeks, or install a periodic job:
python3 gmach.py --install-timer app # macOS, recommended: signed wrapper
python3 gmach.py --install-timer launchd # macOS, bare interpreter
python3 gmach.py --install-timer systemd # Linux user timer
python3 gmach.py --install-timer cron # portable crontab line
Each writes the job definition to disk and prints how to enable it. Nothing is
enabled automatically - turning the job on stays an explicit command you run.
On macOS prefer app: it builds a signed GmachSnapshot.app so the scheduled
process has its own identity instead of appearing as a bare python3 (see
Scheduled runs and process identity).
With --decrypt data present, the Pentest tab splits the analysis into two
perspectives that read the same facts differently:
- Attacker - what could be done with each token and the prerequisite for it (an XSS in the domain, a network position, a lured victim). These are attack surface, not confirmed vulnerabilities; the prerequisites are yours to verify.
- Defender - what to change in the session configuration, grouped by fix.
The needs-manual-review list is the honest residue: names that look like
tokens but whose values do not confirm it, usually because the value was
unavailable. Re-run with --decrypt to shrink it.
Every flag, what it does, and when to reach for it:
| Flag | Purpose |
|---|---|
| (no flags) | Safe scan of default profiles. No values read from Chromium. |
--path FILE |
Add a specific SQLite cookie file (repeatable). For a copied profile, a different browser install, or a forensic image. Auto-detects Chromium vs Firefox schema. |
--out FILE |
Report path (default gmach_report.json). The snapshot dir, purge list and history are placed next to it. |
--decrypt |
Read Chromium values via your login keychain to enable JWT decode, value-based classification, PII scan and Chromium continuity. Firefox is always analysed. |
--reveal |
Opt-in, sensitive. Also write masked-but-recoverable raw values to a separate gmach_confidential.json (0600, gitignored). Live secrets - treat as a password file, delete after use. Never enters the main report or history. |
--tracker-list FILE |
Use an external Disconnect services.json for domain categorisation instead of the built-in heuristic list. |
--snapshot-dir DIR |
Where snapshots live (default gmach_snapshots/ beside the report). Point separate scan scopes at separate dirs to keep their timelines clean. |
--no-snapshot |
Skip writing a snapshot this run. For a one-off scan you do not want folded into the timeline. |
--keep N |
How many snapshots to retain (default 120, 0 = unlimited). Oldest are pruned first. |
--durable-days N |
Threshold in days above which an unchanged identifier counts as durable (default 60). Lower it to flag faster, raise it to reduce noise. |
--timeline-out FILE |
Also write just the cross-run aggregation to its own JSON, without re-scanning. |
--install-timer {app,launchd,systemd,cron} |
Write a periodic-snapshot job definition (and, for app, build the signed wrapper). Enables nothing. |
--history-keep N |
Runs retained in gmach_history.jsonl (default 500). Symmetric to --keep for snapshots; compaction is atomic and records that it happened. |
--no-prune |
Skip history compaction on this run. |
--reset-continuity |
Start a new continuity epoch: new salt, old salt destroyed, cross-epoch comparison refused. Asks you to type RESET. |
--yes |
Skip the interactive confirmation. For scheduled jobs only. |
--quiet |
Errors only on stderr. For the scheduled job, so its log stays clean. |
# Full local audit with values, then read the pentest tab
python3 gmach.py --decrypt
# Investigate a copied profile without touching your timeline
python3 gmach.py --path /evidence/Cookies --no-snapshot --out case01.json
# Tighter durability threshold and a capped snapshot history
python3 gmach.py --decrypt --durable-days 30 --keep 52
# Extract live secrets for a one-off token inspection, then delete the file
python3 gmach.py --decrypt --reveal
# ... inspect gmach_confidential.json ...
rm gmach_confidential.json
# Regenerate only the timeline JSON from existing snapshots
python3 gmach.py --no-snapshot --timeline-out timeline.json
The privacy boundary is the point of the design: raw values are read into memory, reduced to derived signals, and dropped. Nothing crosses the dashed line except with an explicit opt-in flag.
flowchart LR
subgraph SRC["Cookie stores (read-only)"]
CH["Chromium family<br/>Chrome / Edge / Brave"]
FF["Firefox<br/>cookies.sqlite"]
end
SNAP["snapshot-copy<br/>0600 temp, incl. WAL"]
CH --> SNAP
FF --> SNAP
SNAP --> META["Metadata<br/>host, flags, timestamps"]
SNAP --> VAL["Values in memory only<br/>Firefox always<br/>Chromium with --decrypt"]
META --> ENG["Findings engine<br/>measured / heuristic / inference"]
VAL --> DERIV["Derived, masked<br/>token type, JWT claims,<br/>PII scan, entropy"]
VAL --> HASH["Keyed hash<br/>HMAC-SHA256 local salt"]
DERIV --> ENG
ENG --> REP["gmach_report.json<br/>value-free"]
ENG --> PURGE["gmach_purge.json"]
HASH --> STORE[("gmach_snapshots/<br/>0600, per run")]
STORE --> AGG["Cross-run aggregation"]
AGG --> ENG
VAL -.->|"--reveal only"| CONF["gmach_confidential.json<br/>0600, gitignored<br/>live secrets"]
REP --> DASH["dashboard.html<br/>offline, zero CDN"]
classDef secret stroke-dasharray:4 3
class CONF secret
- Exposure profile - interest sectors inferred from cookie domains (explicitly labeled as inference, not fact)
- Activity rhythm - 7×24 heatmap from
last_accesstimestamps: the same behavioral pattern every tracker on your list can reconstruct server-side - Sync bursts / tracking seed events - clusters of tracker cookies created within a 10-second window: one page visit seeding a wave of tracking, pointing at seeder sites
- Cross-browser correlation - trackers present in ≥2 browsers, i.e. domains for which browser separation provides no isolation
- Continuity - per-run snapshots aggregated into lanes: which domains were present in which run, whether the identifier stayed the same, and which cookies came back after deletion
- Trend & diff - append-only run history (
gmach_history.jsonl), dual-axis trend across runs, new/removed domains with new trackers highlighted, scan-scope changes marked on the chart - Findings engine - severity-ranked, each finding tagged with provenance:
measured/heuristic/inference - Purge list - actionable
gmach_purge.json(stale + ad-tech candidates); the tool never writes to live browser databases
| id | severity | trigger |
|---|---|---|
| auth-no-secure-critical | high | value or name classifies as session/auth/JWT/OAuth, no Secure |
| auth-no-secure-review | medium | auth-shaped name, no clear token signal - manual review |
| cookie-no-secure-benign | low | CSRF / anti-forgery / known benign UI state |
| durable-identifier | high/medium | same value held across snapshots past the threshold (60d default) |
| identifier-respawn | high | cookie absent for a run, returned with the same value |
| tracker-reappearance | medium | tracker slot returned with a different value |
| tracker-footprint-growth | medium | tracker domain count up >=15% across the window |
| persistent-id-cookies | high | entropy >3.5 bit/char, len >=16, remaining >30d (values only) |
| pii-in-cookies | high | email/phone/uuid/ip found inside cookie values |
| session-hijack-surface | high/medium | auth/session cookies stealable via XSS/network |
| tracking-seed-events | medium/high | >=3 tracker domains created within 10s |
| long-held-handle | medium | tracker slot held for over a year (now - created) |
| samesite-none-insecure | medium | SameSite=None without Secure |
| lifetime-gt-400d | medium | remaining time beyond the Chrome cap |
| adtech-footprint | medium/high | ad-tech domain density |
| fingerprinting-adjacent | medium | device-intelligence / anti-bot domains |
| new-tracker-domains | medium | tracker domains absent in the previous run |
| cross-browser-tracking | medium | same tracker in >=2 browsers |
| jwt-identity-exposure | medium | JWTs carrying decoded identity claims |
| stale-cookies | low | unused >180d, still valid |
| scan-scope-drift | info | the set of scanned sources changed between runs |
| identity-graph | info | cross-service login ecosystems |
Findings whose classification is uncertain now carry a confidence band in their
evidence lines (conf 90 potwierdzone, conf 40 niepewne), and contradicted
name/value pairs are marked SPRZECZNE.
Cookie names lie. X-Contour-Session-Affinity is routing, not a session;
is_user_logged_in=true is a boolean flag, not a token; a UUID is an identifier
even when the name says nothing. Earlier versions matched on the name and
produced false HIGH findings for exactly these cases.
Classification now returns a confidence score, not just a label, by confronting the name with the value:
- The value decides when it can: a JWT structure, a bearer-token prefix, a high-entropy random string, a UUID. These score high regardless of the name.
- The name is a weak signal, checked against the value. Name says
session, value isweb-07(short, low entropy)? That is a contradiction - the verdict is demoted and marked, not reported as a session. - No value available (Chromium without
--decrypt)? The name-only verdict is capped at low confidence and labelled name-only, so you can see which findings are guesses.
The three-way auth-no-secure split is driven by this confidence: HIGH only for
confidence >=60 with a real token type, MEDIUM (review) for uncertain, LOW for
contradicted or known-benign. The confidential summary reports name_only_pct -
what fraction of verdicts rest on the name alone - so the reliability of the run
is visible up front.
With value data present, the report includes a pentest layer that splits the
same facts into two perspectives, because a missing HttpOnly reads differently
depending on who is asking:
- Attacker - what could be done with each token, each vector carrying a prerequisite the attacker must satisfy separately (an XSS in the domain, a network position, a lured victim). This is attack surface, not confirmed vulnerability - the prerequisites are not checked, that is the pentester's job.
- Defender - what to change in the session configuration, grouped by fix, each with its rationale and its cost.
Priority targets rank auth tokens by theft impact and confidence together; the needs-manual-review list is the honest residue of uncertain verdicts. This is a local, offline starting point for a cookie-focused engagement, not a vulnerability scanner.
Every run writes a snapshot to gmach_snapshots/ (0600). Aggregation across
snapshots is what turns a scan into monitoring:
- Identifier persistence - whether a domain holds the same value over time,
and how often it rotates. Tracked through
HMAC-SHA256(local_salt, value)[:16]. The salt is generated once per snapshot directory, stored 0600, never leaves the machine. The hashes are non-reversible and non-portable: they only answer "same as last time, on this machine". Raw values still never touch disk. - Respawn detection - a cookie that disappears and returns with the same value hash. Clearing it did not break the link; the value was restored. The equality is measured, the mechanism (other browser storage, server-side relink, profile sync) is not observable from the cookie store and is labelled inference.
- Scan-scope fingerprint -
scope_keyhashes the set of scanned sources. Adding a browser changes every number without any behaviour changing, so runs across a scope change are marked as not comparable rather than plotted as a trend. - Coverage caveat - continuity only covers cookies whose value is readable:
Firefox always, Chromium only with
--decrypt. Lanes without value coverage are drawn with diagonal hatching rather than implying stability.
What the aggregation distinguishes, run over run:
flowchart TD
A["Cookie slot seen in run N"] --> B{"Present in run N+1?"}
B -->|no| C{"Returns in a later run?"}
B -->|yes| D{"Same value hash?"}
C -->|no| E["Gone"]
C -->|yes| F{"Same hash as before the gap?"}
F -->|yes| G["identifier-respawn HIGH<br/>clearing did not break the link"]
F -->|no| H["tracker-reappearance MEDIUM<br/>re-seeded by normal traffic"]
D -->|yes| I{"Held past threshold?"}
D -->|no| J["Rotation counted<br/>not proof the link broke"]
I -->|yes| K["durable-identifier<br/>HIGH on tracker domains"]
I -->|no| L["Stable, below threshold"]
M["No value coverage<br/>Chromium without --decrypt"] -.-> N["Presence only<br/>hatched lane cells"]
Set it up as a periodic job with --install-timer app|launchd|systemd|cron. The
definition is written to disk; enabling it stays an explicit manual command. On
macOS prefer app - see the next section for why.
A LaunchAgent pointing straight at the interpreter produces a process called
python3.x with no identity of its own. For an EDR sensor that is three weak
signals at once: an unnamed interpreter, persistence via LaunchAgent, and access
to browser credential stores - the pattern generic rules describe as credential
access.
--install-timer app builds a signed wrapper so the job is attributable instead
of anonymous:
flowchart TD
L["launchd"] --> A["GmachSnapshot<br/>Mach-O, ad-hoc signed<br/>local.hbcc.gmach-snapshot<br/>stable cdhash"]
A --> P["python3<br/>gmach.py --quiet"]
P --> S[("gmach_snapshots/")]
A -.-> LI["Login Items & Extensions<br/>shows GmachSnapshot"]
The main bundle executable is a real Mach-O, not a script: a shebang script
would be replaced by the interpreter image at execve, the process would be
called python3 again, and the bundle signature would have no relationship to
what actually runs. The launcher does one thing - posix_spawn the interpreter
and pass the exit code back.
What this does not do: it does not hide that the tool reads cookie stores. That is its function and any sensor will see it. The goal is attribution, so the job can be allowlisted as a deliberate decision rather than reappearing as something new after every interpreter upgrade.
Known limits, stated plainly:
- An ad-hoc signature carries no Team ID. Against a "block unsigned" rule this
is still untrusted code. What it does give is a deterministic
cdhash. - The
cdhashwill not change when Python is upgraded - the wrapper calls the interpreter internally. It will change on any rebuild of the launcher, so a hash-based allowlist entry needs updating after a rebuild. - Interpreter and script paths are compiled in. Moving the repository requires rebuilding the bundle.
- Under launchd the job does not inherit Terminal's TCC grants. If browser
profile directories come back as
Operation not permittedingmach_timer.log, grant Full Disk Access to the bundle.
By default the tool reads only metadata. The confidential layer goes deeper into what cookies actually carry - while keeping raw secrets out of any shareable artifact:
- JWT decode - header + payload of your own tokens (no signature verification): issuer, subject, email, scope, expiry. Shows what services store about you and whom they trust. PII masked.
- Token classifier - session / auth / jwt / oauth_bearer / csrf / opaque_id.
- In-value PII scan - emails, phones, UUIDs, embedded IPs. Masked in the report.
- Theft-impact score (0-100) - per cookie: what an attacker gains if it leaks, weighted by missing HttpOnly (XSS-stealable), missing Secure (network-interceptable), weak SameSite, lifetime.
- Session-hijack surface - auth/session cookies ranked by theft impact.
- Re-identification index - relative signal (inference) of how uniquely identifiable you are across the tracker set + PII/JWT presence.
Privacy architecture - deliberate, and the point:
- The main
gmach_report.json, the run history and the snapshot store never contain raw values. Snapshots hold a keyed hash for continuity only. Confidential data is derived and masked (prefix + length, redacted claims). - Firefox values are plaintext, so the confidential layer runs by default (masked output only).
--decrypt(opt-in) decrypts Chromium values using the current user's own OS credentials - self-scoped, the same operation the browser performs; App-Bound-Encrypted stores return no key and are skipped.--reveal(opt-in) is the only way raw values leave memory: they go to a separategmach_confidential.json, chmod 600, warning-stamped, gitignored. Treat it like a password file: analyze, then delete. A file of live session tokens is a liability, not a feature - the tool is built so you have to ask for it explicitly.
- Chromium decryption is opt-in, not default (
--decrypt). Default analysis runs on metadata. When enabled, it uses the current user's own OS key material and is self-scoped by the OS crypto. - Raw values never enter the main report or history. They leave memory only with
--reveal, into a separate gitignored 0600 file. The shareable report is always value-free. - Snapshot-copy before read - locked SQLite files from a running browser are handled safely; source stores are opened read-only, never modified.
- Provenance on every claim. Tracker classification and naive eTLD+1 are explicitly labeled heuristics; the method in use is stamped in report meta and the dashboard footer.
- Fully offline dashboard. Zero CDN, zero external fonts, zero network calls beyond an optional local report fetch. Works air-gapped.
If --decrypt reports full coverage on Firefox but zero on Chrome, the tool is
not broken - it has hit a deliberate security boundary, and it now says so
explicitly rather than returning a silent empty confidential layer.
Before Chrome 127 (July 2024), the key that encrypts cookie values was bound to
the OS user. On macOS it lived in the login Keychain under "Chrome Safe
Storage"; any process running as that user could ask for it and receive it. That
is the path --decrypt uses. The problem: an infostealer running as the same
user could do exactly the same thing - no cryptography to break, just ask the
system for a key you already have access to. That is precisely how mass
session-theft campaigns worked.
App-Bound Encryption moves the trust boundary from the user to the application. The key is no longer handed to "any process of this user", only to a verified browser binary.
- On Windows, a privileged COM service (
IElevator) unwraps the key and checks that the caller is a signed Chrome at the expected install path. The key inLocal Statecarries anAPPBprefix. - On macOS, the Keychain entry is ACL-bound to Chrome's code-signing
identity. An external process - even as the same user - is refused, silently.
That silent refusal is the
withval=0you may see.
The cipher did not change (still AES-GCM). The access control to the key did: from "you are this user" to "you are this application". It is a shift in the trust model, not stronger math.
Every technique that gets the key anyway - injecting into the Chrome process to inherit its identity, spoofing Chrome to the key-issuing service, or elevating privileges past the application-identity check - is circumventing a security control, which is the definition of offensive activity. GMACH deliberately does not implement any of them. A cookie-audit tool that starts injecting into other processes stops being an audit tool and becomes a stealer with a nicer README. The line is not technical, it is about what the tool is: GMACH accesses values only through the legitimate OS API (ask the Keychain as the user, receive the key or don't), and stops where that path stops.
- Detects the scheme up front.
chromium_key_scheme()reads the key prefix inLocal Statebefore attempting anything:APPB-> App-Bound (undecryptable from outside),DPAPI/v10/v11-> OS-bound (decryptable as the user). - Says so, once, plainly. Instead of a silent zero, the run prints that the browser uses App-Bound Encryption, that this is by design and not a package or permission error, and that Firefox is the way to get value coverage.
- Reports coverage. The report's
value_coverageblock and the dashboard overview banner state how many values were read and why the rest were not, so a reader can tell an App-Bound limitation apart from a missingcryptographypackage or a forgotten--decrypt.
Firefox stores cookie values in the clear in cookies.sqlite, with no App-Bound
barrier. It is not a fallback - it is where GMACH's confidential and pentest
layers work fully and without cryptographic compromise:
python3 gmach.py --path ~/Library/Application\ Support/Firefox/Profiles/<profile>/cookies.sqlite --no-snapshot
Firefox needs neither --decrypt nor cryptography - the values are plaintext.
On a Chrome-only machine running 127+, the metadata, tracking map, continuity and
flag hygiene all still work; only the value-dependent layers (token
classification by value, JWT decode, PII scan, token pentest) require a source
whose values are actually readable.
Domain aggregation depends on collapsing hosts to their registrable domain, and
a wrong answer is invisible: a.b.stooq.pl and stooq.pl either merge or do
not, and every count, lane, and graph node inherits whichever happened. Up to
1.7 the default path was a label-count heuristic with six hardcoded two-level
suffixes.
1.8 resolves eTLD+1 through three tiers, reported in meta.etld:
| Source | When | Provenance |
|---|---|---|
psl-lib |
publicsuffix2 is installed |
measured |
psl-bundled |
default - psl_snapshot.dat in this repo |
measured |
heuristic |
the snapshot file is missing | heuristic |
The bundled list is complete (10239 rules), not trimmed. A trimmed list would
fall back to the heuristic for anything outside the subset, and that failure is
indistinguishable from a correct answer. Nothing is fetched at runtime, which
keeps the offline-first guarantee intact. The snapshot date and age travel in
the report; past 180 days the result is flagged stale.
The ICANN and PRIVATE sections are both applied. For a tracking-analysis tool
this is the right call - it keeps user.github.io separate from other users of
the same host - but it means some cloud provider hostnames are public suffixes
in their own right and therefore have no registrable domain below them.
Because this changes how hosts collapse into domains, the resolver is part of
scope_key. Your first 1.8 run against an existing history will register one
scope change. That is the mechanism working: the numbers moved because the
measurement changed, not because your browsing did.
The snapshot salt is never rotated automatically, because rotating it breaks identifier continuity by design. Until 1.8 there was also no way to start over.
A bare rotation would be worse than no feature: with a new salt every value hash stops matching, and the engine cannot tell that from every provider rotating their identifiers at once. So the salt file now carries an epoch number, snapshots record the epoch they were hashed under, and the aggregator refuses to compare across epochs, reporting how many runs it set aside.
python gmach.py --reset-continuity # type RESET to confirm
python gmach.py --reset-continuity --yes # scheduled jobs
The old salt is destroyed rather than archived. Archiving it would preserve the ability to re-link exactly the identifiers the reset was meant to sever, which defeats the point. Snapshot files from earlier epochs are kept on disk, but they no longer participate in continuity analysis.
The dashboard ships in Polish and English. Switch with the header pill,
Alt+L, or ?lang=en in the URL; the choice is remembered in
localStorage where available, and otherwise falls back to navigator.language.
One catalog is the source of truth for both the engine and the UI
(gmach_i18n.py for findings, gmach_i18n_ui.py for dashboard chrome), injected
into the single-file dashboard at build time:
python tools/build_i18n.py # inject
python tools/build_i18n.py --check # gate: fails if the injected block is stale
python tools/i18n_audit.py # measure coverage on the rendered demo
Coverage is measured rather than claimed. The audit renders the synthetic demo twice, once forced to each language, and diffs the visible text. As of 1.8: 76.5% of visible Polish strings are translated, with zero remaining in the dashboard's own chrome. The remaining 62 are evidence rows, which the engine builds as complete Polish sentences in Python and which therefore cannot be translated client-side. Fixing that properly means giving evidence a structure instead of a rendered sentence; it is logged in TODO rather than papered over.
pip install playwrightthenplaywright install chromium- required only bytools/i18n_audit.py, which measures translation coverage by diffing headless renders. Not needed to run GMACH.pip install cryptography- required for--decryptto read Chromium values on macOS/Linux (v10/v11 AES-GCM). Without it,--decryptsilently falls back to metadata-only and prints a one-line notice. Not needed for Firefox (plaintext) or for App-Bound Chrome (values unavailable regardless - see above).pip install publicsuffix2- upgrades eTLD+1 resolution from the bundled PSL snapshot to a live library (the three-tier fallback is automatic and labeled)python gmach.py --tracker-list services.json- swap the built-in tracker snapshot for a Disconnect-format list
last_accessis overwritten by browsers: the rhythm heatmap is a snapshot of last touches, not full visit history.- Diff compares domain sets between runs. Changing scan scope (different
--path, added browser) reflects scope change, not behavior - keep scope constant for a meaningful trend. Natural usage pattern: a weekly scheduled task. - Third-party status is proxied by domain classification, not by request-context measurement.
- No entropy-based ID detection for Chromium on the default path, because values are not read there;
--decryptenables it. Metadata analysis remains complete either way. - Built-in tracker list is a curated snapshot, not exhaustive - hence the Disconnect hook.
- Continuity requires at least two runs; the first run only establishes a baseline.
total_span_daysis not the server-declared max-age. Browsers preservecreatedacross overwrites, so a rolling-refreshed cookie inflates it without the declaration changing. It answers "how long has this handle existed", which is a different question.- A rotating identifier is not proof that the link was broken - a provider can map the old identifier to the new one server-side. Labelled inference, deliberately left open.
python -m unittest discover -s tests -v
Standard library only.
Three checks run before a release tag. Each is run on its own, never chained and never piped into something whose exit code is then read.
python -m unittest discover -s tests
python tools/build_i18n.py --check # injected i18n block is current
python tools/verify_diagrams.py # README claims match the tree
tools/i18n_audit.py is a measurement, not a gate: it reports coverage rather
than passing or failing, and it needs playwright.
Every test tied to a fixed defect names it in the
docstring, so a regression is recognisable without reading git history.
MIT
