From 64b61c58c22e1ceadd5c72d234b3f2d27f127b9e Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sat, 29 Aug 2026 07:07:22 +0000 Subject: [PATCH 1/2] feat(extension): show blocked ad/tracker domains on the Shield panel The Shield could only report a count on the toolbar badge, never what it stopped. Under MV3 that is a real constraint: blocking is done natively by declarativeNetRequest static rulesets, and the only telemetry a packaged extension can read is getMatchedRules(), which returns the matched *rule id* and ruleset but never the request URL. (onRuleMatchedDebug, which does carry the URL, needs declarativeNetRequestFeedback and only fires for unpacked extensions -- not something we want to ship.) So resolve rule ids back to the filter that matched. build-filters.js now emits a label index next to each ruleset (rules/.labels.txt, one label per line, line N == rule id startId + N) plus a self-describing rules/index.json so the runtime never hard-codes the start ids the build chose. Every rule in the shipped lists is a whole-domain block, so the label is the ad/tracker domain that was blocked -- exactly what a user wants to see. The index costs 283KB per list against a 2MB ruleset. Adds src/background/blocked-log.js, which resolves ids to labels (lazily loaded and cached), aggregates hits per domain with a count, and skips matches from our dynamic allowlist ruleset since those are allows rather than blocks. Where onRuleMatchedDebug *is* available it records exact URLs and prefers them, shown as an expandable detail per row. The popup gains a "Blocked on this page" section listing each domain with its list badge and hit count, behind a Show all toggle, with an explicit empty state and a graceful unsupported state for browsers that cannot report matches. No new permissions: activeTab already covers getMatchedRules for the tab whose popup is open. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Q3vvUS9Q7m4ASyESCL2C8D --- apps/extension/__tests__/blocked-log.test.js | 272 +++++++++++++++++ .../extension/__tests__/build-filters.test.js | 59 +++- apps/extension/scripts/build-filters.js | 64 +++- apps/extension/scripts/build.js | 4 +- apps/extension/src/background/blocked-log.js | 276 ++++++++++++++++++ apps/extension/src/background/index.js | 15 + .../src/popup/components/AdblockPanel.jsx | 185 +++++++++++- 7 files changed, 859 insertions(+), 16 deletions(-) create mode 100644 apps/extension/__tests__/blocked-log.test.js create mode 100644 apps/extension/src/background/blocked-log.js diff --git a/apps/extension/__tests__/blocked-log.test.js b/apps/extension/__tests__/blocked-log.test.js new file mode 100644 index 0000000..b7991a9 --- /dev/null +++ b/apps/extension/__tests__/blocked-log.test.js @@ -0,0 +1,272 @@ +/** + * Tests for the Shield blocked-request log — resolving declarativeNetRequest + * rule ids back to the domains they blocked, and reporting them per tab. + * @module __tests__/blocked-log.test + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const { mockBrowser, dnrRef, listenersRef } = vi.hoisted(() => { + const dnrRef = { matched: [], throws: null, hasGetMatchedRules: true, hasDebugEvent: false }; + const listenersRef = { ruleMatched: [], tabRemoved: [], tabUpdated: [] }; + + const mockBrowser = { + runtime: { getURL: (path) => `chrome-extension://test/${path}` }, + tabs: { + onRemoved: { addListener: vi.fn((fn) => listenersRef.tabRemoved.push(fn)) }, + onUpdated: { addListener: vi.fn((fn) => listenersRef.tabUpdated.push(fn)) }, + }, + declarativeNetRequest: { + getMatchedRules: vi.fn(async () => { + if (dnrRef.throws) throw new Error(dnrRef.throws); + return { rulesMatchedInfo: dnrRef.matched }; + }), + }, + }; + return { mockBrowser, dnrRef, listenersRef }; +}); + +vi.mock('webextension-polyfill', () => ({ default: mockBrowser })); + +// The generated ruleset index + label files, served through fetch(). +const RULES_INDEX = { + lists: { + ads: { startId: 1, count: 3, labels: 'rules/ads.labels.txt' }, + privacy: { startId: 1_000_000, count: 2, labels: 'rules/privacy.labels.txt' }, + }, +}; +const ADS_LABELS = ['doubleclick.net', 'taboola.com', 'criteo.com'].join('\n'); +const PRIVACY_LABELS = ['google-analytics.com', 'hotjar.com'].join('\n'); + +globalThis.fetch = vi.fn(async (url) => { + if (url.endsWith('rules/index.json')) { + return { ok: true, status: 200, json: async () => RULES_INDEX }; + } + if (url.endsWith('rules/ads.labels.txt')) { + return { ok: true, status: 200, text: async () => ADS_LABELS }; + } + if (url.endsWith('rules/privacy.labels.txt')) { + return { ok: true, status: 200, text: async () => PRIVACY_LABELS }; + } + return { ok: false, status: 404, json: async () => ({}), text: async () => '' }; +}); + +/** Fresh module instance per test — the module caches labels and tab state. */ +async function loadModule() { + vi.resetModules(); + return import('../src/background/blocked-log.js'); +} + +/** Shorthand for a getMatchedRules entry. */ +const match = (rulesetId, ruleId, timeStamp = 1000) => ({ + rule: { rulesetId, ruleId }, + tabId: 7, + timeStamp, +}); + +beforeEach(() => { + globalThis.fetch.mockClear(); + dnrRef.matched = []; + dnrRef.throws = null; + listenersRef.ruleMatched = []; + listenersRef.tabRemoved = []; + listenersRef.tabUpdated = []; + mockBrowser.declarativeNetRequest.getMatchedRules = vi.fn(async () => { + if (dnrRef.throws) throw new Error(dnrRef.throws); + return { rulesMatchedInfo: dnrRef.matched }; + }); + delete mockBrowser.declarativeNetRequest.onRuleMatchedDebug; +}); + +describe('labelForRule — rule id to blocked domain', () => { + it('maps the first rule of a ruleset to its first label', async () => { + const { labelForRule } = await loadModule(); + expect(await labelForRule('ads', 1)).toBe('doubleclick.net'); + }); + + it('maps by offset from the ruleset start id', async () => { + const { labelForRule } = await loadModule(); + expect(await labelForRule('ads', 3)).toBe('criteo.com'); + expect(await labelForRule('privacy', 1_000_001)).toBe('hotjar.com'); + }); + + it('returns empty for an unknown ruleset or an out-of-range id', async () => { + const { labelForRule } = await loadModule(); + expect(await labelForRule('nope', 1)).toBe(''); + expect(await labelForRule('ads', 999)).toBe(''); + }); + + it('fetches each label file only once', async () => { + const { labelForRule } = await loadModule(); + await labelForRule('ads', 1); + await labelForRule('ads', 2); + await labelForRule('ads', 3); + const adsFetches = globalThis.fetch.mock.calls.filter((c) => + String(c[0]).endsWith('ads.labels.txt') + ); + expect(adsFetches).toHaveLength(1); + }); +}); + +describe('getBlockedRequests — per-tab report', () => { + it('aggregates repeated hits on one domain into a single counted row', async () => { + const { getBlockedRequests } = await loadModule(); + dnrRef.matched = [match('ads', 1), match('ads', 1, 2000), match('ads', 2)]; + + const res = await getBlockedRequests(7); + + expect(res.success).toBe(true); + expect(res.supported).toBe(true); + expect(res.source).toBe('matched-rules'); + expect(res.total).toBe(3); + expect(res.entries).toHaveLength(2); + expect(res.entries[0]).toMatchObject({ + label: 'doubleclick.net', + list: 'ads', + count: 2, + lastAt: 2000, + }); + expect(res.entries[1]).toMatchObject({ label: 'taboola.com', count: 1 }); + }); + + it('labels rows with the list they came from', async () => { + const { getBlockedRequests } = await loadModule(); + dnrRef.matched = [match('privacy', 1_000_000), match('ads', 1)]; + + const res = await getBlockedRequests(7); + const byLabel = Object.fromEntries(res.entries.map((e) => [e.label, e.list])); + expect(byLabel['google-analytics.com']).toBe('privacy'); + expect(byLabel['doubleclick.net']).toBe('ads'); + }); + + it('ignores dynamic allowlist matches, which are allows and not blocks', async () => { + const { getBlockedRequests } = await loadModule(); + dnrRef.matched = [match('_dynamic', 1), match('ads', 1)]; + + const res = await getBlockedRequests(7); + expect(res.total).toBe(1); + expect(res.entries).toHaveLength(1); + expect(res.entries[0].label).toBe('doubleclick.net'); + }); + + it('falls back to the rule id when a label cannot be resolved', async () => { + const { getBlockedRequests } = await loadModule(); + dnrRef.matched = [match('ads', 4242)]; + + const res = await getBlockedRequests(7); + expect(res.entries[0].label).toBe('rule #4242'); + }); + + it('reports an empty page cleanly', async () => { + const { getBlockedRequests } = await loadModule(); + const res = await getBlockedRequests(7); + expect(res).toMatchObject({ success: true, supported: true, total: 0 }); + expect(res.entries).toEqual([]); + }); + + it('requires a tab id', async () => { + const { getBlockedRequests } = await loadModule(); + expect(await getBlockedRequests(undefined)).toMatchObject({ success: false }); + }); + + it('degrades to unsupported when the permission is missing', async () => { + const { getBlockedRequests } = await loadModule(); + dnrRef.throws = 'No permission for tab'; + + const res = await getBlockedRequests(7); + expect(res.success).toBe(true); + expect(res.supported).toBe(false); + expect(res.entries).toEqual([]); + expect(res.error).toMatch(/No permission/); + }); + + it('degrades to unsupported when the browser has no getMatchedRules', async () => { + delete mockBrowser.declarativeNetRequest.getMatchedRules; + const { getBlockedRequests } = await loadModule(); + + const res = await getBlockedRequests(7); + expect(res.supported).toBe(false); + expect(res.entries).toEqual([]); + }); +}); + +describe('debug log — real URLs when onRuleMatchedDebug is available', () => { + const debugMatch = (url, tabId = 7, rulesetId = 'ads') => ({ + rule: { rulesetId, ruleId: 1 }, + request: { url, tabId }, + }); + + it('prefers recorded URLs over the rule-id report', async () => { + const { recordDebugMatch, getBlockedRequests } = await loadModule(); + await recordDebugMatch(debugMatch('https://ads.doubleclick.net/a.js')); + await recordDebugMatch(debugMatch('https://ads.doubleclick.net/b.js')); + + const res = await getBlockedRequests(7); + expect(res.source).toBe('debug'); + expect(res.total).toBe(2); + expect(res.entries[0].label).toBe('ads.doubleclick.net'); + expect(res.entries[0].urls).toEqual([ + 'https://ads.doubleclick.net/a.js', + 'https://ads.doubleclick.net/b.js', + ]); + }); + + it('keeps tabs separate', async () => { + const { recordDebugMatch, getBlockedRequests } = await loadModule(); + await recordDebugMatch(debugMatch('https://ads.doubleclick.net/a.js', 7)); + await recordDebugMatch(debugMatch('https://taboola.com/b.js', 9)); + + expect((await getBlockedRequests(7)).entries[0].label).toBe('ads.doubleclick.net'); + expect((await getBlockedRequests(9)).entries[0].label).toBe('taboola.com'); + }); + + it('ignores matches with no tab (background requests)', async () => { + const { recordDebugMatch, getBlockedRequests } = await loadModule(); + await recordDebugMatch(debugMatch('https://ads.doubleclick.net/a.js', -1)); + expect((await getBlockedRequests(7)).total).toBe(0); + }); + + it('clears a tab on navigation so the report matches the page on screen', async () => { + const { recordDebugMatch, getBlockedRequests, initBlockedLog } = await loadModule(); + mockBrowser.declarativeNetRequest.onRuleMatchedDebug = { + addListener: vi.fn((fn) => listenersRef.ruleMatched.push(fn)), + }; + initBlockedLog(); + + await recordDebugMatch(debugMatch('https://ads.doubleclick.net/a.js', 7)); + expect((await getBlockedRequests(7)).total).toBe(1); + + for (const fn of listenersRef.tabUpdated) fn(7, { status: 'loading' }); + expect((await getBlockedRequests(7)).total).toBe(0); + }); + + it('clearBlockedRequests drops a tab', async () => { + const { recordDebugMatch, getBlockedRequests, clearBlockedRequests } = await loadModule(); + await recordDebugMatch(debugMatch('https://ads.doubleclick.net/a.js', 7)); + clearBlockedRequests(7); + expect((await getBlockedRequests(7)).total).toBe(0); + }); + + it('caps the per-tab history so a long-lived tab cannot grow without bound', async () => { + const { recordDebugMatch, getBlockedRequests } = await loadModule(); + for (let i = 0; i < 300; i++) { + await recordDebugMatch(debugMatch(`https://ads.doubleclick.net/${i}.js`, 7)); + } + expect((await getBlockedRequests(7)).total).toBe(250); + }); +}); + +describe('initBlockedLog', () => { + it('is a no-op where the debug event does not exist (packaged builds)', async () => { + const { initBlockedLog } = await loadModule(); + expect(() => initBlockedLog()).not.toThrow(); + }); + + it('subscribes to the debug event when it is available', async () => { + const { initBlockedLog } = await loadModule(); + const addListener = vi.fn(); + mockBrowser.declarativeNetRequest.onRuleMatchedDebug = { addListener }; + initBlockedLog(); + expect(addListener).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/extension/__tests__/build-filters.test.js b/apps/extension/__tests__/build-filters.test.js index 3ace0a7..056d8c8 100644 --- a/apps/extension/__tests__/build-filters.test.js +++ b/apps/extension/__tests__/build-filters.test.js @@ -4,7 +4,14 @@ */ import { describe, it, expect } from 'vitest'; -import { parseRule, isValidDomain, isValidUrlFilter, buildRuleset } from '../scripts/build-filters.js'; +import { + parseRule, + isValidDomain, + isValidUrlFilter, + buildRuleset, + ruleLabel, + buildLabelIndex, +} from '../scripts/build-filters.js'; describe('parseRule — skips what declarativeNetRequest cannot represent', () => { it.each([ @@ -140,3 +147,53 @@ describe('isValidUrlFilter', () => { expect(isValidUrlFilter('foo|bar')).toBe(false); }); }); + +describe('ruleLabel — naming what a rule blocked', () => { + it.each([ + ['whole-domain block', '||doubleclick.net^', 'doubleclick.net'], + ['no trailing separator', '||doubleclick.net', 'doubleclick.net'], + ['start anchor', '|http://ads.example.com', 'http://ads.example.com'], + ['end anchor', 'ads.example.com|', 'ads.example.com'], + ['path pattern', '||example.com/ads/*', 'example.com/ads/*'], + ['bare substring', '/adserver.', '/adserver.'], + ])('%s -> %s', (_label, urlFilter, expected) => { + expect(ruleLabel(urlFilter)).toBe(expected); + }); + + it('is empty for a missing filter', () => { + expect(ruleLabel('')).toBe(''); + expect(ruleLabel(undefined)).toBe(''); + }); +}); + +describe('buildLabelIndex — line N names rule startId + N', () => { + const rules = [ + { id: 1, condition: { urlFilter: '||a.example^' } }, + { id: 2, condition: { urlFilter: '||b.example^' } }, + { id: 3, condition: { urlFilter: '||c.example^' } }, + ]; + + it('emits one label per rule, in rule-id order', () => { + expect(buildLabelIndex(rules, 1).split('\n')).toEqual(['a.example', 'b.example', 'c.example']); + }); + + it('honours a non-zero start id', () => { + const offset = rules.map((r, i) => ({ ...r, id: 1_000_000 + i })); + expect(buildLabelIndex(offset, 1_000_000).split('\n')[2]).toBe('c.example'); + }); + + it('throws rather than silently misaligning when ids are not dense', () => { + const gapped = [rules[0], { id: 99, condition: { urlFilter: '||x.example^' } }]; + expect(() => buildLabelIndex(gapped, 1)).toThrow(/dense/); + }); + + it('stays aligned with what buildRuleset actually produced', () => { + const built = buildRuleset('easylist.txt', 1, ['pagead2.googlesyndication.com']); + const labels = buildLabelIndex(built, 1).split('\n'); + expect(labels).toHaveLength(built.length); + // Spot-check across the whole range, not just the head. + for (const i of [0, 1, 500, 9000, built.length - 1]) { + expect(labels[built[i].id - 1]).toBe(ruleLabel(built[i].condition.urlFilter)); + } + }); +}); diff --git a/apps/extension/scripts/build-filters.js b/apps/extension/scripts/build-filters.js index c113ed5..7c81388 100644 --- a/apps/extension/scripts/build-filters.js +++ b/apps/extension/scripts/build-filters.js @@ -315,20 +315,76 @@ export function buildRuleset(file, startId, priorityDomains = []) { })); } +/** + * Human-readable label for a rule's urlFilter, used by the popup to name what + * a matched rule blocked. declarativeNetRequest only reports the *rule id* of a + * match — never the request URL — so this label is the only way to tell the + * user what was stopped. Strips the anchors (`||`, `|`, trailing `^`) that are + * filter syntax rather than part of the name. + * @param {string} urlFilter + * @returns {string} + */ +export function ruleLabel(urlFilter) { + if (!urlFilter) return ''; + return String(urlFilter) + .replace(/^\|\|?/, '') + .replace(/\|$/, '') + .replace(/\^$/, ''); +} + +/** + * Build the newline-delimited label index for a ruleset. Line N holds the label + * for rule id `startId + N`, so a lookup is a single array index with no keys + * to ship. Throws if ids aren't dense from startId, which would silently + * misalign every label. + * @param {Array<{id: number, condition: {urlFilter: string}}>} rules + * @param {number} startId + * @returns {string} + */ +export function buildLabelIndex(rules, startId) { + return rules + .map((rule, i) => { + if (rule.id !== startId + i) { + throw new Error(`Rule ids must be dense from ${startId}: got ${rule.id} at index ${i}`); + } + return ruleLabel(rule.condition.urlFilter); + }) + .join('\n'); +} + function main() { mkdirSync(RULES_DIR, { recursive: true }); const lists = [ - { file: 'easylist.txt', out: 'ads.json', startId: 1, priority: PRIORITY_ADS }, + { file: 'easylist.txt', out: 'ads.json', id: 'ads', startId: 1, priority: PRIORITY_ADS }, // Offset ids so the two rulesets never collide if ever merged - { file: 'easyprivacy.txt', out: 'privacy.json', startId: 1_000_000, priority: PRIORITY_PRIVACY }, + { + file: 'easyprivacy.txt', + out: 'privacy.json', + id: 'privacy', + startId: 1_000_000, + priority: PRIORITY_PRIVACY, + }, ]; - for (const { file, out, startId, priority } of lists) { + /** @type {Record} */ + const index = {}; + + for (const { file, out, id, startId, priority } of lists) { const rules = buildRuleset(file, startId, priority); writeFileSync(join(RULES_DIR, out), JSON.stringify(rules)); - console.log(`✅ ${file} -> rules/${out} (${rules.length} rules)`); + + const labelsFile = `${id}.labels.txt`; + writeFileSync(join(RULES_DIR, labelsFile), buildLabelIndex(rules, startId)); + + index[id] = { startId, count: rules.length, labels: `rules/${labelsFile}` }; + console.log(`✅ ${file} -> rules/${out} (${rules.length} rules) + rules/${labelsFile}`); } + + // Self-describing map of ruleset id -> id range + label file, so the runtime + // never has to hard-code the start ids this script chose. + writeFileSync(join(RULES_DIR, 'index.json'), JSON.stringify({ lists: index })); + console.log(`✅ rules/index.json (${Object.keys(index).length} lists)`); } // Only run the file-writing build when invoked directly (not when imported by tests) diff --git a/apps/extension/scripts/build.js b/apps/extension/scripts/build.js index 57f6d9b..3341420 100644 --- a/apps/extension/scripts/build.js +++ b/apps/extension/scripts/build.js @@ -73,7 +73,9 @@ function buildVite() { } /** - * Copy adblock rulesets (public/rules/*.json) into a target build dir. + * Copy adblock rulesets and their label index (public/rules/*) into a target + * build dir. The whole directory is copied, so the generated *.labels.txt and + * index.json the popup uses to name blocked domains travel with the rules. */ function copyRules(targetDir) { const rulesDir = join(ROOT_DIR, 'public/rules'); diff --git a/apps/extension/src/background/blocked-log.js b/apps/extension/src/background/blocked-log.js new file mode 100644 index 0000000..9fc33ac --- /dev/null +++ b/apps/extension/src/background/blocked-log.js @@ -0,0 +1,276 @@ +/** + * MarkSyncr Shield — blocked-request log + * + * Reports *what* the shield blocked on a tab, not just how many things it + * blocked. This is harder than it sounds under Manifest V3: blocking is done + * natively by declarativeNetRequest static rulesets, and the only match + * telemetry a packaged extension can read is + * `declarativeNetRequest.getMatchedRules()`, which returns the matched **rule + * id** and ruleset — never the request URL. (The URL-bearing + * `onRuleMatchedDebug` event needs the `declarativeNetRequestFeedback` + * permission and only fires for unpacked extensions.) + * + * So we resolve rule ids back to the filter that matched, using the label index + * `scripts/build-filters.js` emits next to each ruleset (`rules/*.labels.txt`, + * one label per line, line N == rule id `startId + N`). Every rule in the + * shipped lists is a whole-domain block, so the label is the ad/tracker domain + * that was stopped — which is what the user wants to see. + * + * When `onRuleMatchedDebug` *is* available (dev builds), we additionally record + * the exact request URLs, which are strictly better, and prefer them. + */ + +import browser from 'webextension-polyfill'; + +const RULES_INDEX_PATH = 'rules/index.json'; + +/** Per-tab cap on remembered debug entries — keeps the newest. */ +const MAX_DEBUG_ENTRIES_PER_TAB = 250; +/** Cap on how many tabs we keep a debug log for. */ +const MAX_DEBUG_TABS = 50; +/** Rulesets that represent blocking (dynamic allowlist rules are not blocks). */ +const BLOCKING_RULESET_IDS = ['ads', 'privacy']; + +/** @type {Promise>|null} */ +let rulesIndexPromise = null; +/** @type {Map>} ruleset id -> label lines */ +const labelCache = new Map(); +/** + * tabId -> array of { url, label, list, at } oldest-first. Only populated when + * onRuleMatchedDebug is available. + * @type {Map>} + */ +const debugLog = new Map(); +let debugListenerAttached = false; + +/** + * Load the generated ruleset index (ruleset id -> start id + label file). + * Cached for the life of the service worker. + */ +function loadRulesIndex() { + if (!rulesIndexPromise) { + rulesIndexPromise = (async () => { + const res = await fetch(browser.runtime.getURL(RULES_INDEX_PATH)); + if (!res.ok) throw new Error(`rules index ${res.status}`); + const json = await res.json(); + return json?.lists || {}; + })().catch((err) => { + console.warn('[MarkSyncr] Could not load rules index:', err?.message); + rulesIndexPromise = null; // allow a retry on the next call + return {}; + }); + } + return rulesIndexPromise; +} + +/** + * Load (and cache) the label lines for one ruleset. + * @param {string} rulesetId + * @param {string} labelsPath + * @returns {Promise} + */ +function loadLabels(rulesetId, labelsPath) { + if (!labelCache.has(rulesetId)) { + const promise = (async () => { + const res = await fetch(browser.runtime.getURL(labelsPath)); + if (!res.ok) throw new Error(`labels ${res.status}`); + return (await res.text()).split('\n'); + })().catch((err) => { + console.warn(`[MarkSyncr] Could not load labels for ${rulesetId}:`, err?.message); + labelCache.delete(rulesetId); // allow a retry + return []; + }); + labelCache.set(rulesetId, promise); + } + return labelCache.get(rulesetId); +} + +/** + * Resolve a matched rule back to the domain/pattern it blocked. + * @param {string} rulesetId + * @param {number} ruleId + * @returns {Promise} label, or '' when it can't be resolved + */ +export async function labelForRule(rulesetId, ruleId) { + const index = await loadRulesIndex(); + const entry = index[rulesetId]; + if (!entry) return ''; + const labels = await loadLabels(rulesetId, entry.labels); + return labels[ruleId - entry.startId] || ''; +} + +/** + * Fold a flat list of hits into per-target rows: one row per blocked + * domain/pattern, with a count and the most recent timestamp. + * @param {Array<{label: string, list: string, at: number, url?: string}>} hits + */ +function aggregate(hits) { + /** @type {Map} */ + const rows = new Map(); + + for (const hit of hits) { + const key = `${hit.list} ${hit.label}`; + let row = rows.get(key); + if (!row) { + row = { label: hit.label, list: hit.list, count: 0, lastAt: 0, urls: [] }; + rows.set(key, row); + } + row.count += 1; + if (hit.at > row.lastAt) row.lastAt = hit.at; + // Keep a few example URLs (debug mode only) without unbounded growth. + if (hit.url && row.urls.length < 5 && !row.urls.includes(hit.url)) row.urls.push(hit.url); + } + + return [...rows.values()].sort((a, b) => b.count - a.count || a.label.localeCompare(b.label)); +} + +/** + * Read the blocked-request report for a tab. + * + * Prefers the debug log (real URLs) when it has entries for the tab, otherwise + * queries getMatchedRules and resolves rule ids to labels. + * + * @param {number} tabId + * @returns {Promise} { success, supported, source, total, entries, [error] } + */ +export async function getBlockedRequests(tabId) { + if (typeof tabId !== 'number') { + return { success: false, error: 'A tabId is required' }; + } + + const debugHits = debugLog.get(tabId); + if (debugHits?.length) { + return { + success: true, + supported: true, + source: 'debug', + tabId, + total: debugHits.length, + entries: aggregate(debugHits), + }; + } + + if (!browser.declarativeNetRequest?.getMatchedRules) { + return { + success: true, + supported: false, + source: 'unavailable', + tabId, + total: 0, + entries: [], + error: 'This browser cannot report which requests were blocked.', + }; + } + + let matched; + try { + matched = await browser.declarativeNetRequest.getMatchedRules({ tabId }); + } catch (err) { + // Thrown when neither activeTab (for this tab) nor the feedback permission + // is available — e.g. the tab changed since the popup opened. + return { + success: true, + supported: false, + source: 'unavailable', + tabId, + total: 0, + entries: [], + error: err?.message || 'Blocked-request details are not available for this tab.', + }; + } + + const info = matched?.rulesMatchedInfo || []; + const hits = []; + for (const item of info) { + const rulesetId = item?.rule?.rulesetId; + // Skip our dynamic allowlist rules and anything from a ruleset we don't + // have labels for — those are allows, not blocks. + if (!BLOCKING_RULESET_IDS.includes(rulesetId)) continue; + const label = await labelForRule(rulesetId, item.rule.ruleId); + hits.push({ + label: label || `rule #${item.rule.ruleId}`, + list: rulesetId, + at: item.timeStamp || 0, + }); + } + + return { + success: true, + supported: true, + source: 'matched-rules', + tabId, + total: hits.length, + entries: aggregate(hits), + }; +} + +/** Drop a tab's debug log (navigation or tab close). */ +function clearTab(tabId) { + debugLog.delete(tabId); +} + +/** + * Record one onRuleMatchedDebug event. Exported for tests. + * @param {Object} info matched rule info, including `request` + */ +export async function recordDebugMatch(info) { + const tabId = info?.request?.tabId; + if (typeof tabId !== 'number' || tabId < 0) return; + + const rulesetId = info?.rule?.rulesetId; + if (!BLOCKING_RULESET_IDS.includes(rulesetId)) return; + + const url = info.request.url || ''; + let label = ''; + try { + label = new URL(url).hostname.replace(/^www\./, ''); + } catch { + label = (await labelForRule(rulesetId, info.rule.ruleId)) || url; + } + + let entries = debugLog.get(tabId); + if (!entries) { + if (debugLog.size >= MAX_DEBUG_TABS) clearTab(debugLog.keys().next().value); + entries = []; + debugLog.set(tabId, entries); + } + entries.push({ url, label, list: rulesetId, at: Date.now() }); + if (entries.length > MAX_DEBUG_ENTRIES_PER_TAB) entries.shift(); +} + +/** + * Attach the listeners that keep the log fresh. Safe to call more than once and + * a no-op where the debug event isn't available (packaged builds). + */ +export function initBlockedLog() { + if (debugListenerAttached) return; + + const onRuleMatchedDebug = browser.declarativeNetRequest?.onRuleMatchedDebug; + if (onRuleMatchedDebug?.addListener) { + onRuleMatchedDebug.addListener((info) => { + recordDebugMatch(info).catch(() => {}); + }); + debugListenerAttached = true; + } + + // Reset a tab's log when it navigates or goes away, so the report always + // describes the page the user is looking at. + try { + browser.tabs?.onRemoved?.addListener((tabId) => clearTab(tabId)); + browser.tabs?.onUpdated?.addListener((tabId, changeInfo) => { + if (changeInfo?.status === 'loading') clearTab(tabId); + }); + } catch { + /* tabs events unavailable — the debug log just grows to its cap */ + } +} + +/** + * Clear remembered debug entries for a tab, or all of them when no tab is given. + * @param {number} [tabId] + */ +export function clearBlockedRequests(tabId) { + if (typeof tabId === 'number') clearTab(tabId); + else debugLog.clear(); + return { success: true }; +} diff --git a/apps/extension/src/background/index.js b/apps/extension/src/background/index.js index e9b5756..9b8a0b1 100644 --- a/apps/extension/src/background/index.js +++ b/apps/extension/src/background/index.js @@ -18,6 +18,11 @@ import { removeAllowlistDomain, syncAdblockFromCloud, } from './adblock.js'; +import { + initBlockedLog, + getBlockedRequests, + clearBlockedRequests, +} from './blocked-log.js'; // Constants const SYNC_ALARM_NAME = 'marksyncr-auto-sync'; @@ -3633,6 +3638,12 @@ browser.runtime.onMessage.addListener((message, sender) => { case 'SYNC_ADBLOCK_CLOUD': return syncAdblockFromCloud(); + case 'GET_BLOCKED_REQUESTS': + return getBlockedRequests(message.payload?.tabId); + + case 'CLEAR_BLOCKED_REQUESTS': + return Promise.resolve(clearBlockedRequests(message.payload?.tabId)); + default: console.warn('[MarkSyncr] Unknown message type:', message.type); return Promise.resolve({ success: false, error: 'Unknown message type' }); @@ -3644,6 +3655,10 @@ browser.runtime.onMessage.addListener((message, sender) => { // This is critical for Firefox MV3 where background scripts are event-driven // ========================================== +// Shield blocked-request log — attaches the rule-match and tab listeners. +// Must run synchronously at top level like every other listener below. +initBlockedLog(); + // Alarm handler - registered synchronously for Firefox MV3 compatibility browser.alarms.onAlarm.addListener(async (alarm) => { const browserInfo = detectBrowser(); diff --git a/apps/extension/src/popup/components/AdblockPanel.jsx b/apps/extension/src/popup/components/AdblockPanel.jsx index 4f081cb..5eef21a 100644 --- a/apps/extension/src/popup/components/AdblockPanel.jsx +++ b/apps/extension/src/popup/components/AdblockPanel.jsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; /** * Get the extension messaging/tabs API (Chrome or Firefox), or null in a plain @@ -16,17 +16,23 @@ async function sendMessage(message) { return api.runtime.sendMessage(message); } -/** Read the active tab's hostname (needs activeTab, granted while the popup is open). */ -async function getActiveDomain() { +/** + * Read the active tab's id + hostname (needs activeTab, which is granted to the + * extension while the popup is open). The id is what lets the background ask + * declarativeNetRequest which rules matched on this tab. + */ +async function getActiveTab() { const api = getExtApi(); - if (!api?.tabs?.query) return ''; + if (!api?.tabs?.query) return { id: null, domain: '' }; try { const tabs = await api.tabs.query({ active: true, currentWindow: true }); - const url = tabs?.[0]?.url || ''; - if (!url.startsWith('http')) return ''; // skip chrome://, about:, etc. - return normalizeDomain(url); + const tab = tabs?.[0]; + const url = tab?.url || ''; + const id = typeof tab?.id === 'number' ? tab.id : null; + if (!url.startsWith('http')) return { id, domain: '' }; // skip chrome://, about:, etc. + return { id, domain: normalizeDomain(url) }; } catch { - return ''; + return { id: null, domain: '' }; } } @@ -70,23 +76,173 @@ const LISTS = [ { id: 'privacy', name: 'Trackers', description: 'Blocks trackers & analytics (EasyPrivacy)' }, ]; +/** Ruleset id -> short label for the blocked-request rows. */ +const LIST_NAMES = Object.fromEntries(LISTS.map((l) => [l.id, l.name])); + +/** How many blocked rows to show before "Show all". */ +const BLOCKED_PREVIEW_COUNT = 6; + +/** + * "Blocked on this page" — what the shield actually stopped on the active tab. + * + * declarativeNetRequest reports matched *rules*, not URLs, so in a packaged + * build each row names the filter's domain (e.g. doubleclick.net) rather than + * the full request URL. Dev builds with the feedback permission get real URLs, + * which are shown as an expandable detail. + */ +function BlockedList({ report, busy, onRefresh }) { + const [showAll, setShowAll] = useState(false); + const [expanded, setExpanded] = useState(null); + + const entries = report?.entries || []; + const shown = showAll ? entries : entries.slice(0, BLOCKED_PREVIEW_COUNT); + const hasUrls = report?.source === 'debug'; + + return ( +
+
+

+ Blocked on this page + {report?.total ? ( + + {report.total} + + ) : null} +

+ +
+ + {report && !report.supported ? ( +

+ {report.error || 'Blocked-request details are not available in this browser.'} +

+ ) : entries.length === 0 ? ( +

+ Nothing blocked on this page yet. Reload the page to see what gets stopped. +

+ ) : ( + <> +
+ {shown.map((entry) => { + const key = `${entry.list}:${entry.label}`; + const isOpen = expanded === key; + return ( +
+
+ + + {LIST_NAMES[entry.list] || entry.list} + + + {entry.count} + +
+ + {isOpen && entry.urls?.length ? ( +
    + {entry.urls.map((url) => ( +
  • + {url} +
  • + ))} +
+ ) : null} +
+ ); + })} +
+ + {entries.length > BLOCKED_PREVIEW_COUNT && ( + + )} + + )} +
+ ); +} + export function AdblockPanel() { const [status, setStatus] = useState(null); const [domain, setDomain] = useState(''); + const [tabId, setTabId] = useState(null); + const [blocked, setBlocked] = useState(null); + const [blockedBusy, setBlockedBusy] = useState(false); const [loading, setLoading] = useState(true); const [busy, setBusy] = useState(false); + /** Ask the background what the shield stopped on the given tab. */ + const loadBlocked = useCallback(async (id) => { + if (typeof id !== 'number') return; + setBlockedBusy(true); + const res = await sendMessage({ type: 'GET_BLOCKED_REQUESTS', payload: { tabId: id } }); + if (res?.success) setBlocked(res); + setBlockedBusy(false); + }, []); + useEffect(() => { (async () => { - setDomain(await getActiveDomain()); + const tab = await getActiveTab(); + setDomain(tab.domain); + setTabId(tab.id); // Pull cross-device prefs from the cloud (no-op if signed out), which // returns fresh status; fall back to local status if it fails. let res = await sendMessage({ type: 'SYNC_ADBLOCK_CLOUD' }); if (!res?.success) res = await sendMessage({ type: 'GET_ADBLOCK_STATUS' }); if (res?.success) setStatus(res); setLoading(false); + loadBlocked(tab.id); })(); - }, []); + }, [loadBlocked]); const apply = async (message, optimistic) => { setBusy(true); @@ -227,6 +383,15 @@ export function AdblockPanel() { ))} + {/* What the shield actually blocked on this page */} + {enabled && typeof tabId === 'number' && ( + loadBlocked(tabId)} + /> + )} + {/* Allowlisted sites */} {allowlist.length > 0 && (
From 4c6e1ede96e096b5851c454c746b9f64f58a20c9 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sat, 29 Aug 2026 07:16:26 +0000 Subject: [PATCH 2/2] fix(security): repair the two red security jobs and harden the real findings The security workflow's gitleaks and npm audit jobs have been failing on master for a while (both are continue-on-error, so the run still went green and nobody had to look). Neither failure came from any PR. npm audit: `npm install` exited 127 with "pnpm: not found". The cause was not postinstall, which already ends in `|| true`, but the sibling script literally named "dependencies" -- an npm *lifecycle* name, so npm runs it on install. It shelled straight into `pnpm dlx` with no guard, and pnpm does not exist in that job. Renamed to "patch:socket", which keeps it as a manual entry point while removing the accidental install hook (it only duplicated postinstall anyway, so installs also stop applying the same patches twice). postinstall now checks for pnpm before calling it. gitleaks: 3 findings, all verified false positives by reading the flagged commits, all in history so unreachable by editing the tree. Two are the `dropbox-api-token` rule matching the *response header name* in `response.headers.get('dropbox-api-result')`. The third is the Ahrefs Analytics `data-key`, which ships in the HTML of every page view and is public by design. Recorded in .gitleaksignore with the evidence. Also fixes the genuine ThreatCrush findings, all pre-existing: - .githooks/pre-commit ran its checks through `eval "$cmd"` (their only HIGH). run_check now takes argv and runs "$@" directly. - The release hooks handed off through /tmp/.marksyncr-release-trigger, a fixed name in a world-writable directory that another user on a shared box can pre-create or symlink. Moved into the repo's git dir, which also stops two checkouts colliding. - scripts/bump-version.ts interpolated a version string read from package.json into shell strings for git add/commit/tag. Now uses execFileSync with an argument list, so no shell parses it. The remaining ThreatCrush findings are false positives and deliberately left alone: static JSON-LD passed to dangerouslySetInnerHTML, the public Supabase anon key sent as a request header (not logged), a console.error template over a hardcoded table list, redirects to our own API's Stripe URL and to a hardcoded connectUrl, a fake password in a test fixture, and a test-only regex whose quantifiers are over disjoint classes. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Q3vvUS9Q7m4ASyESCL2C8D --- .githooks/commit-msg | 8 ++++++-- .githooks/post-commit | 8 ++++++-- .githooks/pre-commit | 13 ++++++++----- .gitleaksignore | 21 +++++++++++++++++++++ package.json | 4 ++-- scripts/bump-version.ts | 31 +++++++++++++++++++++++++++---- 6 files changed, 70 insertions(+), 15 deletions(-) create mode 100644 .gitleaksignore diff --git a/.githooks/commit-msg b/.githooks/commit-msg index 2fcfefb..fc281d2 100755 --- a/.githooks/commit-msg +++ b/.githooks/commit-msg @@ -15,8 +15,12 @@ COMMIT_MSG_FILE="$1" COMMIT_MSG=$(cat "$COMMIT_MSG_FILE") -# Temp file to signal post-commit hook -RELEASE_TRIGGER_FILE="/tmp/.marksyncr-release-trigger" +# Where the release trigger is recorded, for the post-commit hook to read. +# Kept inside the repository's git directory rather than /tmp: a fixed name in a +# world-writable directory can be pre-created or symlinked by another user on a +# shared machine, and a per-repo path also stops two checkouts colliding. +GIT_DIR_PATH=$(git rev-parse --git-dir) +RELEASE_TRIGGER_FILE="$GIT_DIR_PATH/marksyncr-release-trigger" # Clean up any previous trigger rm -f "$RELEASE_TRIGGER_FILE" diff --git a/.githooks/post-commit b/.githooks/post-commit index b180856..6327318 100755 --- a/.githooks/post-commit +++ b/.githooks/post-commit @@ -18,8 +18,12 @@ BLUE='\033[0;34m' RED='\033[0;31m' NC='\033[0m' # No Color -# Temp file from commit-msg hook -RELEASE_TRIGGER_FILE="/tmp/.marksyncr-release-trigger" +# Where the release trigger is recorded, for the post-commit hook to read. +# Kept inside the repository's git directory rather than /tmp: a fixed name in a +# world-writable directory can be pre-created or symlinked by another user on a +# shared machine, and a per-repo path also stops two checkouts colliding. +GIT_DIR_PATH=$(git rev-parse --git-dir) +RELEASE_TRIGGER_FILE="$GIT_DIR_PATH/marksyncr-release-trigger" # Check if a release was triggered if [ -f "$RELEASE_TRIGGER_FILE" ]; then diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 8abab9e..89b55f9 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -20,16 +20,19 @@ NC='\033[0m' # No Color # Track if any check fails FAILED=0 -# Function to run a check +# Function to run a check. +# Takes the command as separate arguments and runs it directly rather than +# through `eval`, which would re-parse the string as shell and expand anything +# a caller interpolated into it. run_check() { local name="$1" - local cmd="$2" + shift echo "----------------------------------------" echo " $name" echo "----------------------------------------" - if eval "$cmd"; then + if "$@"; then echo "${GREEN} $name passed${NC}" echo "" else @@ -49,10 +52,10 @@ if [ -z "$STAGED_FILES" ]; then fi # 1. Linting -run_check "Lint" "pnpm lint" +run_check "Lint" pnpm lint # 2. Build check -run_check "Build" "pnpm build" +run_check "Build" pnpm build # Final result echo "----------------------------------------" diff --git a/.gitleaksignore b/.gitleaksignore new file mode 100644 index 0000000..a21aa1e --- /dev/null +++ b/.gitleaksignore @@ -0,0 +1,21 @@ +# gitleaks false positives, verified by reading the flagged commit. +# +# The CI job scans the full history (`gitleaks detect --source .`), so these +# findings cannot be resolved by editing the working tree -- the match lives in +# a commit that is already written. Each fingerprint below was checked against +# `git show :` and carries no credential material. +# +# Re-verify before adding an entry here. An entry is a claim that a human looked +# at the matched line and found no secret, not a way to quiet a noisy job. + +# Rule `dropbox-api-token` matched the *response header name* in +# response.headers.get('dropbox-api-result') +# The literal is an HTTP header key, not a token. +4bfe09616e7cb4e28428992f8ca33a2835caba2e:packages/sources/src/oauth/dropbox-sync.ts:dropbox-api-token:142 +7f5c8e0dfba8493dfb2055518851c4fad0fb1078:packages/sources/src/oauth/dropbox-sync.ts:dropbox-api-token:112 + +# Rule `generic-api-key` matched the Ahrefs Analytics site key on the +#