feat(extension): Shield — blocked-request view, phishing/scam blocking, and green security jobs - #17
Merged
Merged
Conversation
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/<list>.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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q3vvUS9Q7m4ASyESCL2C8D
ThreatCrush Security Scan9 finding(s) MEDIUM: 7 | LOW: 2
Snippets are redacted; ThreatCrush never prints matched credential material. |
…indings
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q3vvUS9Q7m4ASyESCL2C8D
| "setup:hooks": "git config core.hooksPath .githooks && git config push.followTags true && echo 'Git hooks and push.followTags configured'", | ||
| "postinstall": "pnpm dlx @socketsecurity/socket-patch apply --silent --ecosystems npm && git config core.hooksPath .githooks && git config push.followTags true 2>/dev/null || true", | ||
| "dependencies": "pnpm dlx @socketsecurity/socket-patch apply --silent --ecosystems npm" | ||
| "postinstall": "command -v pnpm >/dev/null 2>&1 && pnpm dlx @socketsecurity/socket-patch apply --silent --ecosystems npm; git config core.hooksPath .githooks && git config push.followTags true 2>/dev/null || true", |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
The Shield could only report a count on the toolbar badge, never what it stopped. This adds a "Blocked on this page" section to the Shield panel listing each blocked ad/tracker domain with its list badge and hit count.
The MV3 constraint
Blocking is done natively by
declarativeNetRequeststatic rulesets. The only match telemetry a packaged extension can read isgetMatchedRules(), which returns the matched rule id and ruleset — never the request URL. The URL-bearingonRuleMatchedDebugevent requires thedeclarativeNetRequestFeedbackpermission and only fires for unpacked extensions, so it is not shippable.So the approach is to resolve rule ids back to the filter that matched:
scripts/build-filters.jsnow emits a label index next to each ruleset —rules/<list>.labels.txt, one label per line, where line N is rule idstartId + N— plus a self-describingrules/index.jsonso the runtime never hard-codes the start ids the build chose. The build throws rather than silently misaligning if rule ids are ever non-dense.||domain^block, so the label is the ad/tracker domain that was blocked — exactly what a user wants to see.public/rules/is gitignored and generated at build time;copyRules()copies the directory unfiltered, so the new files ship automatically.Changes
src/background/blocked-log.js(new) — resolves rule ids to labels (lazily loaded, cached per service-worker lifetime), aggregates hits per domain with counts and last-seen time, and skips matches from the dynamic allowlist ruleset since those areallowactions, not blocks. WhereonRuleMatchedDebugis available (dev builds) it records exact URLs and prefers them.AdblockPanel.jsx— newBlockedListsection: per-domain rows with list badge + count,Show alltoggle past 6 rows, expandable real URLs in dev builds, explicit empty state, and a graceful unsupported state for browsers that cannot report matches.background/index.js—GET_BLOCKED_REQUESTS/CLEAR_BLOCKED_REQUESTShandlers;initBlockedLog()registered synchronously at top level alongside the other listeners (required for Firefox MV3).No new permissions —
activeTabalready coversgetMatchedRulesfor the tab whose popup is open.Testing
__tests__/blocked-log.test.js, plus label-index coverage inbuild-filters.test.js), including id→label alignment spot-checked across the full 15k range against whatbuildRulesetactually produced.origin/masterworktree (24 files pass, same 2 errors, exit 1).pnpm lintclean.BROWSER=chrome pnpm build:chromeverified end to end: popup bundle contains the new UI, background bundle contains the handler, anddist/chrome/rules/shipsindex.json+ both.labels.txtfiles.🤖 Generated with Claude Code
https://claude.ai/code/session_01Q3vvUS9Q7m4ASyESCL2C8D
Second commit: security jobs + real ThreatCrush findings
Separable from the feature above — split it out if you'd rather land it on its own.
The
securityworkflow'sgitleaksandnpm auditjobs had been failing on master, not just here. Both arecontinue-on-error: true, so the run still reported success and the red jobs went unnoticed. Both now pass.pnpm: not found. The cause wasn'tpostinstall(it already ends in|| true) but the sibling script literally nameddependencies— an npm lifecycle name, so npm runs it on install. It shelled straight intopnpm dlxwith no guard. Renamed topatch:socket: still a manual entry point, no longer an install hook, and installs stop applying the same patches twice.postinstallnow checks for pnpm first. Verified: the script exits 0 with pnpm offPATH.dropbox-api-tokenrule matching the response header name inresponse.headers.get('dropbox-api-result'). The third is the Ahrefs Analyticsdata-key, which ships in the HTML of every page view and is public by design. Recorded in.gitleaksignorewith that evidence. Verified locally with gitleaks 8.21.2: 327 commits scanned, no leaks, exit 0.ThreatCrush: 15 → 9 findings, the only HIGH cleared. All were pre-existing and repo-wide — the workflow scans
.rather than the diff, and runs withFAIL_ON=""so it never gates. Fixed the genuine ones:.githooks/pre-commitran checks througheval "$cmd"(the HIGH).run_checknow takes argv and runs"$@"./tmp/.marksyncr-release-trigger— a fixed name in a world-writable directory another user on a shared box can pre-create or symlink. Moved into the repo's git dir, which also stops two checkouts colliding. Verified end to end:commit-msgwrites the trigger,post-commitdetects and consumes it.scripts/bump-version.tsinterpolated a version string read frompackage.jsoninto shell strings forgit add/commit/tag. NowexecFileSyncwith an argument list, so no shell parses it.The 9 remaining are false positives, deliberately left alone: static JSON-LD passed to
dangerouslySetInnerHTML(×2), the public Supabase anon key sent as a request header and never logged, aconsole.errortemplate over a hardcoded table list, redirects to our own API's Stripe URL and to a hardcodedconnectUrl, a fake password in a test fixture, and a test-only regex whose quantifiers are over disjoint classes.manifest-install-lifecycle-scriptstill flagspostinstallfetching at install time — that's the intended Socket-patch design, now merely non-fatal.Third commit: phishing, malware & scam blocking
Known-bad domains are blocked, and a top-level navigation to one lands on a warning page naming the site instead of a bare network error. Toggle lives on the Shield panel, on by default.
The list. Licence was the binding constraint — most well-known blocklists can't ship inside a commercial extension (hagezi and AdGuard's are GPL-3.0-only, Cloudflare Radar is CC BY-NC, OpenPhish's community feed is non-commercial, abuse.ch now needs an auth key). Three sources clear it:
NEW-today)17,990 after collapsing subdomains a listed parent already covers. Refresh with
node scripts/update-phishing-list.js.Two rules, not 18,000.
requestDomainstakes a whole array and matches subdomains implicitly, so a 5,000-domain chunk costs oneblockrule plus onemain_frameredirectrule. This matters: the ~30k static budget is already fully spent by the ads and privacy rulesets, so a per-domain ruleset would not have fit at all. uBlock Origin Lite ships exactly this shape — I checked its published JSON, and its urlhaus ruleset is literally one rule holding 5,312 domains.Dynamic rules, not a static ruleset. Phishing domains have a median lifetime of hours; a static ruleset only changes when a new version clears store review. So: bundled seed installs on first run (protection is never absent while waiting on a fetch), then a 6-hour refresh alarm. A refresh where every feed fails keeps what's installed rather than clearing it.
The warning page gets the blocked URL from the redirect's
regexSubstitution(#\0is the whole match). That value comes from a hostile page, so it's validated to http(s) before use and set withtextContent; ajavascript:ordata:fragment is refused, and "proceed anyway" is disabled when no hostname parses. The fragment never leaves the browser — nothing about browsing is sent anywhere, which is a store-policy position as much as a privacy one (Avast Online Security was de-listed in 2019 for sending URLs).A collision this would have hit
applyAllowlistremoved every dynamic rule — so allowlisting a site for ads would have silently deleted the user's phishing protection. Dynamic ids are now banded per owner (src/background/rule-ids.js) and priorities ordered so security blocking outranks the ad allowlist (turning off ad blocking on a site no longer turns off scam protection), while a user-granted bypass outranks both. Covered by a test that runs the real allowlist path and asserts the security rules survive.New permissions
https://hole.cert.pl/*andhttps://raw.githubusercontent.com/*(list downloads only), plusblocked.htmlas a web-accessible resource so it can be a redirect target.Testing
blocked.htmlat the package root and the 17,990-domain seed inrules/.Not done / worth a look
requestDomains: ["example.com"]also matchingsub.example.comis documented only by example. uBOL depends on it and the collapsing step assumes it, so it's near-certain — but it deserves one manual smoke test in a real browser before release, since if it were wrong the list would under-block.