Skip to content

Repository files navigation

PhishGuard

A privacy-first, cross-browser anti-phishing extension. Built in phases:

  • Phase 0 — foundation: core, orchestrator, Engine contract, eval bench.
  • Phase 1lookalike engine (homoglyph / typo / combosquat / tiered subdomain-brand), fully local.
  • Phase 2blocklist 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 3certificate engine (gated): CT logs on Chrome, getSecurityInfo on Firefox. Confirms/escalates already-suspicious domains.
  • Phase 4ML 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 4bhybrid char-CNN (a second model behind the same Classifier interface, 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.

Quick start

npm install
npm run check        # typecheck + tests + build blocklist + eval

Scripts:

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 path

What works today

Three 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.

How the blocklist works

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.ts builds 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) and dist/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); an HttpConfirmer backend holding the buckets is the future option for multi-million-entry feeds (see core/kanon/README.md).
  • core/kanon/blocklist-kanon.ts tests locally first (prefix match, no network); only on a match (~never on clean browsing) does it ask the Confirmer for 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.ts turns the parsed artifacts into EngineDeps.kanon, which buildEngines prefers over the Bloom filter.
  • tools/build-blocklist.ts (the "cron") aggregates the feeds, canonicalizes each entry into keys, and writes a sized Bloom filter to dist/blocklist.bloom.json (bench/dev path). Run with --remote to fetch live feeds.
  • core/bloom.ts is 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.ts produces 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.

How the lookalike tiers a brand-in-subdomain hit (FP fix)

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.

How the certificate engine works

  • core/cert/types.ts defines CertFacts (age, issuer, free-DV, SAN count, reuse, domain mismatch…) and the CertSource abstraction.
  • core/cert/score-cert.ts turns 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 ships NullCertSource (zero external requests, ever); the parser stays tested should that decision be revisited, and would remain gated (mayLeakData = true).
  • platform/firefox/local-cert-source.ts reads the live cert via getSecurityInfo. mayLeakData = false — no extra request, no leak.
  • core/engines/cert.ts wraps whichever source is injected and, on the leaking path, emits a zero-severity cert.lookup marker so the privacy budget counts the network call even when the cert is unremarkable.

How the ML engine works

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.ts turns 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) or tools/ingest-corpus.ts (real corpus) build the dataset; tools/ingest-umbrella.ts builds 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", see train/README.md); tools/extract-features.ts writes the training CSV and the shared bigram assets.
  • train/train.py trains LightGBM and exports model.onnx (browser) plus a JSON tree dump, asserting LightGBM ≡ ONNX ≡ TS-traversal on the holdout.
  • core/ml/tree-model.ts evaluates the tree dump in pure TS (used by the eval and as a no-native-deps browser fallback); platform/shared/onnx-classifier.ts is the onnxruntime-web path. Both implement one Classifier interface.
  • core/engines/ml-lookalike.ts runs 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.

How the hybrid char-CNN works (Phase 4b)

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.tstrain/char_encoding.py — a fixed 59-char URL vocabulary (PAD=0, UNK=1, so 61 rows), fixed length L=128, ASCII-only lowercasing (Unicode casefolding diverges between JS and Python), truncate from the end (keep the host) + right-pad. test/cnn-encoding.test.ts asserts the TS encoder reproduces the Python reference vectors byte-for-byte.
  • train/train_cnn.py trains 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 exports model_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 like train.py does for LightGBM.
  • core/ml/cnn-model.ts is 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.ts is the onnxruntime-web path. Feature standardization happens in both wrappers (outside the graph), from the saved mean/std. test/cnn-model.test.ts asserts 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.

Architecture

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)

The privacy gate (the important bit)

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.

The user allowlist (Phase 5.1) — asymmetric by design

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.

Roadmap

  • 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 in ui/. Next: live-use calibration of the caution/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 in CLAUDE.md.

Notes

  • The feeds/*.txt and eval/dataset.ts "phishing" rows are synthetic — not real live URLs. Use --remote and real feeds in production.
  • Adding a real engine never touches orchestrate.ts or eval/ — you only edit engines/index.ts.

Python env (training)

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.py then python3 train/evaluate.py (see train/README.md for the full leak-free workflow on a real corpus).
  • Hybrid CNN: build the imbalanced dataset (ingest-corpus with BALANCE=0, then extract-features with ASSETS_IN=…), then python3 train/train_cnn.py, and compare with MODEL=both python3 train/evaluate.py. Full commands in train/README.md. Real training on ~869k URLs is ~10 min on CPU — a quick SAMPLE=80000 smoke run validates the pipeline first.

Distribution & licence

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/>.

About

Savvy Fish is an anti-phishing extension that makes no network requests at all.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages