PhishingNet: privacy policy and project documentation
Project context for Claude Code. Read this first.
A “Grammarly for phishing”: read raw email text and return a structured, explainable breakdown of manipulation/phishing attributes — each with a score and highlighted evidence spans — plus an overall risk score/verdict. Priority is interpretability (every score traceable to evidence), not a black-box classifier.
data_prep.py merges 6 sources by name, cleans
(casing preserved), dedups, two-track stratified split → data/processed/
(parquet+csv) + manifest.json + DATA_CARD.md. Track A = 67,472 rows
(38,822 legit / 28,650 phish). explore.py prints the checkpoint report.scripts/validate_lexicons.py). Finalized in
phishing_analyzer/lexicons.py (LEXICON = validated; CORPUS_LIMITATION =
modern cues the old corpus can’t validate). Full schema in FEATURE_SCHEMA.md.
Key finding: blind mining is dominated by campaign/source artifacts →
confirms the co-equal-ML leakage guardrails.phishing_analyzer/attributes/ (word-boundary matching, saturating scores,
evidence spans); TF-IDF(word+char)+LogReg trained (classifier.py, val
F1=0.989, AUC=0.999, saved to models/classifier.joblib); risk combination in
risk.py+weights.yaml (classifier CO-EQUAL, weight 13). pipeline.py
orchestrates. 15/15 unit tests pass; scripts/smoke_demo.py verifies E2E.
Leakage confirmed live: classifier’s top legit features include enron,
re:, thanks, > — it rides corporate style. Step 4 MUST report per-source
metrics and we may dial classifier_weight down.build_enron_raw.py, casing_check.py, validate_lexicons.py,
smoke_demo.py.Two failures that the held-out test set (F1 0.98) could not see, both confirmed against the live tool and both now fixed and regression-tested:
bec=0.89 and was reported LEGITIMATE at risk 0.068, because every
real-world detector was report_only. This is the “spam walks past the TF-IDF
model because it reads like a normal email” case — and it does, because there is
no phishing vocabulary in it to find.The four fixes, each with its measurement in the file that carries it:
AMBIENT lexicon tier (lexicons.py, attributes/base.py) — terms triaged on
BOTH lift and legit frequency. Ambient terms score a fraction of a hit and are
capped below the “moderate” band when nothing specific also fired.classifier_gate (weights.yaml, risk.gate_classifier) — dead-zone + ramp
before the probability votes. The model is bimodal (97.8% of legit below 0.20,
93.3% of phish above 0.90), so the middle band is absence of opinion, not weak
opinion. low was swept against both corpora — 0.45 is the knee.corroboration (weights.yaml, risk._corroborate) — a phishing verdict
needs a confident classifier, a structural detector, or 3+ lexical attributes.
Wording alone is held at “suspicious” and the reason is reported.mechanism_reliability_ceiling + promoting bec/link_deception/attachment_risk/
html_attack out of report_only — the 0.6 lexical ceiling exists because one
WORD is ambiguous; a deceptive link or a gift-card ask is not.Cost, measured honestly: in-distribution F1 on a 3,000-email test sample goes 0.9930 -> 0.9883 (recall 0.9930 -> 0.9844). That is the price of the real-world result, and it is the right trade — but do not report the old F1 alongside the new behaviour.
Before changing any of this, run BOTH suites. pytest -q and
python -m evaluate_realworld. The second is the one that catches these.
tests/fixtures/realworld_cases.py is where new cases go; see PILOT.md for how
real corrections become fixtures.
Aimed at fluent, AI-written lures, where the wording gives nothing away. It ignores wording entirely: claimed brand vs provable sender.
brands.py is the ONE brand map (org -> names -> official domains). sender_domain,
link_deception, saas_abuse and trust all read it; add brands here, nowhere else.
Ambiguous names (Chase, Apple, Target…) only count in brand-shaped contexts.auth.py parses Authentication-Results with the domain each verdict is for
(DKIM header.d/header.i). from_authenticated = the From domain itself is
proven; from_failed = positive evidence it is forged. trust.py used to accept
spf=pass + dkim=pass for ANY domain, so a spoofed From: chase.com signed by the
attacker’s own domain got the trusted-brand discount. Fixed.attributes/brand_impersonation.py — claim (display name, sign-off, copyright /
company line, header logo, combosquat sending domain) without proof (official From
domain not failing auth, or a DKIM pass from the brand) = finding. Mechanism detector,
weight 1.5, structural for corroboration, overrides trust.ai_paypal_combosquat, spoofed_brand_dmarc_fail were both LEGITIMATE 0.0
before this. The four new legit fixtures are the false-positive guards — keep them.server/telemetry.py + /stats, /export, /feedback, /dashboard. Off unless
TELEMETRY_ENABLED=1 AND the extension has a team code. Records verdicts, signals
and sender domains; never subject, body, links or addresses. The boundary is
enforced by the privacy audit in scripts/seed_demo_telemetry.py — run it after any
change to the telemetry schema. Full rollout guidance in PILOT.md.
TIS/ and install
pandas scikit-learn numpy pyarrow beautifulsoup4 lxml pyyaml pydantic
tldextract pyspellchecker matplotlib pytest. Verify Python-3.14 wheels on
install; pin or fall back (e.g. csv-only if pyarrow fails).TIS/). Add data/, venv, __pycache__ to .gitignore.Location: C:\Users\15DGupta\Downloads\archive (4). We use 6 per-source files,
merged by column name (column order differs between files — never merge by index).
| Source | Rows | Labels | Casing | In merge |
|---|---|---|---|---|
Enron_raw.csv (NEW) |
15,800 | 0 only (legit) | preserved | ✅ |
CEAS_08.csv |
39,154 | 0 + 1 | preserved | ✅ |
Ling.csv |
2,859 | 0 + 1 | flattened (lowercased) | ✅ |
Nazario.csv |
1,565 | 1 only | preserved | ✅ |
Nigerian_Fraud.csv |
3,332 | 1 only | preserved | ✅ |
SpamAssasin.csv |
5,809 | 0 + 1 | preserved | ✅ |
Enron.csv |
29,767 | 0+1 | flattened | ❌ excluded |
phishing_email.csv |
82,486 | 0+1 | flattened | ❌ excluded |
Labels: 0 = legitimate, 1 = phishing. Text column is body (+ subject); some
files also have sender/receiver/date/urls. urls is a has-URL 0/1 flag, NOT a label.
The originally packaged Enron.csv was pre-lowercased and punctuation-stripped,
which flattened all casing/tone/urgency signal for the legit class and confounded
those features with source+label. We re-downloaded the raw Kaggle Enron dump
(emails.csv, wcukierski), parsed headers→body, sampled 15,800 (seed 42), labeled
0, and saved Enron_raw.csv with schema subject,body,label.
Verified fix (scripts/casing_check.py, mean uppercase-letter ratio):
old Enron 0.00% → Enron_raw 11.78% — now in the band of the other
casing-preserving sources (CEAS 8.84%, Nazario 9.18%, SpamAssassin 8.94%).
!!!,
urgency intensity). Enron_raw now belongs here (this changed after the re-source).source column for per-source evaluation.TIS/
requirements.txt, README.md, DATA_CARD.md
data/processed/ # versioned cleaned outputs (parquet + csv) + manifest.json
scripts/
build_enron_raw.py # DONE — builds Enron_raw.csv
casing_check.py # DONE — per-source casing/punctuation report
phishing_analyzer/
data_prep.py # merge (by name) + two-track split + save
explore.py # Step-1 checkpoint report
text_clean.py # HTML strip / header-remnant strip / whitespace (preserve casing+punct)
lexicons/ # seed lexicons, data-validated by frequency lift
attributes/ # one module per attribute (below), each ->
# AttributeResult{score, label, explanation, evidence_spans}
classifier.py # TF-IDF + LogisticRegression
risk.py + weights.yaml # explicit, adjustable score combination
schema.py # pydantic JSON output models
cli.py # demo: raw email -> JSON + highlighted terminal view
evaluate.py # metrics + attribute validation
tests/ # pytest per attribute
Each attribute is an independently testable module returning
AttributeResult{score∈[0,1], label, explanation, evidence_spans:[{text,start,end}]}.
evidence_spans are character offsets into the original text (drive the highlight UI).
| Attribute | Method | Track |
|---|---|---|
| Urgency / time pressure | Lexicon + regex (deadlines, “act now”) | A phrases, B caps intensity |
| Emotional manipulation (fear/threat/reward/curiosity) | Sub-lexicons per emotion | A |
| Authority impersonation | Lexicon (bank/IT/gov/exec) + sender cross-check | A + sender |
| Financial request | Lexicon (wire, gift card, invoice) + IBAN/BTC regex | A |
| Credential/data harvesting | Lexicon (verify/login/SSN/password) + URL/form cues | A + links |
| Generic vs personalized greeting | Regex (“Dear customer” vs a name) | A |
| Sender-domain mismatch / spoofing | Parse display-name vs domain; freemail-as-brand; lookalike (tldextract + edit distance) | sender-present sources only |
| Suspicious links | Shorteners, odd TLDs, IP-literal, raw-URL count; anchor/href only if HTML present | A |
| Grammar/spelling anomalies | pyspellchecker ratio + heuristics | B (casing matters) |
| Caps/punctuation tone | CAPS ratio, !/? bursts |
B only |
| Content classifier | TF-IDF + LogisticRegression | A |
Lexicons: seed by hand, then validate against real phishing samples (per-term phishing-vs-legit frequency lift on Track A); drop no-lift terms, surface missed high-lift terms. No blind keyword guessing.
Decision: the TF-IDF + LogReg classifier is a CO-EQUAL signal with the
rule-based attributes in the final risk score (not optional, not merely a
backstop). Explicit, adjustable weighted aggregation of attribute scores +
classifier probability, weights in weights.yaml. Two modes: (a) transparent
hand-set weights; (b) optional meta-LogisticRegression over attribute scores +
classifier prob (still interpretable via coefficients). Always output
contributing signals + the classifier’s top contributing n-grams — never a fully
opaque number.
Leakage guardrails (because ML now carries real weight): source correlates
hard with label here (Enron=100% legit), so the classifier could inflate accuracy
by learning “corporate Enron style = safe”. Mitigations: (1) report per-source
metrics in Step 4 and specifically inspect Enron false-negative behavior;
(2) prefer char+word n-grams and inspect top coefficients for source-identifying
tokens (names, “enron”, signature artifacts) — down-weight/blocklist if found;
(3) keep the classifier weight adjustable in weights.yaml so it can be dialed
back if evaluation shows it’s riding source rather than phishing signal.
{
"overall": {"risk_score": 0.0, "verdict": "phishing|suspicious|legitimate",
"top_signals": ["urgency", "financial_request"]},
"attributes": [
{"name": "urgency", "score": 0.0, "label": "...", "explanation": "...",
"evidence_spans": [{"text": "act now", "start": 42, "end": 49}]}
],
"meta": {"sender_analyzed": true, "notes": ["no HTML -> anchor/href check skipped"]}
}
CLI demo (cli.py): raw email from --file/stdin → JSON + highlighted terminal view.
phishing_email.csv or the old Enron.csv — flattened
text + leakage (the aggregate contains the per-source rows).csv.field_size_limit raised (long message fields).python scripts/build_enron_raw.py # -> archive (4)/Enron_raw.csv (seed 42, 15,800 rows)
python scripts/casing_check.py # per-source casing/punctuation report