diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a97a37..e6b2563 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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). @@ -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 diff --git a/PRIVACY.md b/PRIVACY.md index 43a1824..1bfcb47 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -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. @@ -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. @@ -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. @@ -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. diff --git a/README.md b/README.md index 6d77de7..17aa5dc 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/block.js b/block.js index c456af1..d8a6c4f 100644 --- a/block.js +++ b/block.js @@ -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; @@ -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; } @@ -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", @@ -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) => { @@ -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: '', + contentType: "application/xml; charset=utf-8", + }; + } return null; }; diff --git a/block_adaptive.js b/block_adaptive.js index bd9bae4..6a32f27 100644 --- a/block_adaptive.js +++ b/block_adaptive.js @@ -49,10 +49,11 @@ "blur", ]); const DOM_MARKER_ATTR_RE = - /^(?:data-(?:1password(?:-|$)|1p(?:-|$)|onepassword(?:-|$)|op(?:-|$)|lastpass(?:-|$)|lp-(?:ignore|id|tab)|dashlane(?:-|$)|dashlanecreated|grammarly(?:-|$)|gramm(?:-|$)|gr-c-s-(?:loaded|check-loaded)$|honey(?:-|$)|honeyextension(?:-|$)|keeper(?:-|$)|roboform(?:-|$)|nordpass(?:-|$)|bitwarden(?:-|$)|protonpass(?:-|$))|__lpform_)/i; - const DOM_MARKER_TAG_RE = /^(?:grammarly-|lastpass-|dashlane-|honey-|onepassword-|protonpass-)/i; + /^(?:data-(?:1password(?:-|$)|1p(?:-|$)|onepassword(?:-|$)|op(?:-|$)|lastpass(?:-|$)|lp-(?:ignore|id|tab)|dashlane(?:-|$)|dashlanecreated|grammarly(?:-|$)|gramm(?:-|$)|gr-c-s-(?:loaded|check-loaded)$|honey(?:-|$)|honeyextension(?:-|$)|keeper(?:-|$)|roboform(?:-|$)|nordpass(?:-|$)|bitwarden(?:-|$)|protonpass(?:-|$)|keepassxc(?:-|$)|darkreader(?:-|$)|bw(?:-|$)|lt(?:-|$)|languagetool(?:-|$))|__lpform_)/i; + const DOM_MARKER_TAG_RE = + /^(?:grammarly-|lastpass-|dashlane-|honey-|onepassword-|protonpass-|keepassxc-|darkreader-|bitwarden-)/i; const DOM_MARKER_CLASS_RE = - /^(?:grammarly(?:$|-)|lastpass(?:$|-)|__lpform|lpform|dashlane(?:$|-)|honey(?:$|-)|onepassword(?:$|-)|protonpass(?:$|-))/i; + /^(?:grammarly(?:$|-)|lastpass(?:$|-)|__lpform|lpform|dashlane(?:$|-)|honey(?:$|-)|onepassword(?:$|-)|protonpass(?:$|-)|keepassxc(?:$|-)|darkreader(?:$|-)|bitwarden(?:$|-))/i; const adaptiveWindows = new Map(); const reportedVendorSignals = new Set(); const instrumentedFingerprintGlobals = new WeakSet(); diff --git a/block_element_decoys.js b/block_element_decoys.js index 35c62b6..4d84f06 100644 --- a/block_element_decoys.js +++ b/block_element_decoys.js @@ -17,6 +17,7 @@ const MAX_QUEUED_PROBES = 1000; let noiseEnabled = false; let persona = new Set(); + let personaPaths = new Map(); let disabled = false; let nativeAttrValueGetter = null; let nativeAttrValueSetter = null; @@ -29,6 +30,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; if (typeof data.disabled === "boolean") disabled = data.disabled; }; @@ -195,6 +211,18 @@ 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(pathFor(url)); + return pathname ? learned.has(pathname) : false; + }; + + const learnedKindFor = (url) => + isLearnedPersonaPath(url) ? U.learnedDecoyKindForPath(pathFor(url)) : null; + const imageDecoyPath = (pathname) => matchesPathPattern(pathname, IMAGE_DECOY_PATHS); const scriptDecoyPath = (pathname) => matchesPathPattern(pathname, SCRIPT_DECOY_PATHS); const htmlDecoyPath = (pathname) => matchesPathPattern(pathname, HTML_DECOY_PATHS); @@ -238,19 +266,36 @@ return String((elOrTag && elOrTag.tagName) || "").toLowerCase(); }; + const learnedHrefKind = (tag, learned) => { + if (tag === "link" && ["style", "image", "script", "html"].includes(learned)) return learned; + if ((tag === "use" || tag === "image") && learned === "image") return "image"; + return null; + }; + + const learnedSrcKind = (tag, learned) => { + if (tag === "script" && learned === "script") return "script"; + if (["img", "input", "source", "embed"].includes(tag) && learned === "image") return "image"; + return null; + }; + const passiveDecoyKindFor = (url, prop, elOrTag) => { const pathname = pathFor(url); const tag = tagFrom(elOrTag); if (!pathname) return null; + const learned = learnedKindFor(url); - if (prop === "srcset" || prop === "poster") return imageDecoyPath(pathname) ? "image" : null; - - if (prop === "data" && tag === "object") return htmlDecoyPath(pathname) ? "html" : null; - - if (prop === "href") return passiveHrefDecoyKind(tag, pathname); - - if (prop === "src") return passiveSrcDecoyKind(tag, pathname); - + if (prop === "srcset" || prop === "poster") { + return imageDecoyPath(pathname) || learned === "image" ? "image" : null; + } + if (prop === "data" && tag === "object") { + return htmlDecoyPath(pathname) || learned === "html" ? "html" : null; + } + if (prop === "href") { + return passiveHrefDecoyKind(tag, pathname) || learnedHrefKind(tag, learned); + } + if (prop === "src") { + return passiveSrcDecoyKind(tag, pathname) || learnedSrcKind(tag, learned); + } return null; }; diff --git a/block_utils.js b/block_utils.js index d93ec1d..ef37e13 100644 --- a/block_utils.js +++ b/block_utils.js @@ -199,6 +199,32 @@ U.matchesPathPattern = (pathname, patterns) => patterns.some((pattern) => pattern.test(pathname)); + U.sanitizeExtensionPath = (path) => { + if (typeof path !== "string" || !path) return ""; + let value = path.split("?")[0].split("#")[0].trim().toLowerCase(); + if (!value) return ""; + if (!value.startsWith("/")) value = `/${value}`; + if (value.includes("\\") || value.includes("://") || value.includes("//")) return ""; + if (value.includes("/../") || value.endsWith("/..") || value.includes("/./")) return ""; + if (value.length > 96) value = value.slice(0, 96); + if (!/^\/[a-z0-9._\-/]+$/.test(value)) return ""; + return value; + }; + + U.learnedDecoyKindForPath = (pathname) => { + const path = U.sanitizeExtensionPath(pathname); + if (!path) return null; + if (path.endsWith("/manifest.json") || path === "/manifest.json") return "manifest"; + if (/\.(png|gif|jpe?g|svg)$/.test(path)) return "image"; + if (path.endsWith(".js") || path.endsWith(".mjs")) return "script"; + if (path.endsWith(".html") || path.endsWith(".htm")) return "html"; + if (path.endsWith(".css")) return "style"; + if (path.endsWith(".json")) return "json"; + if (path.endsWith(".txt") || path.endsWith(".md")) return "text"; + if (path.endsWith(".xml")) return "xml"; + return null; + }; + // ====================================================================== // DOM / attribute utilities // ====================================================================== diff --git a/bridge.js b/bridge.js index 740de88..ea5bf40 100644 --- a/bridge.js +++ b/bridge.js @@ -30,7 +30,9 @@ const pendingIdCounts = new Map(); const pendingVectorCounts = new Map(); const pendingPathKindCounts = new Map(); + const pendingIdPaths = new Map(); const knownExtensionIds = H.knownPersonaIds ? H.knownPersonaIds(CFG) : new Set(); + const MAX_PATHS_PER_ID = CFG.maxPathsPerId || 8; const bumpMap = (map, key, amount = 1) => { const safeKey = key || "unknown"; @@ -75,6 +77,43 @@ } }; + const bumpCappedPathMap = (id, path) => { + if (!id || !path) return; + let paths = pendingIdPaths.get(id); + if (!paths) { + paths = new Map(); + pendingIdPaths.set(id, paths); + } + if (paths.has(path)) { + paths.set(path, paths.get(path) + 1); + return; + } + if (paths.size < MAX_PATHS_PER_ID) { + paths.set(path, 1); + return; + } + let lowestPath = null; + let lowestCount = Infinity; + for (const [existingPath, count] of paths) { + if (count < lowestCount || (count === lowestCount && existingPath > lowestPath)) { + lowestPath = existingPath; + lowestCount = count; + } + } + if (lowestPath) { + paths.delete(lowestPath); + paths.set(path, 1); + } + }; + + const idPathsToObject = (map) => { + const out = {}; + for (const [id, paths] of map) { + out[id] = mapToObject(paths); + } + return out; + }; + const mapToObject = (map) => { const out = {}; for (const [key, value] of map) out[key] = value; @@ -114,10 +153,12 @@ const deltaSnapshot = mapToObject(pendingIdCounts); const vectorSnapshot = mapToObject(pendingVectorCounts); const pathKindSnapshot = mapToObject(pendingPathKindCounts); + const idPathsSnapshot = idPathsToObject(pendingIdPaths); const diagnosticSnapshot = pendingDiagnosticEvents.splice(0); pendingIdCounts.clear(); pendingVectorCounts.clear(); pendingPathKindCounts.clear(); + pendingIdPaths.clear(); try { const message = { type: "static_probe_blocked", @@ -127,6 +168,7 @@ deltaIdCounts: deltaSnapshot, deltaVectorCounts: vectorSnapshot, deltaPathKindCounts: pathKindSnapshot, + deltaIdPaths: idPathsSnapshot, }; if (diagnosticSnapshot.length > 0) message.diagnosticEvents = diagnosticSnapshot; chrome.runtime.sendMessage(message); @@ -147,6 +189,7 @@ pendingDiagnosticEvents.length = 0; pendingVectorCounts.clear(); pendingPathKindCounts.clear(); + pendingIdPaths.clear(); }; const flushOnHidden = () => { @@ -163,6 +206,8 @@ if (id) { bumpCappedIdMap(idCounts, id); bumpCappedIdMap(pendingIdCounts, id); + const path = H.extensionPathnameFor ? H.extensionPathnameFor(data.url) : ""; + if (path) bumpCappedPathMap(id, path); } if (!flushTimer) flushTimer = setTimeout(flush, 150); }; @@ -247,6 +292,10 @@ port.postMessage({ type: "config_update", persona: Array.isArray(response.ids) ? response.ids : [], + personaPaths: + response.paths && typeof response.paths === "object" && !Array.isArray(response.paths) + ? response.paths + : {}, disabled: !!response.disabled, fingerprintMode: typeof response.fingerprintMode === "string" ? response.fingerprintMode : "off", @@ -280,6 +329,7 @@ type: "config_update", disabled: nowDisabled, persona: [], + personaPaths: {}, fingerprintMode: "off", fingerprintPersona: null, diagnosticsMode: false, @@ -361,6 +411,7 @@ type: "config_update", disabled, persona: [], + personaPaths: {}, fingerprintMode: "off", fingerprintPersona: null, diagnosticsMode: false, @@ -399,6 +450,7 @@ type: "config_update", disabled: nowDisabled, persona: [], + personaPaths: {}, fingerprintMode: "off", fingerprintPersona: null, diagnosticsMode: false, diff --git a/docs/noise-behavior.md b/docs/noise-behavior.md index 78efcad..75cbceb 100644 --- a/docs/noise-behavior.md +++ b/docs/noise-behavior.md @@ -15,10 +15,18 @@ same generic `"Browser Extension"` string. Image fetch decoys only answer format with matching magic bytes and `Content-Type` (PNG, GIF, JPEG, SVG); other image suffixes stay fail-closed. +Learned WAR paths are first-class. LinkedIn-style AED probes `{id, file}` pairs against real +web-accessible resources (`inpage.js`, `src/css/*.css`, `phishing.html`), not a generic +`manifest.json`. After a path is seen at least twice for an eligible persona ID, Noise answers that +exact path on later visits. Unseen path canaries stay blocked even for persona IDs. Learned paths +are stored locally, query-stripped, charset-limited, and capped at 8 paths per ID. Research exports +omit them. + | Probe vector | Eligible Noise persona ID | Non-persona or invalid ID | | ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------- | | `fetch(.../manifest.json)` | Allowlisted static `GET` / `HEAD` paths receive path-matched decoys | Native-like `TypeError` failure | | `XMLHttpRequest` to `manifest.json` | Allowlisted static `GET` / `HEAD` paths receive path-matched decoys | Native-like network error | +| `fetch` / XHR to a learned WAR path | After ≥2 probes of that exact path for the persona ID, path-matched decoy (same body rules as allowlisted kinds) | Native-like failure | | `Image.src` / `img.setAttribute("src")` | Loads a 1x1 transparent PNG; page-visible getters return the original extension URL | Blocked | | `Image.srcset` / `source.srcset` | Loads a 1x1 transparent PNG candidate; page-visible getters return the original extension URL | Blocked | | `input.src` for image inputs | Loads a 1x1 transparent PNG; page-visible getters return the original extension URL | Blocked | @@ -54,9 +62,10 @@ cheap way to distinguish Noise from a browser-managed resource load. Suspicious paths stay blocked too, even when the suffix looks decoyable. Returning generic `200 OK` bodies for arbitrary `*.png`, `*.js`, `*.css`, or `*.html` canaries would let a probing script seed -random path names and then distinguish Static from a normal extension resource lookup. Noise only -answers a conservative allowlist of plausible extension resource paths such as `manifest.json`, -common icon names, and common page / script / stylesheet entrypoints. +random path names and then distinguish Static from a normal extension resource lookup. Noise answers +a conservative allowlist of plausible extension resource paths **or** a path that origin has already +probed at least twice for that persona ID. The learned-path set is how Static poisons LinkedIn-style +AED dictionaries without answering first-visit canaries. ## Test Requirements diff --git a/lists.js b/lists.js index 13c671f..7ef7676 100644 --- a/lists.js +++ b/lists.js @@ -32,6 +32,11 @@ /^data-nordpass(-|$)/i, /^data-bitwarden(-|$)/i, /^data-protonpass(-|$)/i, + /^data-keepassxc(-|$)/i, + /^data-darkreader(-|$)/i, + /^data-bw(-|$)/i, + /^data-lt(-|$)/i, + /^data-languagetool(-|$)/i, /^__lpform_/i, ], @@ -44,6 +49,9 @@ /^honey-/i, /^onepassword-/i, /^protonpass-/i, + /^keepassxc-/i, + /^darkreader-/i, + /^bitwarden-/i, ], // CSS class names to strip (element stays; just the class). @@ -56,6 +64,9 @@ /^honey($|-)/i, /^onepassword($|-)/i, /^protonpass($|-)/i, + /^keepassxc($|-)/i, + /^darkreader($|-)/i, + /^bitwarden($|-)/i, ], // Known-extension ID groups used by Noise-mode persona generation. @@ -74,6 +85,7 @@ "fooolghllnmhmmndgjiamiiodkpenpbb", // RoboForm (legacy) "bmikpgodpkclnkgmnpphehdgcimmided", // NordPass "cjnlpnbkjbnmdieljmighbdoljmgfibk", // Proton Pass + "oboonakemofpalcgghocfoadofidjkkk", // KeePassXC-Browser ], userscript_manager: [ "dhdgffkkebhmkfjojejmpbldmpobfkfo", // Tampermonkey @@ -144,6 +156,17 @@ // Weeks before a persona rotates. personaRotationWeeks: 1, + + // A resource path must be seen this many times for a persona ID before + // Noise will answer that exact path. Stops one-shot path canaries on an + // already-eligible ID (LinkedIn-style `{id, file}` probes are repeated + // across visits, so real WAR files pass; random `*.png` names do not). + personaMinPathCount: 2, + + // Cap learned WAR paths stored per extension ID. LinkedIn-style playbooks + // use one file per ID; extra slots absorb icon + script variants without + // letting a canary flood unbounded path maps. + maxPathsPerId: 8, }; // ======================================================================== @@ -201,6 +224,58 @@ } }, + // Local-only WAR pathname for Noise learning. Lowercased, query-stripped, + // charset-limited, and length-capped so probe storage never keeps website + // URLs or unbounded canary strings. + sanitizeExtensionPath(path) { + if (typeof path !== "string" || !path) return ""; + let value = path.split("?")[0].split("#")[0].trim().toLowerCase(); + if (!value) return ""; + if (!value.startsWith("/")) value = `/${value}`; + if (value.includes("\\") || value.includes("://") || value.includes("//")) return ""; + if (value.includes("/../") || value.endsWith("/..") || value.includes("/./")) return ""; + if (value.length > 96) value = value.slice(0, 96); + if (!/^\/[a-z0-9._\-/]+$/.test(value)) return ""; + return value; + }, + + extensionPathnameFor(url) { + return this.sanitizeExtensionPath(this.pathnameFor(url) || this.extensionPathFor(url)); + }, + + // Suffix-based decoy kind for learned WAR paths that miss the conservative + // allowlist. Formats Static cannot synthesize with matching bytes stay + // fail-closed (webp/ico/bmp and unknown suffixes). + learnedDecoyKindForPath(pathname) { + const path = this.sanitizeExtensionPath(pathname); + if (!path) return null; + if (path.endsWith("/manifest.json") || path === "/manifest.json") return "manifest"; + if (/\.(png|gif|jpe?g|svg)$/.test(path)) return "image"; + if (path.endsWith(".js") || path.endsWith(".mjs")) return "script"; + if (path.endsWith(".html") || path.endsWith(".htm")) return "html"; + if (path.endsWith(".css")) return "style"; + if (path.endsWith(".json")) return "json"; + if (path.endsWith(".txt") || path.endsWith(".md")) return "text"; + if (path.endsWith(".xml")) return "xml"; + return null; + }, + + eligiblePathsForId(pathCounts, minCount = 2) { + const eligible = []; + for (const [path, count] of Object.entries(pathCounts || {})) { + const safePath = this.sanitizeExtensionPath(path); + if ( + safePath && + typeof count === "number" && + count >= minCount && + this.learnedDecoyKindForPath(safePath) + ) { + eligible.push(safePath); + } + } + return eligible.sort(); + }, + // Set of every known extension ID across all conflictSlots, lowercased. knownPersonaIds(config = data) { const ids = new Set(); diff --git a/rules/META.json b/rules/META.json index 9ed4b0d..3116420 100644 --- a/rules/META.json +++ b/rules/META.json @@ -1,9 +1,9 @@ { "rulesets": { "fingerprint_vendors": { - "version": "1.7.0", - "last_verified": "2026-07-13", - "description": "Client-side fingerprinting and anti-bot vendors: Fingerprint Pro (fingerprint.com + fingerprintjs.com), open-source FingerprintJS CDN (openfpcdn.io), DataDome (datadome.co + datadome.io + js.datadome.co + api.datadome.co), PerimeterX/HUMAN (perimeterx.net + perimeterx.com + humansecurity.com + whiteops.com legacy), Sift (cdn.sift.com + siftscience.com + api.sift.com + events.api.sift.com + sift.co + sift.com), Forter (cdn + main API), ThreatMetrix/TransUnion (online-metrix.net + threatmetrix.net + riskmetrix.net), Iovation (iovation.com + iesnare.com + iesnare.co.uk + iovation.co.uk), Kasada (cdn + primary API + kasada.io), Sardine, Shape Security/F5 (shapesecurity.com), Castle.io, GeeTest, BotGuard/Blackwall (botguard.net + blackwall.ai), CHEQ (cheqzone.com + cheq.ai), ClearSale, SEON, Imperva/Incapsula (incapsula.com + imperva.com + distilnetworks.com), BioCatch (we-stats.com + biocatch.com), NuData Security (nudatasecurity.com), TypingDNA (typingdna.com), Feedzai (feedzai.com), Riskified (riskified.com), Socure (socure.com), Kount (kount.com + kount.net), Signifyd (signifyd.com), IPQualityScore (ipqualityscore.com), Accertify (accertify.com), Group-IB (group-ib.com). Includes current Fingerprint CDN/API domains, api.fpjs.pro, api.fpjs.io, npm loader CDN, HUMAN sensor/connect hosts, persistent transport resource types, sub_frame, and stylesheet resource types.", + "version": "1.8.0", + "last_verified": "2026-08-21", + "description": "Client-side fingerprinting and anti-bot vendors: Fingerprint Pro (fingerprint.com + fingerprintjs.com), open-source FingerprintJS CDN (openfpcdn.io), DataDome (datadome.co + datadome.io + js.datadome.co + api.datadome.co), PerimeterX/HUMAN (perimeterx.net + perimeterx.com + humansecurity.com + whiteops.com legacy), Sift (cdn.sift.com + siftscience.com + api.sift.com + events.api.sift.com + sift.co + sift.com), Forter (cdn + main API), ThreatMetrix/TransUnion (online-metrix.net + threatmetrix.net + riskmetrix.net), Iovation (iovation.com + iesnare.com + iesnare.co.uk + iovation.co.uk), Kasada (cdn + primary API + kasada.io), Sardine, Shape Security/F5 (shapesecurity.com), Castle.io, GeeTest, BotGuard/Blackwall (botguard.net + blackwall.ai), CHEQ (cheqzone.com + cheq.ai), ClearSale, SEON, Imperva/Incapsula (incapsula.com + imperva.com + distilnetworks.com), BioCatch (we-stats.com + biocatch.com), NuData Security (nudatasecurity.com), TypingDNA (typingdna.com), Feedzai (feedzai.com), Riskified (riskified.com), Socure (socure.com + socure.io), Kount (kount.com + kount.net), Signifyd (signifyd.com), IPQualityScore (ipqualityscore.com), Accertify (accertify.com), Group-IB (group-ib.com + group-ib.ru), LexisNexis Risk (lexisnexisrisk.com), Stytch telemetry (telemetry.stytch.com), IPFingerprint (ipfingerprint.com), Nethone (nethone.com). Includes current Fingerprint CDN/API domains, api.fpjs.pro, api.fpjs.io, npm loader CDN, HUMAN sensor/connect hosts, persistent transport resource types, sub_frame, and stylesheet resource types.", "enabled_by_default": true }, "captcha_vendors": { diff --git a/rules/fingerprint_vendors.json b/rules/fingerprint_vendors.json index fd44fbe..1d4ad7e 100644 --- a/rules/fingerprint_vendors.json +++ b/rules/fingerprint_vendors.json @@ -1442,5 +1442,119 @@ "other" ] } + }, + { + "id": 77, + "priority": 1, + "action": { "type": "block" }, + "condition": { + "urlFilter": "||lexisnexisrisk.com^", + "resourceTypes": [ + "sub_frame", + "stylesheet", + "xmlhttprequest", + "ping", + "websocket", + "webtransport", + "image", + "script", + "other" + ] + } + }, + { + "id": 78, + "priority": 1, + "action": { "type": "block" }, + "condition": { + "urlFilter": "||telemetry.stytch.com^", + "resourceTypes": [ + "sub_frame", + "stylesheet", + "xmlhttprequest", + "ping", + "websocket", + "webtransport", + "image", + "script", + "other" + ] + } + }, + { + "id": 79, + "priority": 1, + "action": { "type": "block" }, + "condition": { + "urlFilter": "||group-ib.ru^", + "resourceTypes": [ + "sub_frame", + "stylesheet", + "xmlhttprequest", + "ping", + "websocket", + "webtransport", + "image", + "script", + "other" + ] + } + }, + { + "id": 80, + "priority": 1, + "action": { "type": "block" }, + "condition": { + "urlFilter": "||ipfingerprint.com^", + "resourceTypes": [ + "sub_frame", + "stylesheet", + "xmlhttprequest", + "ping", + "websocket", + "webtransport", + "image", + "script", + "other" + ] + } + }, + { + "id": 81, + "priority": 1, + "action": { "type": "block" }, + "condition": { + "urlFilter": "||socure.io^", + "resourceTypes": [ + "sub_frame", + "stylesheet", + "xmlhttprequest", + "ping", + "websocket", + "webtransport", + "image", + "script", + "other" + ] + } + }, + { + "id": 82, + "priority": 1, + "action": { "type": "block" }, + "condition": { + "urlFilter": "||nethone.com^", + "resourceTypes": [ + "sub_frame", + "stylesheet", + "xmlhttprequest", + "ping", + "websocket", + "webtransport", + "image", + "script", + "other" + ] + } } ] diff --git a/service_worker.js b/service_worker.js index bc0b2e3..8e11465 100644 --- a/service_worker.js +++ b/service_worker.js @@ -380,11 +380,56 @@ const sanitizeExtensionIdCounts = (counts) => { return trimCountMap(sanitized, MAX_CAPTURED_IDS, knownPersonaIds()); }; +const sanitizeExtensionIdPaths = (idPaths) => { + const maxPaths = CFG.maxPathsPerId || 8; + const out = {}; + const sanitizePath = SW_HELPERS.sanitizeExtensionPath + ? (path) => SW_HELPERS.sanitizeExtensionPath(path) + : () => ""; + for (const [id, paths] of Object.entries(idPaths || {})) { + const safeId = id.toLowerCase(); + if (!isValidExtensionId(safeId) || !paths || typeof paths !== "object") continue; + const sanitized = {}; + for (const [path, count] of Object.entries(paths)) { + const safePath = sanitizePath(path); + if (!safePath || typeof count !== "number" || count <= 0) continue; + sanitized[safePath] = (sanitized[safePath] || 0) + count; + } + if (Object.keys(sanitized).length) out[safeId] = trimCountMap(sanitized, maxPaths); + } + return out; +}; + +const mergeIdPaths = (target, source) => { + let changed = false; + const maxPaths = CFG.maxPathsPerId || 8; + for (const [id, paths] of Object.entries(source || {})) { + target[id] ||= {}; + if (mergeCounts(target[id], paths)) changed = true; + target[id] = trimCountMap(target[id], maxPaths); + } + return changed; +}; + +const personaPathsFor = (entry, ids) => { + const minCount = CFG.personaMinPathCount || 2; + const out = {}; + if (!entry || !entry.idPaths) return out; + for (const id of ids) { + const eligible = SW_HELPERS.eligiblePathsForId + ? SW_HELPERS.eligiblePathsForId(entry.idPaths[id], minCount) + : []; + if (eligible.length) out[id] = eligible; + } + return out; +}; + const normalizedProbeBatch = (batch) => { const deltaVectorCounts = batch && batch.deltaVectorCounts ? batch.deltaVectorCounts : {}; return { deltaIdCounts: batch && batch.deltaIdCounts ? sanitizeExtensionIdCounts(batch.deltaIdCounts) : {}, + deltaIdPaths: batch && batch.deltaIdPaths ? sanitizeExtensionIdPaths(batch.deltaIdPaths) : {}, deltaPathKindCounts: batch && batch.deltaPathKindCounts ? batch.deltaPathKindCounts : {}, deltaTotal: batch && typeof batch.delta === "number" && batch.delta > 0 @@ -414,11 +459,13 @@ const recordProbes = async (origin, batch) => { const { probe_log = {} } = await chrome.storage.local.get({ probe_log: {} }); const entry = probe_log[origin] || { idCounts: {}, lastUpdated: 0 }; entry.idCounts ||= {}; + entry.idPaths ||= {}; const now = Date.now(); const normalized = normalizedProbeBatch(batch); const countChanged = mergeCounts(entry.idCounts, normalized.deltaIdCounts); + const pathChanged = mergeIdPaths(entry.idPaths, normalized.deltaIdPaths); const weekChanged = mergeProbeWeek(entry, normalized, now); - const changed = countChanged || weekChanged; + const changed = countChanged || pathChanged || weekChanged; if (!changed) return; entry.lastUpdated = now; probe_log[origin] = entry; @@ -885,6 +932,7 @@ const handleProbeBlocked = (msg, sender) => { delta, deltaIdCounts: msg.deltaIdCounts || {}, deltaPathKindCounts: msg.deltaPathKindCounts || {}, + deltaIdPaths: msg.deltaIdPaths || {}, deltaVectorCounts: msg.deltaVectorCounts || {}, }) ); @@ -1091,6 +1139,7 @@ const handleGetPersona = (_msg, sender, sendResponse) => { fingerprintMode, fingerprintPersona, ids: [], + paths: {}, noiseEnabled: noise_enabled, origin, replayMode: replay_mode, @@ -1098,12 +1147,14 @@ const handleGetPersona = (_msg, sender, sendResponse) => { return; } const ids = await personaFor(origin); + const { probe_log = {} } = await chrome.storage.local.get({ probe_log: {} }); sendResponse({ diagnosticsMode: diagnostics_mode, disabled, fingerprintMode, fingerprintPersona, ids, + paths: personaPathsFor(probe_log[origin], ids), noiseEnabled: true, origin, replayMode: replay_mode, diff --git a/service_worker_utils.js b/service_worker_utils.js index 849dd71..bf6155f 100644 --- a/service_worker_utils.js +++ b/service_worker_utils.js @@ -103,12 +103,32 @@ globalThis.__static_sw_utils__ = (() => { } }; + const maxPathsPerIdForCaps = () => + (globalThis.__static_config__ && globalThis.__static_config__.maxPathsPerId) || 8; + + const enforceIdPathsCaps = (entry, retainedIds) => { + if (!entry.idPaths || typeof entry.idPaths !== "object") { + delete entry.idPaths; + return; + } + const maxPaths = maxPathsPerIdForCaps(); + const next = {}; + for (const [id, paths] of Object.entries(entry.idPaths)) { + if (!retainedIds.has(id) || !paths || typeof paths !== "object") continue; + const trimmed = trimCountMap(paths, maxPaths); + if (Object.keys(trimmed).length) next[id] = trimmed; + } + if (Object.keys(next).length) entry.idPaths = next; + else delete entry.idPaths; + }; + const enforceCaps = (probeLog) => { const priorityIds = knownPersonaIdsForCaps(); for (const origin of Object.keys(probeLog)) { const entry = probeLog[origin]; entry.idCounts ||= {}; entry.idCounts = trimCountMap(entry.idCounts, 2000, priorityIds); + enforceIdPathsCaps(entry, new Set(Object.keys(entry.idCounts))); enforcePlaybookCaps(entry, priorityIds); } const origins = Object.keys(probeLog); @@ -340,5 +360,6 @@ globalThis.__static_sw_utils__ = (() => { playbookDriftForEntry, sumCounts, trimCountMap, + enforceIdPathsCaps, }; })(); diff --git a/tests/e2e-specs.txt b/tests/e2e-specs.txt index c677e2b..e436afc 100644 --- a/tests/e2e-specs.txt +++ b/tests/e2e-specs.txt @@ -20,6 +20,7 @@ tests/svg-href.spec.js tests/noise-xhr-consistency.spec.js tests/noise-canary.spec.js tests/noise-decoy-stealth.spec.js +tests/noise-learned-paths.spec.js tests/adversarial-consistency.spec.js tests/stealth-hardness.spec.js tests/decoy-serialization.spec.js diff --git a/tests/edge-privacy.spec.js b/tests/edge-privacy.spec.js index 2434285..702ca0c 100644 --- a/tests/edge-privacy.spec.js +++ b/tests/edge-privacy.spec.js @@ -890,6 +890,9 @@ test("shareable export hashes origin and extension ID labels", async ({ extensio [ids[1]]: 7, [ids[2]]: 25, }, + idPaths: { + [ids[0]]: { "/inpage.js": 4, "/src/css/content.css": 2 }, + }, lastUpdated: Date.now(), }, }, @@ -916,6 +919,8 @@ test("shareable export hashes origin and extension ID labels", async ({ extensio expect(firstIdHashes.every((idHash) => /^[0-9a-f]{64}$/.test(idHash))).toBe(true); expect(serialized).not.toContain(origin); for (const id of ids) expect(serialized).not.toContain(id); + expect(serialized).not.toContain("/inpage.js"); + expect(serialized).not.toContain("idPaths"); expect(Object.values(first.origins[firstOriginHash].idBuckets).sort()).toEqual([ "2-5", "21-100", diff --git a/tests/extension-behavior.spec.js b/tests/extension-behavior.spec.js index d62ecd7..4afceb7 100644 --- a/tests/extension-behavior.spec.js +++ b/tests/extension-behavior.spec.js @@ -2562,6 +2562,38 @@ test("scrubs extension DOM markers on initial parse and later mutations", async expect(later.classes).toEqual(["keep"]); }); +test("scrubs KeePassXC, Dark Reader, Bitwarden, and LanguageTool DOM markers", async ({ + extension, + server, +}) => { + const page = await extension.context.newPage(); + await page.goto(server.url("/blank.html")); + + const result = await page.evaluate(async () => { + const node = document.createElement("div"); + node.className = "keep keepassxc-field darkreader bitwarden-notification"; + node.setAttribute("data-keepassxc", "1"); + node.setAttribute("data-darkreader-mode", "dynamic"); + node.setAttribute("data-bw-theme", "light"); + node.setAttribute("data-lt-active", "true"); + document.body.appendChild(node); + const tag = document.createElement("keepassxc-notification"); + document.body.appendChild(tag); + await new Promise((resolve) => { + setTimeout(resolve, 50); + }); + return { + attrs: [...node.attributes].map((attr) => attr.name), + classes: [...node.classList], + keepassTagCount: document.querySelectorAll("keepassxc-notification").length, + }; + }); + + expect(result.attrs).toEqual(["class"]); + expect(result.classes).toEqual(["keep"]); + expect(result.keepassTagCount).toBe(0); +}); + test("scrubs extension DOM markers from open shadow roots attached after host insertion", async ({ extension, server, diff --git a/tests/firefox-smoke.js b/tests/firefox-smoke.js index fb23ca1..1cd84b2 100755 --- a/tests/firefox-smoke.js +++ b/tests/firefox-smoke.js @@ -261,6 +261,10 @@ function createFirefoxDriver() { const options = new firefox.Options(); options.setBinary(firefoxBinary); + // Firefox 153+ restricts WebDriver navigation to http(s)/file/blob/about:blank + // unless system access is enabled. about:debugging and moz-extension:// popup + // checks need this flag (see Mozilla bug 1579790). + options.addArguments("--remote-allow-system-access"); // Extensions are unreliable in Firefox headless — CI wraps with xvfb-run. options.setPreference("extensions.autoDisableScopes", 0); options.setPreference("extensions.enabledScopes", 15); diff --git a/tests/noise-learned-paths.spec.js b/tests/noise-learned-paths.spec.js new file mode 100644 index 0000000..58a1209 --- /dev/null +++ b/tests/noise-learned-paths.spec.js @@ -0,0 +1,321 @@ +const { expect, test } = require("./helpers/extension-fixture"); + +const BITWARDEN = "nngceckbapebfimnlniiiahkandclblb"; +const METAMASK = "nkbihfbeogaeaoehlefnkodbefgpgknn"; +const probedUrl = (id, path) => `chrome-extension://${id}${path}`; + +const seedLearnedPersona = async (extension, origin, { id = BITWARDEN, paths = {} } = {}) => { + await extension.serviceWorker.evaluate( + ({ pageOrigin, personaId, idPaths }) => + chrome.storage.local.set({ + noise_enabled: true, + probe_log: { + [pageOrigin]: { + idCounts: { [personaId]: 2 }, + idPaths: { [personaId]: idPaths }, + lastUpdated: Date.now(), + }, + }, + user_secret: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + }), + { pageOrigin: origin, personaId: id, idPaths: paths } + ); +}; + +const enableNoise = async (extension) => { + await extension.serviceWorker.evaluate(() => + chrome.storage.local.set({ + noise_enabled: true, + user_secret: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + }) + ); +}; + +const originLearnedPaths = (extension, origin) => + extension.serviceWorker.evaluate(async (pageOrigin) => { + const { probe_log } = await chrome.storage.local.get("probe_log"); + return (probe_log && probe_log[pageOrigin] && probe_log[pageOrigin].idPaths) || {}; + }, origin); + +const pathCountFor = (extension, origin, id, path) => + extension.serviceWorker.evaluate( + async ({ pageOrigin, personaId, resourcePath }) => { + const { probe_log } = await chrome.storage.local.get("probe_log"); + const entry = probe_log && probe_log[pageOrigin]; + return ( + (entry && + entry.idPaths && + entry.idPaths[personaId] && + entry.idPaths[personaId][resourcePath]) || + 0 + ); + }, + { pageOrigin: origin, personaId: id, resourcePath: path } + ); + +const fetchOutcome = async (page, url, method = "GET") => + page.evaluate( + async ({ resourceUrl, httpMethod }) => { + try { + const response = await fetch(resourceUrl, { method: httpMethod }); + const contentType = response.headers.get("content-type") || ""; + const text = httpMethod === "HEAD" ? "" : await response.text(); + return { + contentType, + ok: response.ok, + status: response.status, + text, + type: "fulfilled", + }; + } catch (error) { + return { + message: String(error && error.message), + name: error && error.name, + type: "rejected", + }; + } + }, + { resourceUrl: url, httpMethod: method } + ); + +test("Noise answers a learned LinkedIn-style WAR path that misses the allowlist", async ({ + extension, + server, +}) => { + const page = await extension.context.newPage(); + await seedLearnedPersona(extension, server.origin, { paths: { "/inpage.js": 2 } }); + await page.goto(server.url("/blank.html")); + await page.waitForTimeout(300); + + const learned = await fetchOutcome(page, probedUrl(BITWARDEN, "/inpage.js")); + const canary = await fetchOutcome(page, probedUrl(BITWARDEN, "/canary-not-a-war-file.js")); + const webp = await fetchOutcome(page, probedUrl(BITWARDEN, "/icon.webp")); + + expect(learned).toMatchObject({ + contentType: "application/javascript; charset=utf-8", + ok: true, + status: 200, + text: "", + type: "fulfilled", + }); + expect(canary.type).toBe("rejected"); + expect(canary.name).toBe("TypeError"); + expect(webp.type).toBe("rejected"); +}); + +test("Noise fetch and XHR agree on a learned CSS WAR path", async ({ extension, server }) => { + const page = await extension.context.newPage(); + await seedLearnedPersona(extension, server.origin, { + paths: { "/src/css/content.css": 2 }, + }); + await page.goto(server.url("/blank.html")); + await page.waitForTimeout(300); + + const result = await page.evaluate( + async (url) => { + const response = await fetch(url); + const fetchText = await response.text(); + const xhr = await new Promise((resolve) => { + const request = new XMLHttpRequest(); + request.addEventListener("loadend", () => { + resolve({ + allHeaders: request.getAllResponseHeaders(), + contentType: request.getResponseHeader("content-type"), + status: request.status, + text: request.responseText, + }); + }); + request.open("GET", url); + request.send(); + }); + return { + fetchContentType: response.headers.get("content-type"), + fetchStatus: response.status, + fetchText, + xhr, + }; + }, + probedUrl(BITWARDEN, "/src/css/content.css") + ); + + expect(result.fetchStatus).toBe(200); + expect(result.fetchContentType).toBe("text/css; charset=utf-8"); + expect(result.fetchText).toBe(""); + expect(result.xhr.status).toBe(200); + expect(result.xhr.contentType).toBe("text/css; charset=utf-8"); + expect(result.xhr.text).toBe(""); + expect(result.xhr.allHeaders.toLowerCase()).toContain("content-type:"); +}); + +test("learned HTML and JSON WAR paths return synthesizable bodies", async ({ + extension, + server, +}) => { + const page = await extension.context.newPage(); + await seedLearnedPersona(extension, server.origin, { + paths: { "/phishing.html": 2, "/rules.json": 4 }, + }); + await page.goto(server.url("/blank.html")); + await page.waitForTimeout(300); + + const html = await fetchOutcome(page, probedUrl(BITWARDEN, "/phishing.html")); + const json = await fetchOutcome(page, probedUrl(BITWARDEN, "/rules.json")); + const post = await fetchOutcome(page, probedUrl(BITWARDEN, "/inpage.js"), "POST"); + + expect(html).toMatchObject({ + contentType: "text/html; charset=utf-8", + ok: true, + status: 200, + type: "fulfilled", + }); + expect(html.text).toContain(""); + expect(json).toMatchObject({ + contentType: "application/json; charset=utf-8", + ok: true, + status: 200, + text: "{}", + type: "fulfilled", + }); + expect(post.type).toBe("rejected"); +}); + +test("a one-shot learned path count is not enough to decoy", async ({ extension, server }) => { + const page = await extension.context.newPage(); + await seedLearnedPersona(extension, server.origin, { paths: { "/inpage.js": 1 } }); + await page.goto(server.url("/blank.html")); + await page.waitForTimeout(300); + + const result = await fetchOutcome(page, probedUrl(BITWARDEN, "/inpage.js")); + expect(result.type).toBe("rejected"); + expect(result.name).toBe("TypeError"); +}); + +test("query strings are stripped before a WAR path is learned", async ({ extension, server }) => { + const page = await extension.context.newPage(); + await enableNoise(extension); + await page.goto(server.url("/blank.html")); + await page.waitForTimeout(300); + + await page.evaluate( + async (url) => { + await Promise.allSettled([fetch(url), fetch(url)]); + }, + probedUrl(BITWARDEN, "/inpage.js?cache=1#frag") + ); + + await expect.poll(() => pathCountFor(extension, server.origin, BITWARDEN, "/inpage.js")).toBe(2); + await expect + .poll(() => pathCountFor(extension, server.origin, BITWARDEN, "/inpage.js?cache=1")) + .toBe(0); +}); + +test("LinkedIn-style AED playbook is poisoned after two visits", async ({ extension, server }) => { + await enableNoise(extension); + const first = await extension.context.newPage(); + await first.goto(server.url("/blank.html")); + await first.waitForTimeout(300); + + const firstVisit = await first.evaluate( + async (probes) => { + const results = await Promise.allSettled(probes.map((url) => fetch(url))); + return results.map((result) => result.status); + }, + [probedUrl(METAMASK, "/inpage.js"), probedUrl(METAMASK, "/inpage.js")] + ); + expect(firstVisit).toEqual(["rejected", "rejected"]); + + await expect.poll(() => pathCountFor(extension, server.origin, METAMASK, "/inpage.js")).toBe(2); + + const second = await extension.context.newPage(); + await second.goto(server.url("/blank.html")); + await second.waitForTimeout(300); + + const secondVisit = await fetchOutcome(second, probedUrl(METAMASK, "/inpage.js")); + const stillCanary = await fetchOutcome(second, probedUrl(METAMASK, "/random-canary.js")); + expect(secondVisit).toMatchObject({ ok: true, status: 200, type: "fulfilled" }); + expect(stillCanary.type).toBe("rejected"); +}); + +test("learned script WAR paths decoy script.src without leaking the data URL", async ({ + extension, + server, +}) => { + const page = await extension.context.newPage(); + await seedLearnedPersona(extension, server.origin, { paths: { "/inpage.js": 2 } }); + await page.goto(server.url("/blank.html")); + await page.waitForTimeout(300); + + const result = await page.evaluate( + async (url) => { + const script = document.createElement("script"); + const event = await new Promise((resolve) => { + script.addEventListener("load", () => resolve("load"), { once: true }); + script.addEventListener("error", () => resolve("error"), { once: true }); + script.src = url; + document.body.appendChild(script); + setTimeout(() => resolve("timeout"), 1000); + }); + return { + event, + getter: script.src, + attribute: script.getAttribute("src"), + }; + }, + probedUrl(BITWARDEN, "/inpage.js") + ); + + expect(result.event).toBe("load"); + expect(result.getter).toBe(probedUrl(BITWARDEN, "/inpage.js")); + expect(result.attribute).toBe(probedUrl(BITWARDEN, "/inpage.js")); +}); + +test("learned script paths do not decoy image tags", async ({ extension, server }) => { + const page = await extension.context.newPage(); + await seedLearnedPersona(extension, server.origin, { paths: { "/inpage.js": 2 } }); + await page.goto(server.url("/blank.html")); + await page.waitForTimeout(300); + + const result = await page.evaluate( + async (url) => { + const image = new Image(); + const event = await new Promise((resolve) => { + image.addEventListener("load", () => resolve("load"), { once: true }); + image.addEventListener("error", () => resolve("error"), { once: true }); + image.src = url; + setTimeout(() => resolve("timeout"), 1000); + }); + return { complete: image.complete, event, naturalWidth: image.naturalWidth }; + }, + probedUrl(BITWARDEN, "/inpage.js") + ); + + expect(result.event).not.toBe("load"); + expect(result.naturalWidth).toBe(0); +}); + +test("invalid path canaries never enter the learned map", async ({ extension, server }) => { + const page = await extension.context.newPage(); + await enableNoise(extension); + await page.goto(server.url("/blank.html")); + await page.waitForTimeout(300); + + await page.evaluate(async (id) => { + const urls = [ + `chrome-extension://${id}//inpage.js`, + `chrome-extension://${id}/in page.js`, + `chrome-extension://${id}/icon.webp`, + ]; + await Promise.allSettled(urls.flatMap((url) => [fetch(url), fetch(url)])); + }, BITWARDEN); + + await expect + .poll(async () => pathCountFor(extension, server.origin, BITWARDEN, "/icon.webp")) + .toBe(2); + await expect + .poll(async () => pathCountFor(extension, server.origin, BITWARDEN, "/in page.js")) + .toBe(0); + const stored = await originLearnedPaths(extension, server.origin); + const paths = stored[BITWARDEN] || {}; + expect(Object.keys(paths).some((path) => path.includes("//"))).toBe(false); + expect(paths["/in page.js"]).toBeUndefined(); +}); diff --git a/tests/static-validation.spec.js b/tests/static-validation.spec.js index a3bbe2e..411add9 100644 --- a/tests/static-validation.spec.js +++ b/tests/static-validation.spec.js @@ -696,6 +696,12 @@ test("fingerprint DNR lists cover current official client-side collection hosts" "||sift.com^", "||accertify.com^", "||group-ib.com^", + "||lexisnexisrisk.com^", + "||telemetry.stytch.com^", + "||group-ib.ru^", + "||ipfingerprint.com^", + "||socure.io^", + "||nethone.com^", ]) { expect(fingerprintFilters.has(filter), `fingerprint_vendors missing ${filter}`).toBe(true); } @@ -755,6 +761,13 @@ test("conflictSlots cover key extension categories without cross-slot ID duplica expect(uniqueIds.size, "no extension ID should appear in multiple slots").toBe(allIds.length); }); +test("conflictSlots include KeePassXC-Browser in the password_manager slot", () => { + const context = vm.createContext({}); + vm.runInContext(readText("lists.js"), context); + const slots = context.__static_config__.conflictSlots; + expect(slots.password_manager).toContain("oboonakemofpalcgghocfoadofidjkkk"); +}); + test("conflictSlots include Proton Pass in the password_manager slot", () => { const context = vm.createContext({}); vm.runInContext(readText("lists.js"), context); @@ -797,6 +810,24 @@ test("conflictSlots include Honey in the shopping slot", () => { expect(slots.shopping).toContain("bmnlcjabgnpnenekpadlanbbkooimhnj"); }); +test("every conflictSlots ID has an ID-seeded Noise manifest name", () => { + const context = vm.createContext({}); + vm.runInContext(readText("lists.js"), context); + const slots = context.__static_config__.conflictSlots; + const source = readText("block.js"); + const names = {}; + const block = source.match(/KNOWN_MANIFEST_NAMES = \{([\s\S]*?)\};/); + expect(block, "KNOWN_MANIFEST_NAMES must exist in block.js").toBeTruthy(); + for (const match of block[1].matchAll(/([a-p]{32}):\s*"([^"]+)"/g)) { + names[match[1]] = match[2]; + } + for (const [slot, ids] of Object.entries(slots)) { + for (const id of ids) { + expect(names[id], `${slot} ID ${id} needs a KNOWN_MANIFEST_NAMES entry`).toBeTruthy(); + } + } +}); + // ========================================================================= // Shared block_utils.js utility tests // ========================================================================= @@ -1074,3 +1105,96 @@ test("log_diagnostics noiseReadinessFor handles mixed Chrome and UUID ID pools", expect(result.eligibleIds).toContain(chromeUnknown); expect(result.eligibleIds).toHaveLength(3); }); + +test("path helpers agree between lists.js and block_utils.js", () => { + const listsContext = vm.createContext({ URL }); + vm.runInContext(readText("lists.js"), listsContext); + const helpers = listsContext.__static_config__.helpers; + const { U } = loadBlockUtils(); + const samples = [ + "/inpage.js", + "/src/css/content.css", + "/phishing.html", + "/rules.json", + "/icon.webp", + "/../secret.js", + "//inpage.js", + "/in page.js", + "/inpage.js?cache=1", + "INPAGE.JS", + ]; + for (const sample of samples) { + expect(U.sanitizeExtensionPath(sample), sample).toBe(helpers.sanitizeExtensionPath(sample)); + expect(U.learnedDecoyKindForPath(sample), sample).toBe(helpers.learnedDecoyKindForPath(sample)); + } + expect(helpers.sanitizeExtensionPath("/inpage.js?x=1#y")).toBe("/inpage.js"); + expect(helpers.learnedDecoyKindForPath("/inpage.js")).toBe("script"); + expect(helpers.learnedDecoyKindForPath("/icon.webp")).toBe(null); + expect(helpers.eligiblePathsForId({ "/inpage.js": 2, "/canary.webp": 9, "/x.js": 1 })).toEqual([ + "/inpage.js", + ]); +}); + +test("service worker path caps drop canary floods and evicted IDs", () => { + const { enforceCaps } = loadServiceWorkerUtils(); + const knownId = "nngceckbapebfimnlniiiahkandclblb"; + const unknownId = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const tooManyPaths = {}; + for (let i = 0; i < 12; i++) tooManyPaths[`/file-${i}.js`] = i + 1; + const highCardinalityIds = {}; + for (let index = 0; Object.keys(highCardinalityIds).length < 2000; index++) { + const id = extensionIdFor(index); + if (id !== knownId) highCardinalityIds[id] = 1000 + index; + } + + const probeLog = { + "https://path-caps.test": { + idCounts: { + ...highCardinalityIds, + [knownId]: 2, + }, + idPaths: { + [knownId]: tooManyPaths, + [unknownId]: { "/gone.js": 9 }, + }, + lastUpdated: Date.now(), + }, + }; + + enforceCaps(probeLog); + const retained = probeLog["https://path-caps.test"]; + expect(Object.keys(retained.idCounts)).toHaveLength(2000); + expect(retained.idCounts[knownId]).toBe(2); + expect(retained.idPaths[unknownId]).toBeUndefined(); + expect(Object.keys(retained.idPaths[knownId])).toHaveLength(8); + expect(retained.idPaths[knownId]["/file-11.js"]).toBe(12); + expect(retained.idPaths[knownId]["/file-0.js"]).toBeUndefined(); +}); + +test("bridge flush includes sanitized learned WAR paths", () => { + const harness = loadBridgeHarness(); + const port = harness.portsByEvent.__perf_noise_bi__; + expect(port).toBeTruthy(); + const knownId = "nngceckbapebfimnlniiiahkandclblb"; + port.postMessage({ + type: "probe_blocked", + url: `chrome-extension://${knownId}/inpage.js?secret=1`, + where: "fetch", + }); + port.postMessage({ + type: "probe_blocked", + url: `chrome-extension://${knownId}/inpage.js#frag`, + where: "fetch", + }); + port.postMessage({ + type: "probe_blocked", + url: `chrome-extension://${knownId}/../escape.js`, + where: "fetch", + }); + harness.dispatchWindow("pagehide"); + harness.runTimers(); + const message = harness.messages.find((msg) => msg.type === "static_probe_blocked"); + expect(message.deltaIdPaths[knownId]["/inpage.js"]).toBe(2); + expect(message.deltaIdPaths[knownId]["/inpage.js?secret=1"]).toBeUndefined(); + expect(JSON.stringify(message.deltaIdPaths)).not.toContain(".."); +});