Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning: [S

### Added

- Noise mode now **learns WAR paths** from each origin's own probes and answers those exact files for persona IDs on later visits. LinkedIn-style `{id, file}` AED probes (e.g. `/inpage.js`, `/src/css/content.css`) are poisoned without answering first-visit path canaries. Learned paths are query-stripped, charset-limited, capped at 8 per ID, omitted from research exports, and never store website URLs.
- DOM/global marker coverage for KeePassXC, Dark Reader, Bitwarden overlay (`data-bw-*`), and LanguageTool, closing Castle-style side-effect detection gaps.
- KeePassXC-Browser (`oboonakemofpalcgghocfoadofidjkkk`) in the `password_manager` conflict slot, with an ID-seeded Noise manifest name.
- DNR fingerprint vendor rules for LexisNexis Risk (`lexisnexisrisk.com`), Stytch device telemetry (`telemetry.stytch.com` only, not the auth product), Group-IB RU (`group-ib.ru`), IPFingerprint (`ipfingerprint.com`), Socure IO (`socure.io`), and Nethone (`nethone.com`) (rule IDs 77–82).
- Playwright coverage for learned-path Noise decoys, path canaries, path caps, research-export omission, and new DOM markers (`tests/noise-learned-paths.spec.js`).

- Noise-mode decoy `manifest.json` bodies are now **ID-seeded**: known store IDs return real-looking extension names (e.g. Bitwarden, uBlock Origin), and unknown IDs get a stable per-ID name/version so multi-ID probe dumps no longer collapse to one generic `"Browser Extension"` string.
- Noise fetch/XHR image decoys now serve format-matched bodies and `Content-Type` for PNG, GIF, JPEG, and SVG (path extension, headers, and magic bytes stay aligned). Formats Static cannot synthesize correctly (e.g. WebP) stay fail-closed.
- DNR fingerprint vendor rules for HUMAN legacy `whiteops.com`, Sift root `sift.com`, Accertify (`accertify.com`), and Group-IB (`group-ib.com`) (rule IDs 73–76).
Expand All @@ -17,8 +23,9 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning: [S

- Documented that Static is now published on [Firefox Add-ons](https://addons.mozilla.org/en-US/firefox/addon/privacystatic/) (badge + install instructions in the README).
- Pre-commit (husky) now runs format + lint only; full `test:ci` and Firefox package builds (system `zip`) stay in GitHub Actions (`validate.yml`).
- `rules/META.json` fingerprint_vendors bumped to 1.7.0 (`last_verified` 2026-07-13).
- `docs/noise-behavior.md` documents the ID-seeded manifest contract and image magic-byte fail-closed policy.
- `rules/META.json` fingerprint_vendors bumped to 1.8.0 (`last_verified` 2026-08-21).
- `docs/noise-behavior.md` documents the ID-seeded manifest contract, image magic-byte fail-closed policy, and learned WAR-path Noise contract.
- Firefox smoke CI starts the browser with `--remote-allow-system-access` so WebDriver can still open `about:debugging` and `moz-extension://` pages on Firefox 153+.

## [2.4.0] — 2026-07-09

Expand Down
8 changes: 4 additions & 4 deletions PRIVACY.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Static — Privacy Policy

**Last updated:** July 5, 2026
**Last updated:** August 21, 2026

Static is a Chrome and Firefox extension that blocks websites from fingerprinting which browser extensions you have installed, and blocks known client-side fingerprinting / anti-bot vendor endpoints at the network layer.

Expand All @@ -19,7 +19,7 @@ This policy describes exactly what information Static processes on your machine,

Static stores this local state in your browser profile and never writes to `chrome.storage.sync`, so nothing is synced off your device through Chrome's sync service:

1. **Probe log** — a per-origin map of extension IDs that each site has probed you for, with counts. Capped at 100 origins × 2,000 IDs per origin; older entries are evicted beyond that cap.
1. **Probe log** — a per-origin map of extension IDs that each site has probed you for, with counts, plus a capped map of the extension-resource pathnames those probes used (for Noise-mode learned-path decoys). Pathnames are extension WAR paths such as `/inpage.js`, never website URLs. Capped at 100 origins × 2,000 IDs per origin × 8 paths per ID; older entries are evicted beyond that cap.
2. **Since-install probe counter** — the total number of extension-enumeration probes blocked since you installed Static.
3. **User secret** — a random 256-bit value generated once, at install time, via `crypto.getRandomValues`. Used only to seed the per-origin decoy personas for Noise mode so that different Static users produce different decoys on the same site. Never displayed anywhere in the UI, never transmitted.
4. **Preferences** — whether Noise mode is enabled, which Replay poisoning mode is selected (`off`, `mask`, `noise`, or `chaos`), and which DNR rulesets (fingerprinting vendors, CAPTCHA vendors) you have turned on. Noise and Replay poisoning preferences are stored in `chrome.storage.local`; DNR ruleset choices are persisted locally by Chrome's extension ruleset API.
Expand All @@ -33,7 +33,7 @@ You can erase the probe log, playbook summaries, replay detection log, adaptive
## What Static does NOT store or access

- Page content, form inputs, passwords, cookies, or local storage of any website.
- Full URLs or your browsing history. The probe log aggregates at the **origin** level (e.g. `https://example.com`), never the URL / path level.
- Full website URLs or your browsing history. The probe log aggregates at the **origin** level (e.g. `https://example.com`). Extension-resource pathnames stored for Noise learning are WAR paths inside `chrome-extension://` IDs (for example `/inpage.js`), never the page URL you visited.
- Your IP address, device fingerprint, or any identifier tied to you personally.
- Any personally identifiable information.

Expand All @@ -60,7 +60,7 @@ Static's only network-related actions are **blocking** narrow fingerprinting or
The log viewer offers two export options and one clipboard copy option. Downloads are saved to your computer via the browser's native download mechanism; clipboard copies stay local until you paste them somewhere. Nothing is transmitted by Static.

- **Export raw log** — full fidelity, including per-origin `lastUpdated` timestamps, exact per-ID probe counts, weekly playbook summaries, replay detection summaries, adaptive behavior summaries, and the since-install cumulative counter. Intended for private archival. If you choose to share this file, be aware that timestamps plus exact counts can be correlated with similar dumps from other users to partially re-identify individual browsing patterns.
- **Export for research** — anonymized. Replaces the precise `exportedAt` timestamp with a coarse `exportMonth` (`"YYYY-MM"`), drops per-origin `lastUpdated` timestamps, drops the since-install cumulative counter, coarsens per-ID counts into log-scale buckets (`2-5`, `6-20`, `21-100`, `101-1000`, `1000+`), drops IDs probed fewer than 2 times (canary filter), drops origins with fewer than 3 surviving IDs (low-signal noise filter), and replaces origin/extension-ID labels with per-export salted hashes. The salt is not retained in the file, so labels are not stable across exports.
- **Export for research** — anonymized. Replaces the precise `exportedAt` timestamp with a coarse `exportMonth` (`"YYYY-MM"`), drops per-origin `lastUpdated` timestamps, drops the since-install cumulative counter, coarsens per-ID counts into log-scale buckets (`2-5`, `6-20`, `21-100`, `101-1000`, `1000+`), drops IDs probed fewer than 2 times (canary filter), drops origins with fewer than 3 surviving IDs (low-signal noise filter), replaces origin/extension-ID labels with per-export salted hashes, and omits learned WAR pathnames. The salt is not retained in the file, so labels are not stable across exports.
- **Copy issue report** — anonymized and bounded for GitHub issues. Replaces site origins, extension IDs, and replay-signal labels with per-copy salted hashes, keeps coarse probe vectors/path kinds and recent diagnostic event types, omits local timestamps, omits full site URLs, and does not retain the salt.

Static does not retain a copy of any export or copied issue report. Once the file is downloaded or text is copied, only you have it.
Expand Down
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,19 +145,20 @@ The design, cost model, calibration requirements, and future blocking stages are
- **Stable per origin.** Each origin gets a persona of 3 to 8 IDs, deterministic from `hash(user_secret + origin + week)`. The persona stays stable for a week so you don't look like a bot changing extensions every pageview, then rotates. Different users get different sets because the secret is random per install.
- **Conflict-aware.** IDs are bucketed into slots (password manager, ad blocker, grammar, web3 wallet, devtools, translator) and the persona picks at most one per slot. This avoids obvious tells like "three password managers installed."
- **Canary-resistant.** Known plausible extension IDs enter the replay pool only after Static has seen them probed at least twice on that origin. Unknown extension-shaped IDs need stronger repeated evidence before Static will claim them, so cheap two-hit canaries do not become part of Static's own persona.
- **Learned WAR paths.** Sites like LinkedIn do not fetch `manifest.json`; they fetch the specific `web_accessible_resources` file they catalogued for each ID. Static learns those pathnames from the site's own probes and, on later visits, answers only the paths that origin already asked for. A random `*.png` canary on an otherwise eligible ID still fails.
- **Cold start is honest.** First visit to a site produces no poisoning because there's nothing logged yet. From the second pageview onward, the site gets noise.
- **Decoy responses by plausible path.** Static-read `GET` / `HEAD` probes answer only for a conservative allowlist of plausible extension resource paths: `manifest.json`, common icon names, and common entrypoint-like `content.js` / `page.html` / `style.css` paths. Suspicious supported-suffix canaries like random `*.png` or `*.js` names stay blocked.
- **Decoy responses by plausible path.** Static-read `GET` / `HEAD` probes answer for a conservative allowlist of plausible extension resource paths (`manifest.json`, common icon names, common entrypoint-like `content.js` / `page.html` / `style.css`) **and** for WAR paths that origin has already probed at least twice for that persona ID. Suspicious first-visit canaries like random `*.png` or `*.js` names stay blocked.

**Scope in v2.1:** Noise mode decoys `fetch`, `XMLHttpRequest`, and passive element probes for eligible persona IDs. Images, `srcset` candidates, image inputs, video posters, scripts, and stylesheets receive small inert data-URL resources. Page-visible `src` / `srcset` / `href` / `data` / `poster` getters, attribute-node APIs, clones, and HTML/XML serialization still report the original extension URL. Active surfaces with larger behavioral footprints stay fail-closed: frames, navigation-adjacent URLs, form submissions, audio constructors, CSSOM `@import` rules, style text, CSS declaration URLs set through `setProperty` / `cssText`, `iframe`, media streams, track files, `Worker`, `SharedWorker`, worklet `addModule`, `EventSource`, and `serviceWorker.register`.
**Scope in v2.1:** Noise mode decoys `fetch`, `XMLHttpRequest`, and passive element probes for eligible persona IDs. Images, `srcset` candidates, image inputs, video posters, scripts, and stylesheets receive small inert data-URL resources. Page-visible `src` / `srcset` / `href` / `data` / `poster` getters, attribute-node APIs, clones, and HTML/XML serialization still report the original extension URL. Active surfaces with larger behavioral footprints stay fail-closed: frames, navigation-adjacent URLs, form submissions, audio constructors, CSSOM `@import` rules, style text, CSS declaration URLs set through `setProperty` / `cssText`, `iframe`, media streams, track files, `Worker`, `SharedWorker`, worklet `addModule`, `EventSource`, and `serviceWorker.register`. Decoy eligibility is path-aware: allowlisted icon/entrypoint names plus origin-learned WAR paths that have been probed at least twice for that persona ID.

The cross-vector behavior contract is documented in `docs/noise-behavior.md`.

**Privacy:** Probe logs are kept locally in `chrome.storage.local`. Capped at 100 origins × 2,000 IDs each, with weekly playbook summaries capped to the latest 10 weeks. Nothing leaves your machine unless you explicitly export.
**Privacy:** Probe logs are kept locally in `chrome.storage.local`. Capped at 100 origins × 2,000 IDs each × 8 learned WAR paths per ID, with weekly playbook summaries capped to the latest 10 weeks. Nothing leaves your machine unless you explicitly export.

Two export formats and one clipboard report are available in the log viewer (click **View probe log** in the popup):

- **Export raw log**: full detail. Contains per-origin timestamps (`lastUpdated`), exact probe counts, weekly playbook summaries, your since-install cumulative counter, and the precise `exportedAt` moment. This is fine for your own archive but **should not be published**. Timestamp + count patterns can cross-correlate users across sites if multiple raw dumps from different users ever end up in the same hands.
- **Export for research**: anonymized. Replaces precise `exportedAt` with a coarse `"exportMonth": "YYYY-MM"` bucket, drops per-origin `lastUpdated`, drops the `cumulative` counter, coarsens per-ID counts into log-scale buckets (`"2-5"`, `"6-20"`, `"21-100"`, `"101-1000"`, `"1000+"`), drops any ID that was probed fewer than 2 times (canary filter), drops any origin with fewer than 3 surviving IDs (low-signal noise), and replaces origin/extension-ID labels with per-export salted hashes. Safer to publish, but intentionally less useful for cross-user correlation than the raw log.
- **Export for research**: anonymized. Replaces precise `exportedAt` with a coarse `"exportMonth": "YYYY-MM"` bucket, drops per-origin `lastUpdated`, drops the `cumulative` counter, coarsens per-ID counts into log-scale buckets (`"2-5"`, `"6-20"`, `"21-100"`, `"101-1000"`, `"1000+"`), drops any ID that was probed fewer than 2 times (canary filter), drops any origin with fewer than 3 surviving IDs (low-signal noise), replaces origin/extension-ID labels with per-export salted hashes, and omits learned WAR pathnames. Safer to publish, but intentionally less useful for cross-user correlation than the raw log.
- **Copy issue report**: anonymized and bounded for GitHub issues. It hashes site origins, extension IDs, and replay-signal labels, omits local timestamps and full site URLs, and keeps the coarse vectors/path kinds needed to debug protection gaps or compatibility reports.

Noise mode is **off by default**. Turning it on is an active choice to shift Static from pure defense to counter-intelligence. Toggle it from the popup.
Expand Down
54 changes: 47 additions & 7 deletions block.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
const COMPAT_SIGNAL_THROTTLE_MS = 15000;
const blockedFetchPromises = new WeakMap();
let persona = new Set();
let personaPaths = new Map();
let noiseEnabled = false;
let disabled = false;
let lastCompatSignalAt = 0;
Expand All @@ -16,6 +17,21 @@
if (Array.isArray(data.persona)) {
persona = new Set(data.persona.filter((id) => typeof id === "string"));
}
if (data.personaPaths && typeof data.personaPaths === "object") {
const next = new Map();
for (const [id, paths] of Object.entries(data.personaPaths)) {
if (typeof id !== "string" || !Array.isArray(paths)) continue;
next.set(
id.toLowerCase(),
new Set(
paths.filter((path) => typeof path === "string").map((path) => path.toLowerCase())
)
);
}
personaPaths = next;
} else if (Array.isArray(data.persona)) {
personaPaths = new Map();
}
if (typeof data.noiseEnabled === "boolean") {
noiseEnabled = data.noiseEnabled;
}
Expand Down Expand Up @@ -99,6 +115,7 @@
fooolghllnmhmmndgjiamiiodkpenpbb: "RoboForm Password Manager",
bmikpgodpkclnkgmnpphehdgcimmided: "NordPass® Password Manager & Digital Vault",
cjnlpnbkjbnmdieljmighbdoljmgfibk: "Proton Pass: Free Password Manager",
oboonakemofpalcgghocfoadofidjkkk: "KeePassXC-Browser",
dhdgffkkebhmkfjojejmpbldmpobfkfo: "Tampermonkey",
clngdbkpkpeebahjckkjfobafhncgmne: "Stylus",
bkdgflcldnnnapblkhphbgpggdiikppg: "DuckDuckGo Privacy Essentials",
Expand Down Expand Up @@ -292,25 +309,38 @@

const matchesPathPattern = U.matchesPathPattern;

const isLearnedPersonaPath = (url) => {
const id = U.extractExtId(url);
if (!id) return false;
const learned = personaPaths.get(id);
if (!learned) return false;
const pathname = U.sanitizeExtensionPath(pathForDecoy(url));
return pathname ? learned.has(pathname) : false;
};

const allowlistedOrLearnedKind = (url, pathname, patterns, kind) => {
if (matchesPathPattern(pathname, patterns) || isLearnedPersonaPath(url)) return kind;
return null;
};

const decoyKindForPath = (url) => {
const pathname = pathForDecoy(url);
if (!pathname) return null;
if (pathname.endsWith("/manifest.json")) return "manifest";
if (/\.(png|jpe?g|gif|webp|ico|bmp|svg)$/i.test(pathname)) {
if (!matchesPathPattern(pathname, IMAGE_DECOY_PATHS)) return null;
// Only claim image kinds we can answer with matching magic bytes.
return imageDecoyForPath(pathname) ? "image" : null;
const kind = allowlistedOrLearnedKind(url, pathname, IMAGE_DECOY_PATHS, "image");
return kind && imageDecoyForPath(pathname) ? "image" : null;
}
if (pathname.endsWith(".js") || pathname.endsWith(".mjs")) {
return matchesPathPattern(pathname, SCRIPT_DECOY_PATHS) ? "script" : null;
return allowlistedOrLearnedKind(url, pathname, SCRIPT_DECOY_PATHS, "script");
}
if (pathname.endsWith(".html") || pathname.endsWith(".htm")) {
return matchesPathPattern(pathname, HTML_DECOY_PATHS) ? "html" : null;
return allowlistedOrLearnedKind(url, pathname, HTML_DECOY_PATHS, "html");
}
if (pathname.endsWith(".css")) {
return matchesPathPattern(pathname, STYLE_DECOY_PATHS) ? "style" : null;
return allowlistedOrLearnedKind(url, pathname, STYLE_DECOY_PATHS, "style");
}
return null;
return isLearnedPersonaPath(url) ? U.learnedDecoyKindForPath(pathname) : null;
};

const buildDecoyBody = (url) => {
Expand All @@ -333,6 +363,16 @@
};
}
if (kind === "style") return { body: "", contentType: "text/css; charset=utf-8" };
if (kind === "json") {
return { body: "{}", contentType: "application/json; charset=utf-8" };
}
if (kind === "text") return { body: "", contentType: "text/plain; charset=utf-8" };
if (kind === "xml") {
return {
body: '<?xml version="1.0" encoding="UTF-8"?><root/>',
contentType: "application/xml; charset=utf-8",
};
}
return null;
};

Expand Down
Loading
Loading