Project site · Architecture · Privacy policy · Threat model · ML methodology · Demo script
PhishLens is a defensive Chrome extension and FastAPI backend for explainable phishing risk analysis in real time.
It combines local URL heuristics, privacy-preserving DOM signals, optional PhishTank threat intelligence, backend-side TLS certificate inspection with Certificate Transparency lookups, RDAP domain-age checks, and an optional machine learning model. The project is built as a practical cybersecurity portfolio project with clear safety boundaries.

PhishLens flagging a cloned PayPal login on a look-alike domain (paypal-verify-account.net) and explaining why, both in the popup and in the in-page warning. Both sites are local demo pages and the domain is not registered. The walkthrough below goes from the phishing email to the in-page warning:

# 1. Clone and set up the backend
git clone https://github.com/JuanCardesa/PhishLens.git && cd PhishLens
cp .env.example .env
python -m venv .venv && source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -r backend/requirements-dev.txt
# 2. Start the backend and leave it running
uvicorn app.main:app --app-dir backend --reload
# 3. In a second terminal, from the repository root, build the extension
cd extension && npm install && npm run build
# 4. Load it in Chrome: chrome://extensions → Developer mode → Load unpacked → extension/dist
The extension also works without the backend; see Development Setup for Windows commands and Local Demo for pages that show each risk label. To rebuild the ML dataset and retrain the model: python ml/datasets/build_dataset.py && python ml/train_model.py.
/analyze, /report, /health; PhishTank threat intel, backend TLS + Certificate Transparency inspection, RDAP domain-age lookups — each with URL normalization, TTL caching, timeouts, and clean degradation when unavailable.dangerous results. Works fully offline; the backend only enriches. Firefox support is experimental: the code and manifest are cross-browser, but it has not been click-tested in a real Firefox profile.The popup on the local demo pages (demo/pages/), captured from the built extension with node extension/scripts/record-demo.mjs --docs.
| Safe page | Suspicious page | Dangerous page | Backend unavailable |
|---|---|---|---|
![]() |
![]() |
![]() |
![]() |
With the backend unreachable, the safe page keeps the same score (8). The local score is the base the backend adds to, so the two only differ by what the backend finds.
The warning overlay on the dangerous demo page. That page also matches the localhost-only demo threat source (see Local Demo):

