PhishLens

ML Methodology

Objective

The model estimates whether a page has phishing-like risk signals from numeric URL and DOM features. It is an assistive signal in a hybrid scoring system, not a standalone verdict.

Datasets

Real dataset (preferred)

ml/datasets/real_phishing_urls.csv — built by ml/datasets/build_dataset.py and committed to the repository (it contains only the 16 numeric features and a label, never raw URLs or domains, so it carries no privacy risk).

Source Content Size
PhishTank public data dump Verified phishing URLs (verified=yes) 600 rows
Tranco top-1M list Legitimate domains (top 50 000 sampled) 600 rows

To rebuild the dataset from a fresh PhishTank/Tranco snapshot (requires internet access, ~1–2 min):

cd ml
python datasets/build_dataset.py
python train_model.py

build_dataset.py imports the backend URL feature extractor, so the 10 URL-derived training features are generated by the same code path used during inference. train_model.py stores the dataset name and SHA-256 in the joblib artifact, writes a local copy under ml/models/, and copies the runtime artifact to backend/app/models/phishlens_model.joblib for backend packaging. evaluate_model.py refuses to evaluate if the artifact metadata does not match the current CSV.

Limitation: DOM features (has_password_field, num_forms, etc.) are set to 0 for all URL-only rows because they require a live browser session to collect. The model therefore relies entirely on URL-derived signals when evaluated against this dataset. Real-world inference still uses DOM features from the extension’s content script — see Train/inference feature mismatch for why this means the metrics below describe a URL-only model, not the full production vector.

Known limitation found and fixed: URL-length separability bias

