A cross-browser phishing detection suite with an active-learning loop — extension catches the URL, local model scores it, user feedback flows back into a labeled corpus, retraining is one CLI away.
A privacy-conscious phishing detector that runs entirely on the user's machine: a Chrome / Firefox MV3 extension talks to a local Python inference service over loopback; flagged URLs and user reports land in a Postgres-backed feedback store; a Next.js dashboard closes the active-learning loop.
Phishing-detection products usually fall into two camps: cloud scanners that see every URL you visit (great signal, real privacy cost), and pure block-list extensions (privacy-clean, but one domain-rotation away from useless). PhishShield sits between them — features and inference live on the user's machine, only opt-in user-reported URLs ever leave the device, and the labeled corpus that drives retraining is auditable in the dashboard.
| Capability | Where it shows up |
|---|---|
| Cross-browser MV3 extension | TypeScript + React; one Vite build pipeline produces both Chrome and Firefox bundles. Background service worker, content-script DOM probe, popup risk card, on-page warning banner. |
| Lexical + DOM features | URL features (entropy, IDN homograph, brand similarity, suspicious TLDs, IP-as-host) computed in the extension; DOM features (password field on non-HTTPS, hidden iframes, login form on a young domain) added by the content script. Same feature schema is recomputed server-side for safety. |
| Local-only inference | FastAPI + scikit-learn GradientBoosting model, bound to 127.0.0.1, bearer-token auth. The extension never sends URLs anywhere except localhost. |
| Active-learning loop | User reports a missed phish (or a false positive) → row lands in feedback table → dashboard shows the queue → reviewer labels → make train rebuilds the model from seed.csv ∪ labeled feedback. |
| Drift-aware metrics | Dashboard plots precision / recall / FPR over the last N labeled rows, score distributions per class, and a per-feature importance bar via SHAP-style permutation. |
| Model card | Honest, machine-readable MODEL_CARD.md: training data, intended use, known failure modes (out-of-distribution TLDs, non-Latin scripts), evaluation slice. |
| Polyglot monorepo | Python for inference + training, TypeScript for both the extension and the dashboard (Next.js + Tailwind). |
┌─────────────────────┐ ┌──────────────────────┐
│ Chrome / Firefox │ POST /v1/score │ inference │
│ MV3 extension │ ──────────────────▶ (Python / FastAPI) |
│ · URL features │ │ │
│ · DOM features │ POST /v1/feedback │ │
│ · banner injection │ ──┐ │ · GradientBoost │
└─────────────────────┘ │ │ · feature recompute │
▲ │ └────┬─────────────────┘
│ score │ user reports │
│ ▼ ▼
│ ┌──────────────────────────────┐
│ │ Postgres │
│ │ reports, labeled, models │
│ └────┬─────────────────────────┘
│ │
│ ▼
│ ┌────────────────────┐
│ │ dashboard │
│ │ (Next.js + TS) │
│ │ · /reports queue │
│ │ · /labeled │
│ │ · /metrics │
│ └────────────────────┘
│
┌──────────┴──────────────┐
│ phishshield-train (CLI) │ retrains model from seed ∪ labeled feedback
└─────────────────────────┘
See ARCHITECTURE.md for the request lifecycle, feature schema, and the rationale behind each language pick.
make compose-up # builds + brings inference + dashboard + postgres up
make seed # loads seed.csv and trains v0 model into postgres
make demo # scores a benign + a synthesized phish, files a reportAfter make compose-up, the stack is reachable on localhost:
| Port | Service | What |
|---|---|---|
| 8000 | inference | POST /v1/score, POST /v1/feedback, GET /v1/admin/* |
| 3000 | dashboard | Next.js, the active-learning UI |
| 5432 | postgres | reports + labeled + model_versions tables |
The extension is loaded into the browser as an unpacked extension —
see extension/README.md for make ext-build
plus the per-browser load step. On first run the extension's options
page asks for the inference URL (http://localhost:8000) and the
bearer token (printed by make compose-up).
Tear down with make compose-down.
The Compose path is the recommended one — but if you want every moving part on bare metal (no container runtime, SQLite instead of Postgres), the steps below stand up the same stack with about a minute of work.
cd services/inference
python -m venv .venv
# Windows PowerShell / Git Bash:
.venv/Scripts/python.exe -m pip install -e ".[dev]"
# macOS / Linux:
.venv/bin/python -m pip install -e ".[dev]"Generate a bearer token, train v0, then start the API:
cd ../.. # back to repo root
bash scripts/gen-secret.sh # writes PHISHSHIELD_API_KEY into .env
TOKEN=$(grep PHISHSHIELD_API_KEY .env | cut -d= -f2)
cd services/inference
PHISHSHIELD_DATABASE_URL=sqlite:///./phishshield.db \
PHISHSHIELD_API_KEY=$TOKEN \
.venv/Scripts/python.exe -m phishshield_inference.train --reseed --note "v0 local"
PHISHSHIELD_DATABASE_URL=sqlite:///./phishshield.db \
PHISHSHIELD_API_KEY=$TOKEN \
PHISHSHIELD_HOST=127.0.0.1 \
PHISHSHIELD_PORT=8000 \
PHISHSHIELD_CORS_ORIGIN=http://localhost:3000 \
.venv/Scripts/phishshield-inference.exe # use phishshield-inference on macOS/Linux/healthz should answer 200 on http://127.0.0.1:8000.
In a second terminal:
cd dashboard
npm install
PHISHSHIELD_INFERENCE_URL=http://127.0.0.1:8000 \
PHISHSHIELD_API_KEY=$(grep PHISHSHIELD_API_KEY ../.env | cut -d= -f2) \
npm run devOpen http://localhost:3000. The five routes (/, /reports,
/labeled, /metrics, /models) should all render against the
running inference service.
In a third terminal:
cd extension
npm install
npm run build # emits dist-chrome/ and dist-firefox/Load the unpacked extension:
- Chrome / Edge / Brave —
chrome://extensions/→ enable Developer mode → Load unpacked → pickextension/dist-chrome/. - Firefox —
about:debugging→ This Firefox → Load Temporary Add-on… → pick any file insideextension/dist-firefox/(Firefox loads the directory).
The options page opens automatically on first run. Paste:
- Inference URL:
http://127.0.0.1:8000 - Bearer token: the value of
PHISHSHIELD_API_KEYfrom.env
⚠ Content blockers can mask the extension. uBlock Origin, Privacy Badger, NoScript, and similar extensions can suppress the content script's runtime messaging — the popup will then sit on Loading… indefinitely, with no error logged. If the popup hangs, temporarily disable the blocker on the page you're testing (uBlock: click its icon → power button) and reload. PhishShield coexists fine with content blockers in normal use; this only trips the initial score on a freshly-loaded page.
Score a benign + a phish from the shell:
bash examples/curl/score-url.sh "https://www.google.com/"
bash examples/curl/score-url.sh "http://verify-account-update-secure-banking.tk/"File a report:
bash examples/curl/demo.shThe /reports page in the dashboard now shows the new row in
pending. Click Confirm phish to push it into the labeled set,
then retrain:
cd services/inference
PHISHSHIELD_DATABASE_URL=sqlite:///./phishshield.db \
PHISHSHIELD_API_KEY=$TOKEN \
.venv/Scripts/python.exe -m phishshield_inference.train --note "v1 after feedback"A new row appears in /models. Click Activate — the inference
service hot-reloads the new model in-memory; no restart, the
extension's next request scores against the new version.
Ctrl-C in each terminal. Removing services/inference/phishshield.db
gives you a clean DB on the next run.
-
The dashboard at http://localhost:3000 — Reports shows the URL just filed by
make demoawaiting review. Click Confirm phish to move it into the labeled set. -
Score a URL directly — sanity-check the model from the shell:
curl -s http://localhost:8000/v1/score \ -H "Authorization: Bearer $(cat .env | grep PHISHSHIELD_API_KEY | cut -d= -f2)" \ -H 'Content-Type: application/json' \ -d '{"url":"http://192.168.0.5/secure-login-verify-account/"}' | jqExpect a high
scoreand atop_featuresarray naming the URL shape signals (IP-as-host, suspicious keywords, no HTTPS). -
Retrain after labeling — once you have a few labeled feedback rows in the dashboard, regenerate the model:
make train # writes a new row into model_versionsThe dashboard's Models tab lists every version with its eval metrics; switch the active model with one click.
-
Inspect the model card — MODEL_CARD.md is machine-readable (front-matter + body). The dashboard's Metrics tab pulls evaluation numbers from the same source so the card never drifts from reality.
Innermost first:
page navigation → content-script DOM probe → background score request → popup card refresh + (if score ≥ threshold) banner injection. The
content script never blocks navigation; the banner is informational.
Hard-blocking is opt-in in the options page — the default posture is
warn, don't break the web.
CI runs the full TS-extension + TS-dashboard + Python-inference matrix
(typecheck, lint, test) in parallel on every push to main and every PR.
Polyglot monorepo. Each component builds and tests independently.
| Component | Toolchain | Build | Test |
|---|---|---|---|
extension |
Node ≥ 20, TypeScript | npm run build |
npm test |
dashboard |
Node ≥ 20, Next.js 15 | npm run build |
npm test |
services/inference |
Python ≥ 3.11 | pip install -e .[dev] |
pytest |
make help lists every target. CI (.github/workflows/ci.yml) runs
the same lint + test matrix across TS-extension, TS-dashboard, and
Python on every push to main and every PR.
MIT — © 2026 Dimitris Sofikitis. See LICENSE.