A privacy-first, cross-browser anti-phishing extension. Built in phases:
- Phase 0 — foundation: core, orchestrator,
Enginecontract, eval bench. - Phase 1 — lookalike engine (homoglyph / typo / combosquat / tiered subdomain-brand), fully local.
- Phase 2 — blocklist engine: k-anonymity hash-prefix confirmation
(production default, zero FP) with a Bloom-filter bench/dev fallback, built
from aggregated phishing feeds (alive-checked union — see
core/kanon/README.md). Clean browsing never queries. - Phase 3 — certificate engine (gated): CT logs on Chrome,
getSecurityInfoon Firefox. Confirms/escalates already-suspicious domains. - Phase 4 — ML lookalike engine: a LightGBM model that extends recall to fuzzier squats the deterministic rules miss. Local, no leak. Features computed only in TS (no train/serve skew); ships as ONNX (browser) + a pure-TS tree evaluator (eval / fallback).
- Phase 4b — hybrid char-CNN (a second model behind the same
Classifierinterface, not a replacement): reads the raw URL character by character and the 19 TS features, to catch lexical signal the aggregate features miss. Same local guarantee, same dual backend (ONNX + pure-TS). On the realistic corpus (Umbrella hostnames, see below) it is the only viable ML backend: AUC 0.919 vs LightGBM's 0.720 — aggregate features alone no longer separate once legit examples carry real subdomains.
npm install
npm run check # typecheck + tests + build blocklist + evalScripts:
npm run typecheck
npm run test # vitest
npm run build:blocklist # builds dist/blocklist.bloom.json from the feeds
npm run build:kanon # builds dist/kanon/ (kanon.bin client + JSON DBs, prod blocklist)
npm run eval # Chrome path
npm run eval:firefox # Firefox pathThree engines now run on every navigation (lookalike + blocklist are local; cert is gated and only runs on already-suspicious domains):
| category | example | engine | verdict |
|---|---|---|---|
| homoglyph / IDN | xn--pypal-4ve.com (pаypal) |
lookalike | block |
| visual / typo squat | paypa1…, rncrosoft, gogle |
lookalike | block |
| combosquat + fresh cert | microsoft-account-verify.com |
lookalike+cert | block (escalated) |
| combosquat, older cert | netflix-billing-update.com |
lookalike+cert | caution |
| known-bad host / page / IP | account-security-check.info, 198.51.100.23/… |
blocklist | block |
| edit-distance-2 squat (below threshold) | peypol.com, miicrosof.com, neflixx.com |
ml | ok ⚠ (missed) |
| compromised host, other pages | compromised-shop.store/ |
— | ok |
| benign mention / legit IDN / legit subdomain | apple-orchard-tours.com, münchen.de |
— | ok |
| brand subdomain of a popular site | apple.stackexchange.com |
— | ok |
| brand subdomain, unknown site, no other cue | paypal.some-blog.xyz |
lookalike | caution |
Seed eval set (30 URLs): precision 1.00, recall 0.83, FPR 0%. The three
edit-distance-2 .com squats above are the only misses — they score below the
high-precision threshold (real-corpus model), which is the gap the char-CNN
chips away at on the disjoint test, not on this hand-built set.
Privacy, same detection both platforms:
| platform | legit sites leaked | overall leaked |
|---|---|---|
| chrome | 0 / 12 | 15 / 30 (all phishing — bench-only simulated CT source) |
| firefox | 0 / 12 | 0 / 30 (local cert reads) |
The cert engine escalates three combosquats from caution to block (a freshly
issued free-DV cert on a brand squat). On Firefox the facts come from a
local getSecurityInfo read (zero leak). Production Chrome ships no cert
source at all (NullCertSource — a CT-log query would be an external
request, excluded by the "zero external requests, ever" decision); the bench's
Chrome path keeps a simulated leaking source precisely to prove the
suspicion gate contains it. Either way, legit sites never reach a leaking path.
Still a small hand-built set — it validates behaviour, not real-world accuracy.
Production default is k-anonymity (exact confirmation, zero FP); the Bloom
filter remains the bench/dev fallback. This matters because a blocklist hit is
deliberately hard to silence: the ordinary allowlist never exempts it — only
the reinforced override (two explicit checkboxes on the interstitial)
does. A Bloom false positive (~0.1–0.2%) would be recurring user-facing
friction, so the production engine must have zero FP. The blocklist
subsystem's single reference — binary format, feed sources, freshness,
vetting, audit — is core/kanon/README.md.
tools/build-kanon.tsbuilds three k-anonymity artifacts from the real feed union (alive-checked,brands.legit-vetted):dist/kanon/kanon.bin(the CLIENT artifact — sorted 8-byte truncated hashes in a compact binary,core/kanon/binary.ts; ~2.9 MB for the current 372k keys, ~12× smaller than the old JSON pair),dist/kanon/prefixes.json(client DB of the HTTP wiring) anddist/kanon/buckets.json(prefix → full hashes, backend). Production wiring bundles kanon.bin locally (zero FP, zero network — refreshed from static hosting, the remaining Phase 6 task); anHttpConfirmerbackend holding the buckets is the future option for multi-million-entry feeds (seecore/kanon/README.md).core/kanon/blocklist-kanon.tstests locally first (prefix match, no network); only on a match (~never on clean browsing) does it ask theConfirmerfor the bucket and confirm by exact match — full hash (JSON, HTTP) or ≥ 8-byte truncation (kanon.bin, residual FP odds ~2^-64). The only thing that can ever leave the device is a 4-byte hash prefix shared by a huge anonymity set.core/kanon/artifacts.tsturns the parsed artifacts intoEngineDeps.kanon, whichbuildEnginesprefers over the Bloom filter.tools/build-blocklist.ts(the "cron") aggregates the feeds, canonicalizes each entry into keys, and writes a sized Bloom filter todist/blocklist.bloom.json(bench/dev path). Run with--remoteto fetch live feeds.core/bloom.tsis a Bloom filter with a deterministic murmur3 hash shared by builder and client, so a server-built filter tests correctly in the browser.core/canonicalize.tsproduces full / host+path / host keys. Host-level feed entries match any page on the host; path-level entries match only that page. Both blocklist engines share these keys.
A single brand-in-subdomain match used to block on its own, which broke real
community sites (apple.stackexchange.com). It now applies a two-axis matrix:
block requires an unknown registrable and a second deception cue (a
suspicious sibling token like login/verify, or a TLD-like token right after
the brand — paypal.com.evil.ru fakes a full FQDN); a popular registrable
with no cue stays silent; everything in between raises caution, which
the ML engine can corroborate into a block (noisy-OR). "Popular" =
EngineDeps.popularDomains, the Tranco top-100k minus PSL-private wildcard
hosts (webflow.io et al. rent subdomains to anyone — measured phishing
abuse). Measured on the 80k-legit disjoint test: subdomain-brand block FPs
dropped ~106 → 7 with the combined-system numbers unchanged.
core/cert/types.tsdefinesCertFacts(age, issuer, free-DV, SAN count, reuse, domain mismatch…) and theCertSourceabstraction.core/cert/score-cert.tsturns facts into signals — pure and shared. A newborn or free-DV cert on an already-suspicious domain is what tips caution to block.core/cert/ct-source.ts(CtLogCertSource) would query crt.sh by hostname. Not wired anywhere in production — Chrome shipsNullCertSource(zero external requests, ever); the parser stays tested should that decision be revisited, and would remain gated (mayLeakData = true).platform/firefox/local-cert-source.tsreads the live cert viagetSecurityInfo.mayLeakData = false— no extra request, no leak.core/engines/cert.tswraps whichever source is injected and, on the leaking path, emits a zero-severitycert.lookupmarker so the privacy budget counts the network call even when the cert is unremarkable.
The deterministic engine is tight (token edit distance ≤ 1) to keep false
positives near zero; that means edit-distance-2 squats like peypol.com
(paypal) slip through. The ML engine targets that gap and lifts recall on the
real corpus (see the disjoint-test numbers in train/README.md). On this tiny
hand-built set, though, those three short .com squats still score below the
high-precision deploy threshold — they sit structurally among the legit domains,
the exact error-analysis finding the hybrid char-CNN was built to chip away at.
core/lookalike/features.tsturns a domain into a 19-feature vector (brand edit distance, skeleton match, entropy, digit ratio, affix/lure flags, a bigram naturalness score, risky-TLD flag, …). This is the only feature code, used for both training and inference, so there's no train/serve skew.tools/gen-dataset.ts(synthetic) ortools/ingest-corpus.ts(real corpus) build the dataset;tools/ingest-umbrella.tsbuilds the legit list from the Cisco Umbrella top-1M (real hostnames WITH subdomains, vetted against Tranco — a bare-registrable legit corpus teaches the model "any subdomain => phishing", seetrain/README.md);tools/extract-features.tswrites the training CSV and the shared bigram assets.train/train.pytrains LightGBM and exportsmodel.onnx(browser) plus a JSON tree dump, asserting LightGBM ≡ ONNX ≡ TS-traversal on the holdout.core/ml/tree-model.tsevaluates the tree dump in pure TS (used by the eval and as a no-native-deps browser fallback);platform/shared/onnx-classifier.tsis theonnxruntime-webpath. Both implement oneClassifierinterface.core/engines/ml-lookalike.tsruns the model, short-circuits known-legit brand domains, and emits a probabilistic signal that corroborates rather than auto-blocks.
See train/README.md to train on a real corpus.
LightGBM reads only the 19 aggregate features; error analysis showed many misses
sit structurally among the legit domains (short, .com, low subdomain depth) —
the signal is lexical (the exact character sequence), which aggregates flatten.
The CNN adds a second input — the raw URL, character by character — alongside the
same 19 features, behind the same Classifier interface. LightGBM stays; you
pick the backend at eval time (ML_MODEL=lightgbm|cnn).
The anti-skew trap here is the character encoding: it must be identical at training (Python) and inference (TS / browser). So it lives in one place conceptually, mirrored on both sides and proven equal by a test:
core/ml/char-encoding.ts⟺train/char_encoding.py— a fixed 59-char URL vocabulary (PAD=0,UNK=1, so 61 rows), fixed lengthL=128, ASCII-only lowercasing (Unicode casefolding diverges between JS and Python), truncate from the end (keep the host) + right-pad.test/cnn-encoding.test.tsasserts the TS encoder reproduces the Python reference vectors byte-for-byte.train/train_cnn.pytrains in PyTorch:Embedding → 3×Conv1D+ReLU(+MaxPool) → global-max-pool, concatenated with the 19 standardized features → dense → sigmoid (~48k params). It reads URLs (dataset.*.jsonl) and the 19 features (features.*.csv) aligned line-by-line (alignment asserted via the label column), uses class weights to exploit the natural 557k-legit / 311k-phishing imbalance instead of discarding legit, and exportsmodel_cnn.onnx(~190 KB) + a weights JSON + a meta file (feature order, standardization mean/std, vocab, architecture, threshold). After export it asserts PyTorch ≈ onnxruntime to < 1e-4 on the holdout, exactly liketrain.pydoes for LightGBM.core/ml/cnn-model.tsis the pure-TS evaluator (mirrors the PyTorch forward pass, zero native deps — used by the eval and as the browser fallback);platform/shared/onnx-cnn-classifier.tsis theonnxruntime-webpath. Feature standardization happens in both wrappers (outside the graph), from the saved mean/std.test/cnn-model.test.tsasserts CnnModel ≈ PyTorch.
So the same "two backends, one model, all cross-checked" discipline as LightGBM:
PyTorch ≡ ONNX (Python) and CnnModel ≡ PyTorch (TS). See train/README.md for
the full training commands.
Result (disjoint test, realistic Umbrella-based corpus, precision ≥ 0.99):
CNN AUC 0.919, recall 0.463 (threshold 0.991, EPOCHS=12) vs LightGBM AUC
0.720, recall 0.047 — on a realistic hostname distribution the 19 aggregate
features no longer separate, and the CNN (188 KB ONNX, ~48k params) is the only
viable ML backend. Full combined system (ML_MODEL=cnn): precision 0.982 /
recall 46.7 % / FPR 0.9 %. Earlier reports (AUC 0.964/0.936, recalls 0.814/0.765) were an artifact
of the bare-registrable Tranco corpus — see train/README.md and the guard below.
The CNN is the default backend everywhere (loadModel, eval:system,
evaluate.py, warning-preview); LightGBM remains an explicit opt-in fallback
(ML_MODEL=lightgbm / MODEL=lightgbm). Pick per run: MODEL=both python3 train/evaluate.py (the decisive number, onnxruntime authority — what
run-split-eval.sh uses) and EVAL_LIMIT=20000 npx tsx tools/eval-system.ts
(full system; EVAL_LIMIT subsamples for the slower pure-TS CNN evaluator).
Regression guard after every retrain: prefixing www. to bare legit test
domains must cross the deploy threshold ~0 times on both models.
core/ browser-agnostic, 100% unit-testable
types.ts the Engine / Signal / Verdict contracts
normalize.ts URL -> NormalizedTarget (PSL via tldts + skeleton)
skeleton.ts punycode decode + Unicode confusable folding
distance.ts Damerau-Levenshtein (optimal string alignment)
brands.ts protected brands + affix/lure lexicons
bloom.ts Bloom filter + deterministic murmur3 (shared builder/client)
canonicalize.ts full / host+path / host blocklist keys
score.ts noisy-OR aggregation + verdict thresholds
orchestrate.ts navigation -> [ASYMMETRIC ALLOWLIST BYPASS] -> open engines -> PRIVACY GATE -> gated engines -> verdict
cert/
types.ts CertFacts + CertSource abstraction
score-cert.ts facts -> signals (pure, shared)
ct-source.ts Chrome: crt.sh CT-logs source (leaks hostname)
lookalike/
features.ts 19-feature extractor (train + serve, no skew)
ml/
tree-model.ts pure-TS LightGBM evaluator + Classifier interface
char-encoding.ts URL -> char-index sequence for the CNN (≡ char_encoding.py)
cnn-model.ts pure-TS hybrid char-CNN evaluator (mirrors PyTorch)
kanon/
hash.ts sha-256 + hash prefixes
confirmer.ts LocalConfirmer (offline) / HttpConfirmer (backend, prefix-only)
blocklist-kanon.ts local prefix filter + exact confirmation (zero FP)
binary.ts kanon.bin codec — compact client artifact, searched in place
artifacts.ts parsed build-kanon artifacts -> EngineDeps.kanon
engines/
lookalike.ts Phase 1 — REAL (tiered subdomain-brand, popularDomains-aware)
url-lure.ts deterministic URL-lure rules (@ userinfo, IP literal, embedded redirect, exotic port)
ml-lookalike.ts Phase 4 — REAL (injected model; passes rawUrl for the CNN)
ml-url.ts URL CNN — corroborative second ML engine (never blocks alone)
blocklist.ts Phase 2 — REAL (Bloom filter — bench/dev fallback)
blocklist-kanon.ts Phase 2 — REAL (k-anonymity — production default)
cert.ts Phase 3 — REAL (gated, injected CertSource)
index.ts engine factory per platform (kanon takes precedence over Bloom)
tools/
build-blocklist.ts the "cron": feeds -> Bloom artifact (dist/)
build-kanon.ts feeds -> dist/kanon/ (kanon.bin + JSON DBs); kanon-server.ts = backend ref
feed-vetting.ts brands.legit rejection at build time (shared by both builders)
alive-check.ts DNS liveness filter for the feeds (Cloudflare DoH — never contacts the sites)
kanon-audit.ts scan/cure of the shipped artifact against poisoned feed entries
gen-dataset.ts synthetic dnstwist-style training data
ingest-corpus.ts real corpus -> dataset.jsonl (BALANCE=0 = imbalanced)
ingest-umbrella.ts Cisco Umbrella top-1M -> legit.txt (vetted vs Tranco, UNION_TRANCO)
extract-features.ts dataset -> features.csv + bigram assets
eval-system.ts combined-engine eval (ML_MODEL=cnn|lightgbm, default cnn; EVAL_LIMIT)
feeds/ sample feeds (replaced by live feeds with --remote)
train/ train.py + evaluate.py (LightGBM); train_cnn.py + char_encoding.py (CNN)
platform/shared/ onnx-classifier.ts (LightGBM) + onnx-cnn-classifier.ts (CNN), via onnxruntime-web;
user-allowlist.ts (storage.local-backed allowlist, sync in-memory mirror);
warning.ts (block -> interstitial params); surface.ts (badge/banner/popup glue)
platform/chrome/ MV3 glue; NO cert source (NullCertSource); pure-TS CnnModel
platform/firefox/ local-cert-source.ts (getSecurityInfo); blocking webRequest
ui/ Phase 5 brut renderers: warning.* (interstitial), banner.* (caution), popup.*
eval/ labeled set + metrics + runner; load-legit.ts = loadPopularDomains
(Tranco top-100k minus PSL-private wildcard hosts minus abused hosters)
test/ unit tests incl. ml, cnn-encoding (TS≡Python), cnn-model (TS≡PyTorch)
orchestrate.ts runs cheap local engines first (lookalike, blocklist — both
zero-leak), then only runs engines marked gatedBySuspicion (the certificate
engine) if prior suspicion clears gateThreshold. A clean-looking domain never
reaches an engine that could touch the network.
When the user vouches for a registrable domain (eTLD+1), the orchestrator applies
an asymmetric bypass: it skips every engine flagged
bypassableByAllowlist — the suspicion engines (lookalike, ml-lookalike,
cert) — but still runs the certainty engines (blocklist / blocklist-kanon,
flagged false). So one click silences a guess ("stop warning me about this
site"), never a confirmed verdict: if an allowlisted domain later shows up in a
known-phishing feed it still blocks, with Verdict.userAllowlisted = true so the
warning page can say "you approved this site, but it is now flagged as malicious".
Trust is two-tier (2026-07-05): a second, reinforced level
(hasBlocklistOverride, granted behind two explicit checkboxes on the
interstitial) durably silences the blocklist too — the user can override a
confirmed verdict, but only through that extra friction. One curated exception
sits above everything: a registrable in brands.legit can never be blocked by
the blocklist at all (see core/kanon/README.md for the full trust hierarchy).
The allowlist is injected into evaluateUrl (optional 4th arg); the eval and
tests don't inject one, so detection numbers are unchanged. Storage is
platform/shared/user-allowlist.ts: 100% local (browser/chrome.storage.local,
Firefox first), an in-memory Set mirror kept in sync via storage.onChanged so
has() stays synchronous.
- Phase 0 — foundations + eval bench ✅
- Phase 1 — lookalike engine ✅
- Phase 2 — blocklist engine ✅ (Bloom filter, canonical keys, builder)
- Phase 3 — certificate engine ✅ (CT logs on Chrome, getSecurityInfo on Firefox)
- Phase 4 — ML lookalike ✅ (LightGBM, TS features, ONNX + pure-TS backends)
- Phase 4b — hybrid char-CNN ✅ (second model, same interface; on the realistic corpus it is the only viable ML backend — AUC 0.919 vs 0.720)
- Phase 5 — scoring tuning + UX ✅: user allowlist + asymmetric bypass (5.1), block
interstitial (5.2), caution banner + popup + badge (5.3). Logic in
platform/shared/{warning,surface,user-allowlist}.ts; brut renderers inui/. Next: live-use calibration of thecaution/ML thresholds, then polish. - FP fixes (task 6) ✅: tiered subdomain-brand matrix (block FPs ~106 → 7 on the
80k-legit test) + k-anonymity blocklist as the production default. Remaining FP
backlog: real brand domains missing from
brands.legit(googlemail.com, microsoftonline.cn…) behind ~90 pre-existing typosquat FPs. - Phase 6 — distribution (in progress): the compact client blocklist
(
kanon.bin) is done; remaining — feed-freshness pipeline + static hosting, in-extension blocklist updates, store packaging (minimal permissions, explicit "no browsing data leaves your machine" privacy policy, licence, lint). Task list inCLAUDE.md.
- The
feeds/*.txtandeval/dataset.ts"phishing" rows are synthetic — not real live URLs. Use--remoteand real feeds in production. - Adding a real engine never touches
orchestrate.tsoreval/— you only editengines/index.ts.
Training (LightGBM and the CNN) runs in a virtualenv at .venv. Always use
python3 (never python).
python3 -m venv .venv
source .venv/bin/activate
pip install -r train/requirements.txt
# extra deps for the hybrid char-CNN (Phase 4b):
pip install torch --index-url https://download.pytorch.org/whl/cpu # CPU-only
pip install onnxscript # torch>=2.9 ONNX export- LightGBM model:
python3 train/train.pythenpython3 train/evaluate.py(seetrain/README.mdfor the full leak-free workflow on a real corpus). - Hybrid CNN: build the imbalanced dataset (
ingest-corpuswithBALANCE=0, thenextract-featureswithASSETS_IN=…), thenpython3 train/train_cnn.py, and compare withMODEL=both python3 train/evaluate.py. Full commands intrain/README.md. Real training on ~869k URLs is ~10 min on CPU — a quickSAMPLE=80000smoke run validates the pipeline first.
Publication sur les stores (paquet, sources, textes des fiches, notes aux relecteurs) :
store/README.md. Politique de confidentialité publique : PRIVACY.md.
Savvy Fish est un logiciel libre publié sous GNU General Public License v3.0 ou ultérieure
(texte complet : LICENSE).
Copyright (C) 2026 Savvy Fish <notphished@protonmail.com>
This program is free software: you can redistribute it and/or modify it under the terms
of the GNU General Public License as published by the Free Software Foundation, either
version 3 of the License, or (at your option) any later version.
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with this program.
If not, see <https://www.gnu.org/licenses/>.