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.
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.
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.
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):
Hold-out (396 rows) accuracy: 0.92 — precision 0.87/recall 0.99/F1 0.93 for legitimate
URLs, precision 0.99/recall 0.85/F1 0.92 for phishing URLs. Confusion matrix (rows/cols = legitimate,
phishing), from a python ml/train_model.py run against the committed CSV:
| Predicted legit | Predicted phishing | |
|---|---|---|
| Actual legit | 197 | 1 |
| Actual phishing | 29 | 169 |
num_subdomains, num_dots, url_length, domain_entropy,
uses_httpsAll 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:
RandomForest happened to use one,
e.g. has_password_field) was learned from a constant value of 0 — it cannot encode
a meaningful relationship between that feature and the label, so in practice those
features are inert at inference too. The risk is not “the model overreacts to DOM
features” but “the model is structurally unable to use them,” which is a silent
capability gap rather than a correctness bug: MLResult.top_factors (SHAP) has never
been observed citing a DOM feature in practice, consistent with this.scoring_service._score_dom, with its own explicit point weights and tests) where
their effect is transparent and tested, and treat the ML adjustment as a URL-only
signal until a labeled dataset with real captured DOM snapshots exists.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):
_add_realistic_path enrichment as the production
dataset.RandomForestClassifier (same hyperparameters as
train_model.py) on the old split only, and evaluates on the new split — it never
touches the committed dataset or the production model artifact.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.
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.
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 %.
Initial features mirror the backend and extension:
@, HTTPS, subdomain count.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.
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.
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.

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