An earlier version of build_dataset.py built legitimate rows as bare Tranco domain roots (https://example.com/) while PhishTank phishing URLs are real captured pages that almost always carry a path or query string. A direct audit of the committed CSV showed this made the classes nearly separable on url_length alone — legitimate rows averaged ~21 characters, phishing rows ~48 — meaning the model could score well by detecting “has a path” rather than learning phishing-specific patterns. That would have missed phishing hosted at a clean root domain and false-flagged legitimate pages with long, parameterized URLs (e.g. SSO/OAuth callbacks).

Fix: build_dataset.py now appends a realistic path/query (/login, /account/settings, /search?q=..., etc., see REALISTIC_PATH_TEMPLATES) to ~80% of legitimate URLs before feature extraction, with the rest left as roots to reflect real traffic. This narrowed the mean url_length gap (legitimate ~38.5 vs. phishing ~49.3 characters after the fix, with overlapping ranges) and is now guarded by backend/tests/test_ml_dataset_builder.py::test_real_dataset_url_length_is_not_trivially_separable_by_class, which fails the build if the distributions stop overlapping.

Measured performance (real dataset, 1200 rows, post-fix)

From a train_model.py run against the committed real_phishing_urls.csv (33% stratified hold-out, plus 5-fold stratified cross-validation on the full set):

Train/inference feature mismatch (important caveat on the numbers above)

All the metrics above are computed on the 10 URL-derived features only — the 6 DOM columns (has_password_field, num_forms, external_form_action, num_iframes, external_links_ratio, has_hidden_inputs) are hardcoded to 0 for every row in real_phishing_urls.csv (ml/datasets/build_dataset.py, FEATURE_COLUMNS/ extract_url_features), because building the dataset never opens a browser. The trained RandomForestClassifier therefore never saw real variation in those 6 columns during training or cross-validation.

At inference time, backend/app/services/ml_service.py::_feature_values builds the same 16-feature vector but fills the DOM columns with real values collected by the extension’s content script (extension/src/content/dom-analyzer.ts). This means:

Temporal validation (train on older phishing, test on newer)

The cross-validation and hold-out numbers above only prove the model generalizes within one snapshot — they say nothing about whether it still works against phishing campaigns it has never seen. ml/evaluate_temporal_drift.py answers that directly using PhishTank’s submission_time field (present in the public dump, range observed: 2011-02-18 to today):

Result from a real run (cutoffs: train < 2024-06-23, test > 2026-06-09; 300 rows per side): 0.91 accuracy, precision 0.96/recall 0.85 for phishing, precision 0.87/recall 0.97 for legitimate — within noise of the random-split numbers above (0.907 CV / 0.92 hold-out). This is a meaningfully positive result: the model isn’t just memorizing snapshot-specific quirks, it generalizes to phishing campaigns from over two years after its training data, using only URL-structure features.

Limitation, stated plainly: Tranco has no time axis (it’s always “current rank now”), so only the phishing side is genuinely split by time; the legitimate side is split by a disjoint rank window as the closest available proxy for “different sample, not seen during training.” This is documented here instead of being described as a full temporal split on both classes.

Accuracy dropped from the pre-fix 0.954 to 0.907 once the trivial url_length shortcut was removed — a lower but more honest number that reflects genuine URL-structure signal rather than an artifact of how the dataset was assembled. These numbers reflect URL-only features (DOM features are 0 for every row, per the limitation above) and PhishTank/Tranco’s snapshot at build time — re-running build_dataset.py will pull a different sample and produce slightly different numbers. Treat this as a baseline, not a fixed benchmark.

Synthetic demo dataset (fallback)

ml/datasets/demo_phishing_urls.csv is a small synthetic set used only to validate the training pipeline when the real dataset has not been built yet. Do not treat metrics from this dataset as production evidence.

train_model.py auto-detects which dataset is present and logs which one it used.

Model Metrics

Run the full pipeline to generate metrics:

cd ml
python train_model.py   # prints CV accuracy, classification report, feature importances
python evaluate_model.py  # re-evaluates the saved artifact

Metrics to track: accuracy, precision, recall, F1-score, AUC-ROC, confusion matrix. Prioritize recall for phishing URLs while keeping the false-positive rate on legitimate pages (e.g. bank login pages) below 10 %.

Features

Initial features mirror the backend and extension:

Models

The training script stores both the primary model and baseline metadata in a joblib artifact. The runtime artifact committed for the backend lives at backend/app/models/phishlens_model.joblib.

Per-Prediction Explainability (SHAP)

train_model.py already prints global feature_importances_ (which features matter most across the whole training set), but that doesn’t say which features drove the score for this specific URL. backend/app/services/ml_service.py builds a shap.TreeExplainer for the loaded RandomForest once (cached alongside the model) and computes per-prediction SHAP values at inference time, surfacing the top 2 contributing features as MLResult.top_factors. When the ML adjustment is non-zero, scoring_service._ml_reasons appends a "Top ML factors: ..." reason listing them.

This is genuinely per-instance — two URLs that both increase risk can cite different top factors (e.g. one driven by domain character entropy, another by number of subdomains). If SHAP can’t explain a given model (e.g. the model type doesn’t support TreeExplainer, or shape mismatch), top_factors falls back to an empty tuple and the rest of the scoring pipeline is unaffected — explainability is best-effort, never a prediction blocker.

Heuristic-only confidence calibration (reliability diagram)

scoring_service._confidence() falls back to 0.55 + abs(score - 50) / 100 (capped at 0.9) whenever the ML model is unavailable — a linear proxy for “how far this result is from the middle of the score range,” explicitly never described as a calibrated probability. ml/evaluate_confidence_calibration.py checks exactly how far off it is from one, by reconstructing the URL-only heuristic score for every row of the committed dataset (same reconstruction limits as the heuristic backtest above: no typosquat/homograph, DOM features fixed at 0), computing _confidence() on it, and binning predicted confidence against the empirical accuracy of the resulting safe/not-safe call:

cd ml
python evaluate_confidence_calibration.py

Result from a run against the committed dataset (1200 rows):

Confidence bin Mean confidence Empirical accuracy n
[0.70, 0.75) 0.723 0.250 4
[0.75, 0.80) 0.790 0.000 1
[0.80, 0.85) 0.809 0.000 15
[0.85, 0.90) 0.873 0.000 23
[0.90, 0.95) 0.900 0.519 1157

Mean calibration error (weighted |confidence - accuracy| across bins): 0.397.

Reliability diagram: heuristic-only confidence sits well below the perfectly-calibrated diagonal across every bin

Finding, stated plainly: the heuristic-only confidence is not just “uncalibrated” in the harmless sense of being a rough proxy — on this dataset it is systematically overconfident. Every bin sits below the diagonal, and the bin holding 96% of the rows (confidence ≈ 0.90) is only ~52% accurate, barely better than a coin flip. This was not “fixed” by adding a counter-bias or a temperature-scaling correction in this round, because doing so against a dataset that itself can’t exercise typosquat/homograph/DOM/ TLS/domain-age/threat-intel signals would calibrate the formula to this benchmark’s blind spots, not to real-world accuracy. The honest takeaway is narrower: treat the heuristic-only confidence number as a tie-breaker between results, not as a probability, until it can be calibrated against a dataset that exercises the full signal set the live backend actually uses.

Metrics

Track accuracy, precision, recall, F1-score, and confusion matrix. In future datasets, prioritize recall for malicious URLs while controlling false positives for normal login and banking pages.

Heuristic engine backtest (rule-based scoring, no ML)

ml/evaluate_heuristics.py backtests _score_url’s point weights (backend/app/services/scoring_service.py) against the committed real_phishing_urls.csv, independent of the ML model.

Limitation: the committed CSV does not store raw domains (privacy decision, see above), so this backtest can only reconstruct the columns present in the file — url_length, num_dots, num_hyphens, uses_ip_domain, has_at_symbol, uses_https, num_subdomains, suspicious_keyword_count, uses_punycode, domain_entropy. It cannot backtest typosquatting, homograph, or mixed-script detection, which require the actual domain string and contribute the largest single point values in _score_url (+14 to +16). Treat these numbers as a partial backtest of the numeric-only weights, not a full evaluation of the URL heuristic engine.

Result from a run against the 1200-row dataset (python ml/evaluate_heuristics.py):

Score threshold Precision Recall F1
≥ 1 0.759 0.468 0.579
≥ 8 1.000 0.162 0.278
≥ 14 1.000 0.033 0.065
≥ 20 1.000 0.007 0.013
≥ 30 0.000 0.000 0.000

0% of rows in either class reach the 35-point URL_SCORE_CAP using only these numeric signals. This is expected, not a bug: it confirms that typosquatting/homograph detection (excluded from this backtest) carries most of the weight needed to reach the cap in practice — the numeric-only signals are a low-recall, high-precision secondary layer, not the primary URL-based detector. No weights were changed based on this result; it is documented here as a baseline for future backtesting once a privacy-safe way to include domain-string-dependent features is found (e.g. backtesting in-memory against freshly fetched URLs without persisting them to disk).

Future Data Sources

Use legally available, documented datasets only. Candidate sources include public phishing URL feeds, benign URL corpora, internally generated safe examples, and browser telemetry only if collected with explicit consent and strict privacy controls.

Limitations

Phishing behavior changes quickly. URL-only and DOM-only signals can be bypassed. The model needs regular retraining, drift checks, and careful review of false positives.