Chrome page
-> content script extracts non-sensitive DOM signals
-> service worker scores the page locally and sets the toolbar badge
-> popup computes the local score (URL + DOM points scaled to 0-100) and shows it
-> popup optionally calls FastAPI /analyze
-> backend starts from that same score and adds threat intel, TLS + Certificate
Transparency, domain age, and an ML adjustment (-5 to +12)
-> popup shows score, label, confidence, risk breakdown, and feedback controls
(confidence is a percentage only when the ML model contributed, "Heuristic" otherwise)
-> dangerous results can display a dismissible page overlay
-> development diagnostics expose counters only
The extension never sends full HTML, form values, passwords, or typed emails. The backend receives only the current URL and technical DOM features.
webextension-polyfill (for the experimental Firefox build).Linux / macOS
python -m venv .venv
source .venv/bin/activate
pip install -r backend/requirements-dev.txt
uvicorn app.main:app --app-dir backend --reload
Windows (PowerShell)
python -m venv .venv
.\.venv\Scripts\python.exe -m pip install -r backend/requirements-dev.txt
.\.venv\Scripts\python.exe -m uvicorn app.main:app --app-dir backend --reload
Health check:
curl http://localhost:8000/health
Interactive API documentation is available at http://localhost:8000/docs while the backend is running.
cd extension
npm install
npm run build
Load extension/dist in Chrome:
chrome://extensions.extension/dist folder.The extension works locally without the backend. When the backend is available at http://localhost:8000, the popup enriches the local result with backend analysis.
Extension settings are available from the popup settings button or Chrome extension details page. The default backend is http://localhost:8000.
Backend (the same checks as Backend CI):
cd backend
pytest tests
ruff check app tests ../demo
mypy app
Extension (the same checks as Extension CI):
cd extension
npm run lint # tsc --noEmit
npm run test
npm run build
npx playwright install chromium # first run only
npm run test:e2e # loads the built extension in real Chromium
Dependency audits (run by Security CI, not Extension CI):
cd extension && npm audit --audit-level=high
pip install pip-audit && pip-audit -r backend/requirements.txt
ML:
python ml/train_model.py
python ml/evaluate_model.py
python ml/evaluate_cv_metrics.py # the metrics in "ML Model Performance" below
python ml/evaluate_ml_adjustment.py --temporal # evidence behind the ML score adjustment (downloads data)
Docker:
docker compose build
docker compose up backend
Review automation:
python scripts/ci/pr_guardian.py --all
Demo readiness:
python scripts/dev/check_demo.py
Copy .env.example to .env for local overrides. No real keys are committed.
| Variable | Default | Description |
|---|---|---|
PHISHTANK_API_KEY |
(empty) | Optional PhishTank application key. Omit to skip threat intel. |
PHISHTANK_USER_AGENT |
phishtank/phishlens-demo |
User-Agent sent with PhishTank requests (required by their API). |
PHISHLENS_ALLOWED_ORIGINS |
http://localhost:5173 |
Comma-separated CORS origins. Add chrome-extension://* only for local dev. |
PHISHLENS_CHROME_EXTENSION_IDS |
(empty) | Comma-separated Chrome extension IDs for production CORS. |
PHISHLENS_ENABLE_THREAT_INTEL |
true |
Enable/disable PhishTank lookups. |
PHISHLENS_ENABLE_TLS_ANALYSIS |
true |
Enable/disable backend TLS certificate inspection. |
PHISHLENS_ENABLE_CT_LOG_LOOKUP |
true |
Enable/disable Certificate Transparency log lookups (crt.sh) as an additional TLS risk signal. |
PHISHLENS_ENABLE_DOMAIN_AGE_LOOKUP |
true |
Enable/disable RDAP domain-registration-age lookups. |
PHISHLENS_EXTERNAL_TIMEOUT_SECONDS |
4.0 |
Timeout for external backend enrichment calls such as PhishTank, RDAP, and crt.sh. |
PHISHLENS_MODEL_PATH |
app/models/phishlens_model.joblib |
Path to a trained joblib model artifact. |
PHISHLENS_BRAND_DOMAINS_PATH |
app/data/brand_domains.json |
Path to the curated brand-domain list used for typosquat/brand-impersonation detection. |
PHISHLENS_ENABLE_DIAGNOSTICS |
true |
Expose aggregate counters at GET /diagnostics. |
PHISHLENS_DIAGNOSTICS_TOKEN |
(empty) | When set, GET /diagnostics requires X-Diagnostics-Token: <value>. |
PHISHLENS_ENABLE_RATE_LIMITING |
true |
Enable in-memory sliding-window rate limits. |
PHISHLENS_ANALYZE_RATE_LIMIT |
60 |
Max /analyze requests per window per IP. |
PHISHLENS_REPORT_RATE_LIMIT |
20 |
Max /report requests per window per IP. |
PHISHLENS_RATE_LIMIT_WINDOW_SECONDS |
60 |
Rate-limit window in seconds. |
PHISHLENS_BEHIND_PROXY |
false |
Trust X-Forwarded-For when behind nginx / Caddy / ALB. |
PHISHLENS_FEEDBACK_DB_PATH |
feedback.db |
SQLite path for feedback metadata. Set to "" to disable. |
PHISHLENS_ENABLE_DEMO_THREAT_SOURCE |
false |
Enable localhost-only dangerous demo signal. |
Extension settings:
http://localhost:8000.dangerous results.PhishLens is defensive only. It must not collect credentials, typed emails, private form content, or full page HTML. It is a risk-assistance tool, not a phishing verdict authority. False positives and false negatives are expected, especially outside the limited dataset and signal coverage documented below.
Run the backend, demo pages, and extension locally, each in its own terminal:
# Terminal 1: backend with the localhost-only demo threat source
# (Linux / macOS: PHISHLENS_ENABLE_DEMO_THREAT_SOURCE=true uvicorn app.main:app --app-dir backend --reload)
$env:PHISHLENS_ENABLE_DEMO_THREAT_SOURCE="true"
.\.venv\Scripts\python.exe -m uvicorn app.main:app --app-dir backend --reload
# Terminal 2: demo pages on port 8080
python demo/serve_demo.py
# Terminal 3: build the extension
cd extension
npm run build
Load extension/dist in Chrome and visit:
http://localhost:8080/pages/safe.html (Safe)http://localhost:8080/pages/suspicious.html (Suspicious)http://localhost:8080/pages/phishlens-demo-dangerous-login-secure-update.html (Dangerous)Each page shows its label with or without the backend, and the E2E test checks all three. PHISHLENS_ENABLE_DEMO_THREAT_SOURCE=true adds a threat-intelligence match to the dangerous page so that category is visible without a PhishTank key; it only matches localhost URLs containing phishlens-demo-dangerous. Use localhost rather than 127.0.0.1: the backend rejects private IP literals as an SSRF safeguard.
Package the extension:
cd extension
npm run package
The zip is written to extension/release/.
78.5% precision / 81.7% recall (5-fold stratified CV, N=1,200) for the shipped
RandomForestClassifier on the committed PhishTank + Tranco dataset, with a 22% false-positive
rate. This is a modest, URL-shape-only model. That is why it only nudges the rule-based score
(−5 to +12) and never decides a verdict on its own.
These numbers used to read 99.0% precision / 0.8% false positives. That was a dataset artifact:
599 of the 600 legitimate URLs had no subdomain, so the model learned “has a subdomain” as
phishing. Once legitimate URLs got realistic subdomains, and www. stopped counting as one,
precision fell to its honest level (see Lessons Learned).
| Metric | As-is (N=1,200) | Deduplicated (N=1,052) |
|---|---|---|
| Precision | 0.785 | 0.786 |
| Recall | 0.817 | 0.823 |
| F1 | 0.801 | 0.804 |
| False-positive rate | 0.223 | 0.214 |
| ROC-AUC | 0.886 | 0.889 |
| Accuracy | 0.797 | 0.804 |
Confusion matrix [[TN, FP], [FN, TP]] |
[[466, 134], [110, 490]] |
[[423, 115], [91, 423]] |
Reproduce with python ml/evaluate_cv_metrics.py. Phishing is the positive class, and the
dataset is balanced 50/50. The deduplicated column keeps one row per distinct feature vector: it
is the closest check for train/test leakage, because the CSV stores no URLs.
python ml/evaluate_ml_adjustment.py --temporal).docs/ml-methodology.md has the full methodology: class balance, leakage, the URL-length and subdomain dataset biases that were found and fixed, the temporal validation, and how the adjustment was sized.
A few things found during a deliberate self-audit of this project, kept here instead of quietly fixed and forgotten, because how a bug was found and corrected is often more informative than the fact that the code is now clean:
import statement into two files that execute as classic, non-module scripts (MV3 content scripts and chrome.scripting.executeScript-injected files can’t be modules). tsc, vitest, and vite build all passed, because none of them load the bundle in an actual browser — Vitest mocks the module graph, and Vite’s build doesn’t check runtime module-format compatibility. Found by recording this README’s original demo GIF with a real Playwright + Chromium session instead of a screen recording tool, which surfaced “Could not establish connection” the moment the popup tried to collect DOM features. Fixed by splitting the build into two Rollup passes — ES modules for popup/options/the service worker, IIFE for the content script and overlay. The lesson: a green test suite proves the code you tested, not the environment you didn’t.https://example.com/) while phishing URLs from
PhishTank carry real paths, so url_length alone separated the two classes almost
perfectly — the model was learning “has a path” instead of phishing patterns. Fixed by
adding realistic paths to legitimate URLs; honest accuracy dropped to ~91% CV. Detailed
in docs/ml-methodology.md.www. prefix alone swung the ML adjustment from −10 to +20. Fixed by not
counting www as a subdomain and by giving legitimate URLs realistic hosts; the same PayPal
page now scores Safe 20 (page structure 16/30, ML −5). Precision fell from
99.0% to 78.5%, and a temporal check that had looked reassuring (0.91) fell to 0.67. The
near-perfect numbers had been measuring the dataset, not phishing. Detailed in
docs/ml-methodology.md.sklearn import triggered by lazily unpickling the ML model on
the first request, which also blocked the event loop for concurrent requests. Fixed by
warming up the model from a FastAPI lifespan handler at startup; first-request latency
dropped from ~5.5s to ~1.4s.See docs/roadmap.md.
PhishLens uses deterministic review gates instead of relying on a single reviewer. See docs/review-methodology.md and docs/release-process.md.
Publication preparation lives in docs/chrome-web-store.md, with permission rationale in docs/permissions.md.