Spaces:
Running on Zero
Running on Zero
ShadowSage AI: 4-tab Gradio app — DistilBERT + rule guardrail, HIBP footprint, Isolation Forest
Browse files- .gitignore +5 -0
- README.md +111 -7
- app.py +720 -0
- data/generate_login_data.py +190 -0
- data/login_activity.csv +131 -0
- data/phishing_dataset.csv +285 -0
- model/__init__.py +1 -0
- model/behavior_monitor.py +368 -0
- model/phishing_classifier.py +550 -0
- requirements.txt +7 -0
- utils/__init__.py +1 -0
- utils/footprint_scanner.py +192 -0
- utils/scoring.py +133 -0
.gitignore
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.venv/
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.pyc
|
| 4 |
+
.gradio/
|
| 5 |
+
model_artifacts/
|
README.md
CHANGED
|
@@ -1,13 +1,117 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: gradio
|
| 7 |
-
sdk_version: 6.26.0
|
| 8 |
-
python_version: '3.12'
|
| 9 |
app_file: app.py
|
| 10 |
pinned: false
|
|
|
|
| 11 |
---
|
| 12 |
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: ShadowSage AI
|
| 3 |
+
emoji: 👁️
|
| 4 |
+
colorFrom: purple
|
| 5 |
+
colorTo: indigo
|
| 6 |
sdk: gradio
|
| 7 |
+
sdk_version: "6.26.0"
|
|
|
|
| 8 |
app_file: app.py
|
| 9 |
pinned: false
|
| 10 |
+
license: mit
|
| 11 |
---
|
| 12 |
|
| 13 |
+
# ShadowSage AI — the all-seeing guardian of your digital world
|
| 14 |
+
|
| 15 |
+
An AI-powered cybersecurity oracle that defends **proactively**, not reactively.
|
| 16 |
+
ShadowSage detects hidden threats before they land, analyzes suspicious messages,
|
| 17 |
+
identifies phishing, uncovers your exposed digital footprint, and watches login
|
| 18 |
+
behavior for signs of account takeover — then distills everything into one
|
| 19 |
+
**Sage Score** with plain-English guidance.
|
| 20 |
+
|
| 21 |
+
Built for CPU-only live demos: small models, no API keys required, everything
|
| 22 |
+
degrades gracefully to a labeled fallback instead of crashing.
|
| 23 |
+
|
| 24 |
+
## The four scrying stones
|
| 25 |
+
|
| 26 |
+
| Tab | What it does | How |
|
| 27 |
+
| --- | --- | --- |
|
| 28 |
+
| **Sage Verdict** | One combined Sage Score + 2-3 personalized recommendations | Weighted average of the three module scores (formula in `utils/scoring.py`) |
|
| 29 |
+
| **Phishing Analyzer** | Risk score, verdict and highlighted evidence for any email / SMS / URL | DistilBERT fine-tuned on a bundled 284-example dataset **plus** transparent rule + URL heuristics (typosquats, raw-IP hosts, abuse TLDs, entropy, urgency patterns) |
|
| 30 |
+
| **Footprint Scanner** | Breach history + exposure score for an email or domain | HaveIBeenPwned API v3 when `HIBP_API_KEY` is set; otherwise a **clearly-labeled** deterministic demo dataset |
|
| 31 |
+
| **Behavior Monitor** | Login timeline with anomalies highlighted + behavioral risk score | Isolation Forest over `data/login_activity.csv` (90 days of synthetic history with 7 planted intrusions), features include circular hour encoding, rare country/device flags and impossible-travel velocity |
|
| 32 |
+
|
| 33 |
+
## Project structure
|
| 34 |
+
|
| 35 |
+
```
|
| 36 |
+
app.py # Gradio Blocks UI — the whole mystical dashboard
|
| 37 |
+
model/phishing_classifier.py # DistilBERT training + rule/URL fallback engine
|
| 38 |
+
model/behavior_monitor.py # Isolation Forest behavioral monitor
|
| 39 |
+
utils/footprint_scanner.py # HaveIBeenPwned v3 wrapper + demo fallback
|
| 40 |
+
utils/scoring.py # Sage Score formula + recommendation engine
|
| 41 |
+
data/phishing_dataset.csv # 284 labeled examples (1 = phishing)
|
| 42 |
+
data/login_activity.csv # 130 login events, 7 planted anomalies
|
| 43 |
+
data/generate_login_data.py # deterministic generator for the login history
|
| 44 |
+
```
|
| 45 |
+
|
| 46 |
+
## Run locally
|
| 47 |
+
|
| 48 |
+
```bash
|
| 49 |
+
python -m venv .venv
|
| 50 |
+
# Windows: .venv\Scripts\activate | macOS/Linux: source .venv/bin/activate
|
| 51 |
+
pip install -r requirements.txt
|
| 52 |
+
python app.py # -> http://127.0.0.1:7860
|
| 53 |
+
```
|
| 54 |
+
|
| 55 |
+
On the very first launch there is no trained model yet, so the app starts in
|
| 56 |
+
**rule-engine mode** (fully functional) and fine-tunes DistilBERT in a
|
| 57 |
+
background thread — the Phishing tab shows the training status
|
| 58 |
+
("the Sage is awakening"). Analysis is never blocked.
|
| 59 |
+
|
| 60 |
+
## Deploy to Hugging Face Spaces
|
| 61 |
+
|
| 62 |
+
1. Create a new Space → SDK: **Gradio**.
|
| 63 |
+
2. Push this repo as-is (`app.py` stays at the root; the YAML block at the top
|
| 64 |
+
of this README is the Spaces config).
|
| 65 |
+
3. Done. `requirements.txt` installs everything; on first boot the Space trains
|
| 66 |
+
the phishing model in the background and shows rule-based results meanwhile.
|
| 67 |
+
|
| 68 |
+
Optional: add a Space secret `HIBP_API_KEY` to switch the Footprint Scanner from
|
| 69 |
+
demo data to live HaveIBeenPwned lookups. Nothing else changes.
|
| 70 |
+
|
| 71 |
+
## Retraining the phishing model on your own dataset
|
| 72 |
+
|
| 73 |
+
Replace `data/phishing_dataset.csv` (two columns: `text,label`, label 1 =
|
| 74 |
+
phishing), then either:
|
| 75 |
+
|
| 76 |
+
```bash
|
| 77 |
+
# CLI: train and save to model_artifacts/phishing_distilbert/
|
| 78 |
+
python -m model.phishing_classifier # full 2-epoch run
|
| 79 |
+
python -m model.phishing_classifier --limit 60 # quick smoke-train
|
| 80 |
+
```
|
| 81 |
+
|
| 82 |
+
…or simply delete the `model_artifacts/` folder and start the app: it retrains
|
| 83 |
+
itself in the background. The entry points to look at first are
|
| 84 |
+
`train()` in `model/phishing_classifier.py` (hyperparameters are the constants
|
| 85 |
+
at the top of that file) and the retraining notes in its module docstring.
|
| 86 |
+
|
| 87 |
+
## How the scores work (viva crib sheet)
|
| 88 |
+
|
| 89 |
+
- **Phishing risk** — ML mode blends `0.65 × P(phishing from DistilBERT)` with
|
| 90 |
+
`0.35 × URL-heuristic score` when a link is present; rule mode blends
|
| 91 |
+
keyword and URL scores 60/40. In ML mode the rule score is a guardrail: if
|
| 92 |
+
the transparent heuristics are more alarmed than the model (a blind spot
|
| 93 |
+
from the tiny dataset), the higher score wins. Every added point is listed
|
| 94 |
+
in the UI.
|
| 95 |
+
- **Footprint exposure** — +16 per breach containing password data, +8 per
|
| 96 |
+
data-only breach, +6 extra for breaches newer than ~3 years, capped at 100.
|
| 97 |
+
- **Behavioral risk** — `0.5 × peak anomaly severity + 0.25 × anomaly density +
|
| 98 |
+
0.25 × recency`; the detector is fully unsupervised but the CSV carries a
|
| 99 |
+
`known_anomaly` column so the UI can show precision/recall against the
|
| 100 |
+
planted truth (currently 6 of 7 caught).
|
| 101 |
+
- **Sage Score** — `0.40 × phishing + 0.35 × footprint + 0.25 × behavior`,
|
| 102 |
+
renormalized over whichever modules have run.
|
| 103 |
+
|
| 104 |
+
## Regenerating the demo data
|
| 105 |
+
|
| 106 |
+
```bash
|
| 107 |
+
python data/generate_login_data.py # deterministic (seed 42)
|
| 108 |
+
```
|
| 109 |
+
|
| 110 |
+
## Deployment note
|
| 111 |
+
|
| 112 |
+
The primary target is Hugging Face Spaces (Gradio SDK), where this repo runs
|
| 113 |
+
with zero changes. A Vercel deployment would need a serverless-friendly
|
| 114 |
+
rewrite (Gradio on Node/Vercel is not supported; you would front the same
|
| 115 |
+
Python modules with a FastAPI + static UI) — out of scope for the hackathon,
|
| 116 |
+
but the `model/` and `utils/` packages are UI-agnostic and would carry over
|
| 117 |
+
unchanged.
|
app.py
ADDED
|
@@ -0,0 +1,720 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""ShadowSage AI — the all-seeing guardian of your digital world.
|
| 2 |
+
|
| 3 |
+
Hackathon-ready Gradio app (gr.Blocks, custom CSS — no stock theme).
|
| 4 |
+
|
| 5 |
+
Four tabs:
|
| 6 |
+
* Sage Verdict — combines the three module scores into one Sage Score
|
| 7 |
+
(weighted average, formula in utils/scoring.py) and
|
| 8 |
+
generates personalized recommendations.
|
| 9 |
+
* Phishing Analyzer — DistilBERT + rule/URL heuristics (model/phishing_classifier.py)
|
| 10 |
+
* Footprint Scanner — HaveIBeenPwned v3 with a clearly-labeled demo fallback
|
| 11 |
+
(utils/footprint_scanner.py)
|
| 12 |
+
* Behavior Monitor — Isolation Forest over data/login_activity.csv
|
| 13 |
+
(model/behavior_monitor.py)
|
| 14 |
+
|
| 15 |
+
Deploy: Hugging Face Spaces (Gradio SDK) — this file stays at the repo root
|
| 16 |
+
and runs as-is (`python app.py`). A Vercel port is possible later (see README).
|
| 17 |
+
|
| 18 |
+
Run locally:
|
| 19 |
+
python app.py -> http://127.0.0.1:7860
|
| 20 |
+
On first launch with no trained model, the app starts in rule-engine mode and
|
| 21 |
+
fine-tunes DistilBERT in a background thread ("the Sage is awakening") — the
|
| 22 |
+
demo never blocks and never needs an API key.
|
| 23 |
+
"""
|
| 24 |
+
|
| 25 |
+
import html
|
| 26 |
+
import os
|
| 27 |
+
import random
|
| 28 |
+
import time
|
| 29 |
+
|
| 30 |
+
import gradio as gr
|
| 31 |
+
|
| 32 |
+
from model.phishing_classifier import (
|
| 33 |
+
HAS_ML,
|
| 34 |
+
TRAINING_STATUS,
|
| 35 |
+
get_classifier,
|
| 36 |
+
start_background_training,
|
| 37 |
+
)
|
| 38 |
+
from model.behavior_monitor import get_monitor
|
| 39 |
+
from utils import footprint_scanner, scoring
|
| 40 |
+
|
| 41 |
+
# =====================================================================
|
| 42 |
+
# CSS — the "digital oracle" theme: deep violet void, cyan + magenta
|
| 43 |
+
# glow, Orbitron for headings, Share Tech Mono for body. Fonts load
|
| 44 |
+
# via <link> in the header HTML.
|
| 45 |
+
# =====================================================================
|
| 46 |
+
CSS = """
|
| 47 |
+
:root {
|
| 48 |
+
--ss-bg: #0a0612;
|
| 49 |
+
--ss-cyan: #4de8ff;
|
| 50 |
+
--ss-magenta: #ff4fd8;
|
| 51 |
+
--ss-violet: #8a5cf6;
|
| 52 |
+
--ss-red: #ff3355;
|
| 53 |
+
--ss-amber: #ffc861;
|
| 54 |
+
--ss-text: #e8e2f5;
|
| 55 |
+
--ss-dim: #9d93b8;
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
/* ---------- wipe the stock Gradio look ---------- */
|
| 59 |
+
body, gradio-app, .gradio-container, .main, .wrap, .contain, .gap, .panel, form, .form, .block {
|
| 60 |
+
background: transparent !important;
|
| 61 |
+
}
|
| 62 |
+
body { background: var(--ss-bg) !important; color: var(--ss-text); font-family: 'Share Tech Mono', monospace; }
|
| 63 |
+
.gradio-container { max-width: 1120px !important; font-family: 'Share Tech Mono', monospace !important; }
|
| 64 |
+
footer, .footer, .builtin { display: none !important; }
|
| 65 |
+
|
| 66 |
+
/* inputs */
|
| 67 |
+
.gradio-container textarea, .gradio-container input[type="text"] {
|
| 68 |
+
background: rgba(10, 6, 18, .78) !important;
|
| 69 |
+
border: 1px solid rgba(138, 92, 246, .35) !important;
|
| 70 |
+
color: var(--ss-text) !important;
|
| 71 |
+
font-family: 'Share Tech Mono', monospace !important;
|
| 72 |
+
box-shadow: inset 0 0 22px rgba(77, 232, 255, .05);
|
| 73 |
+
border-radius: 8px !important;
|
| 74 |
+
}
|
| 75 |
+
.gradio-container textarea:focus, .gradio-container input[type="text"]:focus {
|
| 76 |
+
border-color: var(--ss-cyan) !important;
|
| 77 |
+
box-shadow: 0 0 16px rgba(77, 232, 255, .25) !important;
|
| 78 |
+
}
|
| 79 |
+
.gradio-container label, .gradio-container .label-wrap, .gradio-container .label-wrap span {
|
| 80 |
+
color: #b7aede !important;
|
| 81 |
+
font-family: 'Share Tech Mono', monospace !important;
|
| 82 |
+
letter-spacing: .05em;
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
/* buttons */
|
| 86 |
+
.ss-btn, .gradio-container button.primary {
|
| 87 |
+
background: linear-gradient(135deg, rgba(42, 22, 80, .95), rgba(15, 42, 74, .95)) !important;
|
| 88 |
+
border: 1px solid rgba(77, 232, 255, .55) !important;
|
| 89 |
+
color: #9ff3ff !important;
|
| 90 |
+
font-family: 'Orbitron', sans-serif !important;
|
| 91 |
+
font-weight: 600 !important;
|
| 92 |
+
letter-spacing: .14em;
|
| 93 |
+
text-transform: uppercase;
|
| 94 |
+
font-size: .78rem !important;
|
| 95 |
+
border-radius: 8px !important;
|
| 96 |
+
box-shadow: 0 0 18px rgba(77, 232, 255, .18), inset 0 0 12px rgba(77, 232, 255, .06);
|
| 97 |
+
transition: all .25s ease;
|
| 98 |
+
}
|
| 99 |
+
.ss-btn:hover, .gradio-container button.primary:hover {
|
| 100 |
+
box-shadow: 0 0 32px rgba(77, 232, 255, .45) !important;
|
| 101 |
+
border-color: var(--ss-cyan) !important;
|
| 102 |
+
color: #ffffff !important;
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
/* tabs — nav wraps instead of collapsing tabs into a "More tabs" dropdown */
|
| 106 |
+
.gradio-container .tab-container, .gradio-container .tab-nav, .gradio-container .tabs {
|
| 107 |
+
background: transparent !important; border: none !important;
|
| 108 |
+
flex-wrap: wrap !important;
|
| 109 |
+
}
|
| 110 |
+
.gradio-container .tab-container button, .gradio-container .tab-nav button {
|
| 111 |
+
background: rgba(18, 10, 31, .6) !important;
|
| 112 |
+
color: #9d93b8 !important;
|
| 113 |
+
border: 1px solid rgba(138, 92, 246, .25) !important;
|
| 114 |
+
border-bottom: none !important;
|
| 115 |
+
border-radius: 8px 8px 0 0 !important;
|
| 116 |
+
font-family: 'Orbitron', sans-serif !important;
|
| 117 |
+
font-size: .7rem !important;
|
| 118 |
+
letter-spacing: .12em;
|
| 119 |
+
text-transform: uppercase;
|
| 120 |
+
padding: 9px 12px !important;
|
| 121 |
+
white-space: nowrap;
|
| 122 |
+
}
|
| 123 |
+
.gradio-container .tab-container button.selected, .gradio-container .tab-nav button.selected {
|
| 124 |
+
color: var(--ss-cyan) !important;
|
| 125 |
+
border-color: rgba(77, 232, 255, .65) !important;
|
| 126 |
+
background: rgba(20, 32, 58, .75) !important;
|
| 127 |
+
box-shadow: 0 0 18px rgba(77, 232, 255, .22);
|
| 128 |
+
}
|
| 129 |
+
/* narrow windows: compact the labels so all four tabs fit without the
|
| 130 |
+
"More tabs" overflow dropdown Gradio renders when the bar runs out of space */
|
| 131 |
+
@media (max-width: 760px) {
|
| 132 |
+
.gradio-container .tab-container button, .gradio-container .tab-nav button {
|
| 133 |
+
font-size: .55rem !important;
|
| 134 |
+
letter-spacing: .04em !important;
|
| 135 |
+
padding: 6px 7px !important;
|
| 136 |
+
}
|
| 137 |
+
}
|
| 138 |
+
.gradio-container .tabitem, .gradio-container .tab-item { border: 1px solid rgba(138, 92, 246, .18) !important; border-radius: 0 10px 10px 10px !important; background: rgba(14, 8, 26, .45) !important; padding: 18px !important; }
|
| 139 |
+
|
| 140 |
+
/* ---------- animated background (fixed, behind everything) ---------- */
|
| 141 |
+
.ss-bg { position: fixed; inset: 0; z-index: -1; pointer-events: none; overflow: hidden; }
|
| 142 |
+
.ss-bg::after {
|
| 143 |
+
content: ""; position: absolute; inset: 0;
|
| 144 |
+
background: radial-gradient(ellipse at center, transparent 52%, rgba(5, 2, 10, .8) 100%);
|
| 145 |
+
}
|
| 146 |
+
.ss-orb { position: absolute; border-radius: 50%; filter: blur(70px); }
|
| 147 |
+
.ss-o1 { width: 44vw; height: 44vw; left: -12vw; top: -10vw;
|
| 148 |
+
background: radial-gradient(circle, rgba(138, 92, 246, .30), transparent 70%);
|
| 149 |
+
animation: ss-drift1 46s ease-in-out infinite alternate; }
|
| 150 |
+
.ss-o2 { width: 40vw; height: 40vw; right: -10vw; top: 30vh;
|
| 151 |
+
background: radial-gradient(circle, rgba(77, 232, 255, .16), transparent 70%);
|
| 152 |
+
animation: ss-drift2 58s ease-in-out infinite alternate; }
|
| 153 |
+
.ss-o3 { width: 36vw; height: 36vw; left: 30vw; bottom: -14vw;
|
| 154 |
+
background: radial-gradient(circle, rgba(255, 79, 216, .13), transparent 70%);
|
| 155 |
+
animation: ss-drift1 70s ease-in-out infinite alternate-reverse; }
|
| 156 |
+
@keyframes ss-drift1 { to { transform: translate(6vw, 5vh) scale(1.12); } }
|
| 157 |
+
@keyframes ss-drift2 { to { transform: translate(-7vw, -6vh) scale(.94); } }
|
| 158 |
+
.ss-bg span {
|
| 159 |
+
position: absolute; bottom: -50px; opacity: 0;
|
| 160 |
+
animation: ss-rise linear infinite;
|
| 161 |
+
}
|
| 162 |
+
@keyframes ss-rise {
|
| 163 |
+
0% { transform: translateY(0); opacity: 0; }
|
| 164 |
+
10% { opacity: .38; }
|
| 165 |
+
90% { opacity: .18; }
|
| 166 |
+
100% { transform: translateY(-112vh); opacity: 0; }
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
/* ---------- header ---------- */
|
| 170 |
+
.ss-header { text-align: center; padding: 26px 0 4px; }
|
| 171 |
+
.ss-title {
|
| 172 |
+
font-family: 'Orbitron', sans-serif; font-weight: 900;
|
| 173 |
+
font-size: clamp(2.1rem, 6vw, 3.4rem); letter-spacing: .09em;
|
| 174 |
+
background: linear-gradient(90deg, #4de8ff, #8a5cf6 55%, #ff4fd8);
|
| 175 |
+
-webkit-background-clip: text; background-clip: text; color: transparent;
|
| 176 |
+
filter: drop-shadow(0 0 22px rgba(138, 92, 246, .4));
|
| 177 |
+
}
|
| 178 |
+
.ss-tagline {
|
| 179 |
+
margin-top: 6px; color: var(--ss-dim); letter-spacing: .34em;
|
| 180 |
+
text-transform: uppercase; font-size: .68rem;
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
/* ---------- the Sage's eye ----------
|
| 184 |
+
Reacts to the current verdict: .calm = slow cyan pulse (safe),
|
| 185 |
+
.warn = amber pulse, .bad = fast red pulse (threat). */
|
| 186 |
+
.ss-eye-wrap { display: flex; justify-content: center; margin: 4px 0 10px; }
|
| 187 |
+
.ss-eye {
|
| 188 |
+
width: 320px; max-width: 72vw;
|
| 189 |
+
--c: var(--ss-cyan); color: var(--c);
|
| 190 |
+
animation: ss-pulse 4s ease-in-out infinite;
|
| 191 |
+
}
|
| 192 |
+
.ss-eye.warn { --c: var(--ss-amber); animation-duration: 2s; }
|
| 193 |
+
.ss-eye.bad { --c: var(--ss-red); animation-duration: .8s; }
|
| 194 |
+
@keyframes ss-pulse {
|
| 195 |
+
0%, 100% { filter: drop-shadow(0 0 8px var(--c)); transform: scale(1); }
|
| 196 |
+
50% { filter: drop-shadow(0 0 34px var(--c)); transform: scale(1.045); }
|
| 197 |
+
}
|
| 198 |
+
.ss-eye .lid, .ss-eye .rays { stroke: currentColor; }
|
| 199 |
+
.ss-eye .lid { stroke-width: 2.5; fill: rgba(10, 6, 18, .55); }
|
| 200 |
+
.ss-eye .rays { stroke-width: 2; stroke-linecap: round; opacity: .8; }
|
| 201 |
+
.ss-eye .iris { stroke: currentColor; stroke-width: 2.5; fill: rgba(10, 6, 18, .85); }
|
| 202 |
+
.ss-eye .iris-ring { stroke: currentColor; stroke-width: 1.2; fill: none; stroke-dasharray: 4 6; opacity: .8; }
|
| 203 |
+
.ss-eye .pupil { fill: currentColor; }
|
| 204 |
+
.ss-eye .glint { fill: #ffffff; opacity: .95; }
|
| 205 |
+
.ss-eye .glint2 { fill: #ffffff; opacity: .5; }
|
| 206 |
+
.ss-eye .ring, .ss-eye .ring2 { stroke: currentColor; fill: none; stroke-width: 1; opacity: .5;
|
| 207 |
+
transform-box: fill-box; transform-origin: center; }
|
| 208 |
+
.ss-eye .ring { stroke-dasharray: 7 12; animation: ss-spin 26s linear infinite; }
|
| 209 |
+
.ss-eye .ring2 { stroke-dasharray: 2 9; animation: ss-spin 44s linear infinite reverse; opacity: .3; }
|
| 210 |
+
@keyframes ss-spin { to { transform: rotate(360deg); } }
|
| 211 |
+
|
| 212 |
+
/* ---------- copy blocks ---------- */
|
| 213 |
+
.ss-intro { color: var(--ss-dim); font-size: .88rem; line-height: 1.6; max-width: 760px; }
|
| 214 |
+
.ss-intro b { color: var(--ss-cyan); font-weight: 400; }
|
| 215 |
+
.ss-sub {
|
| 216 |
+
font-family: 'Orbitron', sans-serif; font-size: .68rem; letter-spacing: .24em;
|
| 217 |
+
text-transform: uppercase; color: var(--ss-violet); margin: 16px 0 6px;
|
| 218 |
+
}
|
| 219 |
+
.ss-note { font-size: .72rem; color: #7a7195; margin-top: 12px; line-height: 1.5; }
|
| 220 |
+
.ss-note .ss-st-training { color: var(--ss-amber); }
|
| 221 |
+
.ss-note .ss-st-ready { color: var(--ss-cyan); }
|
| 222 |
+
.ss-note .ss-st-failed { color: var(--ss-red); }
|
| 223 |
+
|
| 224 |
+
/* ---------- result card ---------- */
|
| 225 |
+
.ss-card {
|
| 226 |
+
background: linear-gradient(160deg, rgba(24, 14, 42, .85), rgba(12, 8, 24, .92));
|
| 227 |
+
border: 1px solid rgba(138, 92, 246, .3); border-radius: 12px;
|
| 228 |
+
padding: 18px 20px; margin-top: 14px;
|
| 229 |
+
box-shadow: 0 0 34px rgba(90, 50, 160, .16);
|
| 230 |
+
}
|
| 231 |
+
.ss-card-head { display: flex; align-items: center; gap: 18px; flex-wrap: wrap; }
|
| 232 |
+
.ss-score { font-family: 'Orbitron', sans-serif; font-weight: 900; font-size: 2.7rem; line-height: 1; min-width: 92px; text-align: center; }
|
| 233 |
+
.ss-sage-score { font-size: 4rem; }
|
| 234 |
+
.ss-score-safe, .ss-mod-safe .ss-mod-score { color: var(--ss-cyan); text-shadow: 0 0 24px rgba(77, 232, 255, .6); }
|
| 235 |
+
.ss-score-warn, .ss-mod-warn .ss-mod-score { color: var(--ss-amber); text-shadow: 0 0 24px rgba(255, 200, 97, .55); }
|
| 236 |
+
.ss-score-bad, .ss-mod-bad .ss-mod-score { color: var(--ss-red); text-shadow: 0 0 28px rgba(255, 51, 85, .7); }
|
| 237 |
+
.ss-score-idle, .ss-mod-idle .ss-mod-score { color: var(--ss-dim); }
|
| 238 |
+
.ss-verdict { font-family: 'Orbitron', sans-serif; font-weight: 700; font-size: 1.1rem; letter-spacing: .18em; text-transform: uppercase; display: block; }
|
| 239 |
+
.ss-verdict-safe { color: var(--ss-cyan); } .ss-verdict-warn { color: var(--ss-amber); }
|
| 240 |
+
.ss-verdict-bad { color: var(--ss-red); } .ss-verdict-idle { color: var(--ss-dim); }
|
| 241 |
+
.ss-detail { color: var(--ss-dim); font-size: .84rem; margin-top: 4px; line-height: 1.45; }
|
| 242 |
+
.ss-bar { height: 10px; border-radius: 6px; background: rgba(10, 6, 18, .92);
|
| 243 |
+
border: 1px solid rgba(138, 92, 246, .25); overflow: hidden; margin: 14px 0 4px; }
|
| 244 |
+
.ss-bar-fill { height: 100%; border-radius: 6px; transition: width .8s ease; }
|
| 245 |
+
.ss-fill-safe { background: linear-gradient(90deg, rgba(77, 232, 255, .25), var(--ss-cyan)); box-shadow: 0 0 12px rgba(77, 232, 255, .7); }
|
| 246 |
+
.ss-fill-warn { background: linear-gradient(90deg, rgba(255, 200, 97, .25), var(--ss-amber)); box-shadow: 0 0 12px rgba(255, 200, 97, .7); }
|
| 247 |
+
.ss-fill-bad { background: linear-gradient(90deg, rgba(255, 51, 85, .25), var(--ss-red)); box-shadow: 0 0 12px rgba(255, 51, 85, .8); }
|
| 248 |
+
.ss-stats { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 12px; }
|
| 249 |
+
.ss-stat { background: rgba(18, 10, 31, .65); border: 1px solid rgba(138, 92, 246, .25);
|
| 250 |
+
padding: 5px 12px; border-radius: 14px; font-size: .76rem; color: #cfc6e4; }
|
| 251 |
+
.ss-stat b { color: #8f82b5; font-weight: 400; font-family: 'Orbitron', sans-serif; font-size: .6rem; letter-spacing: .14em; text-transform: uppercase; margin-right: 7px; }
|
| 252 |
+
|
| 253 |
+
/* ---------- loading oracle ---------- */
|
| 254 |
+
.ss-loading {
|
| 255 |
+
display: flex; align-items: center; justify-content: center; gap: 12px;
|
| 256 |
+
padding: 42px 12px; margin-top: 14px;
|
| 257 |
+
color: #b7aede; font-style: italic; letter-spacing: .05em;
|
| 258 |
+
border: 1px dashed rgba(138, 92, 246, .35); border-radius: 12px;
|
| 259 |
+
background: rgba(18, 10, 31, .35);
|
| 260 |
+
}
|
| 261 |
+
.ss-mini-eye { color: var(--ss-cyan); font-size: 1.35rem; font-style: normal;
|
| 262 |
+
animation: ss-pulse 1.6s ease-in-out infinite; }
|
| 263 |
+
.ss-loading .dots i { animation: ss-blink 1.4s infinite; font-style: normal; }
|
| 264 |
+
.ss-loading .dots i:nth-child(2) { animation-delay: .2s; }
|
| 265 |
+
.ss-loading .dots i:nth-child(3) { animation-delay: .4s; }
|
| 266 |
+
@keyframes ss-blink { 0%, 100% { opacity: .15; } 50% { opacity: 1; } }
|
| 267 |
+
|
| 268 |
+
/* ---------- highlighted text (phishing) ---------- */
|
| 269 |
+
.ss-highlight {
|
| 270 |
+
background: rgba(10, 6, 18, .65); border: 1px solid rgba(138, 92, 246, .2);
|
| 271 |
+
border-radius: 8px; padding: 12px 14px; white-space: pre-wrap; word-break: break-word;
|
| 272 |
+
color: #cfc6e4; font-size: .87rem; line-height: 1.6; max-height: 280px; overflow-y: auto;
|
| 273 |
+
}
|
| 274 |
+
mark.ss-mark {
|
| 275 |
+
background: rgba(255, 79, 216, .16); color: #ffd7f5;
|
| 276 |
+
border-bottom: 1px solid rgba(255, 79, 216, .8);
|
| 277 |
+
padding: 0 3px; border-radius: 3px; text-shadow: 0 0 8px rgba(255, 79, 216, .5);
|
| 278 |
+
}
|
| 279 |
+
.ss-triggers { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 4px; }
|
| 280 |
+
.ss-triggers li {
|
| 281 |
+
padding: 6px 10px; background: rgba(10, 6, 18, .5);
|
| 282 |
+
border-left: 2px solid rgba(255, 79, 216, .6); border-radius: 0 6px 6px 0;
|
| 283 |
+
font-size: .84rem; color: #d8cfee;
|
| 284 |
+
}
|
| 285 |
+
.ss-triggers .pts { display: inline-block; min-width: 40px; font-family: 'Orbitron', sans-serif;
|
| 286 |
+
font-weight: 700; color: var(--ss-magenta); margin-right: 8px; }
|
| 287 |
+
.ss-triggers .pts.neg { color: var(--ss-cyan); }
|
| 288 |
+
|
| 289 |
+
/* ---------- behavior timeline ---------- */
|
| 290 |
+
.ss-timeline { display: flex; flex-direction: column; gap: 4px; max-height: 460px; overflow-y: auto; padding-right: 4px; }
|
| 291 |
+
.ss-timeline::-webkit-scrollbar { width: 8px; }
|
| 292 |
+
.ss-timeline::-webkit-scrollbar-thumb { background: rgba(138, 92, 246, .4); border-radius: 4px; }
|
| 293 |
+
.ss-ev {
|
| 294 |
+
display: grid; grid-template-columns: 128px 148px 158px 118px auto; gap: 3px 10px;
|
| 295 |
+
padding: 6px 10px; border: 1px solid rgba(138, 92, 246, .18); border-radius: 6px;
|
| 296 |
+
background: rgba(18, 10, 31, .6); font-size: .8rem; color: #cfc6e4; align-items: center;
|
| 297 |
+
}
|
| 298 |
+
.ss-ev .ss-ev-why { grid-column: 1 / -1; color: var(--ss-dim); font-size: .74rem; }
|
| 299 |
+
.ss-ev-head { color: var(--ss-violet); font-family: 'Orbitron', sans-serif; font-size: .62rem; letter-spacing: .16em; }
|
| 300 |
+
.ss-ev.ss-anom { border-color: rgba(255, 51, 85, .55); background: rgba(255, 51, 85, .07); box-shadow: inset 0 0 14px rgba(255, 51, 85, .1); }
|
| 301 |
+
.ss-ev.ss-anom .ss-ev-why { color: #f0a9b8; }
|
| 302 |
+
.ss-badge-bad { background: rgba(255, 51, 85, .15); color: var(--ss-red); border: 1px solid var(--ss-red);
|
| 303 |
+
padding: 1px 9px; border-radius: 10px; font-size: .62rem; letter-spacing: .12em; font-family: 'Orbitron', sans-serif; white-space: nowrap; }
|
| 304 |
+
|
| 305 |
+
/* ---------- footprint breaches ---------- */
|
| 306 |
+
.ss-breach-list { display: flex; flex-direction: column; gap: 6px; }
|
| 307 |
+
.ss-breach { display: flex; gap: 16px; align-items: baseline; flex-wrap: wrap;
|
| 308 |
+
padding: 8px 12px; border: 1px solid rgba(77, 232, 255, .2); border-radius: 6px;
|
| 309 |
+
background: rgba(18, 10, 31, .6); }
|
| 310 |
+
.ss-breach-crit { border-color: rgba(255, 79, 216, .5); box-shadow: inset 0 0 12px rgba(255, 79, 216, .07); }
|
| 311 |
+
.ss-breach-name { font-family: 'Orbitron', sans-serif; font-size: .8rem; color: var(--ss-cyan); min-width: 150px; }
|
| 312 |
+
.ss-breach-date { color: var(--ss-amber); font-size: .78rem; }
|
| 313 |
+
.ss-breach-data { color: var(--ss-dim); flex: 1; font-size: .78rem; min-width: 220px; }
|
| 314 |
+
.ss-demo-banner, .ss-live-banner { padding: 6px 12px; border-radius: 6px; font-size: .72rem;
|
| 315 |
+
text-align: center; margin: 8px 0; letter-spacing: .06em; }
|
| 316 |
+
.ss-demo-banner { background: rgba(255, 200, 97, .08); border: 1px dashed rgba(255, 200, 97, .55); color: var(--ss-amber); }
|
| 317 |
+
.ss-live-banner { background: rgba(77, 232, 255, .07); border: 1px solid rgba(77, 232, 255, .4); color: var(--ss-cyan); }
|
| 318 |
+
|
| 319 |
+
/* ---------- sage verdict tab ---------- */
|
| 320 |
+
.ss-sage-hero { display: flex; align-items: center; justify-content: center; gap: 22px; flex-wrap: wrap; padding: 8px 0; }
|
| 321 |
+
.ss-sage-verdict { font-family: 'Orbitron', sans-serif; font-weight: 700; font-size: 1.25rem; letter-spacing: .14em; text-transform: uppercase; }
|
| 322 |
+
.ss-modules { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 10px; margin-top: 18px; }
|
| 323 |
+
.ss-mod { background: rgba(10, 6, 18, .6); border: 1px solid rgba(138, 92, 246, .25); border-radius: 10px; padding: 12px; text-align: center; }
|
| 324 |
+
.ss-mod-label { font-family: 'Orbitron', sans-serif; font-size: .62rem; letter-spacing: .22em; text-transform: uppercase; color: var(--ss-violet); }
|
| 325 |
+
.ss-mod-score { font-family: 'Orbitron', sans-serif; font-weight: 900; font-size: 2rem; margin: 6px 0 2px; }
|
| 326 |
+
.ss-mod-verdict { font-size: .68rem; letter-spacing: .16em; text-transform: uppercase; color: #b7aede; }
|
| 327 |
+
.ss-mod-detail { font-size: .68rem; color: var(--ss-dim); margin-top: 6px; line-height: 1.4; min-height: 2.2em; }
|
| 328 |
+
.ss-mod-idle { opacity: .5; }
|
| 329 |
+
.ss-mod-safe .ss-mod-verdict { color: var(--ss-cyan); }
|
| 330 |
+
.ss-mod-warn .ss-mod-verdict { color: var(--ss-amber); }
|
| 331 |
+
.ss-mod-bad .ss-mod-verdict { color: var(--ss-red); }
|
| 332 |
+
.ss-recs ol { margin: 8px 0 0; padding-left: 20px; display: flex; flex-direction: column; gap: 8px; }
|
| 333 |
+
.ss-recs li { color: #d8cfee; font-size: .89rem; line-height: 1.55; padding: 9px 13px;
|
| 334 |
+
background: rgba(18, 10, 31, .5); border-left: 2px solid var(--ss-cyan); border-radius: 0 8px 8px 0; }
|
| 335 |
+
.ss-error { border: 1px dashed rgba(255, 200, 97, .6); color: var(--ss-amber);
|
| 336 |
+
background: rgba(255, 200, 97, .06); padding: 12px 16px; border-radius: 8px; margin-top: 14px; text-align: center; }
|
| 337 |
+
"""
|
| 338 |
+
|
| 339 |
+
# =====================================================================
|
| 340 |
+
# HTML fragments
|
| 341 |
+
# =====================================================================
|
| 342 |
+
FONTS_LINK = (
|
| 343 |
+
'<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@500;700;900'
|
| 344 |
+
'&family=Share+Tech+Mono&display=swap" rel="stylesheet">'
|
| 345 |
+
)
|
| 346 |
+
|
| 347 |
+
_RUNES = "ᚠᚢᚦᚨᚱᚲᛃᛇᛜᛟᛝᛉᛘᛉ◈✧⌖"
|
| 348 |
+
|
| 349 |
+
|
| 350 |
+
def _background_html() -> str:
|
| 351 |
+
"""Fixed decorative layer: two drifting glow orbs + rising runes.
|
| 352 |
+
Seeded so the layout is identical on every load (demo stability)."""
|
| 353 |
+
rng = random.Random(7)
|
| 354 |
+
glyphs = []
|
| 355 |
+
for ch in _RUNES:
|
| 356 |
+
glyphs.append(
|
| 357 |
+
f'<span style="left:{rng.uniform(2, 95):.1f}vw;'
|
| 358 |
+
f'animation-duration:{rng.uniform(16, 40):.1f}s;'
|
| 359 |
+
f'animation-delay:{rng.uniform(-35, 0):.1f}s;'
|
| 360 |
+
f'font-size:{rng.uniform(12, 26):.0f}px;'
|
| 361 |
+
f'color:{rng.choice(["rgba(77,232,255,", "rgba(255,79,216,", "rgba(138,92,246,"])}0.5)">{ch}</span>'
|
| 362 |
+
)
|
| 363 |
+
return (
|
| 364 |
+
'<div class="ss-bg" aria-hidden="true">'
|
| 365 |
+
'<div class="ss-orb ss-o1"></div><div class="ss-orb ss-o2"></div>'
|
| 366 |
+
'<div class="ss-orb ss-o3"></div>' + "".join(glyphs) + "</div>"
|
| 367 |
+
)
|
| 368 |
+
|
| 369 |
+
|
| 370 |
+
def sage_eye(state_cls: str = "calm") -> str:
|
| 371 |
+
"""The Sage's eye. state_cls: calm | warn | bad (see CSS .ss-eye)."""
|
| 372 |
+
return f"""
|
| 373 |
+
<div class="ss-eye-wrap">
|
| 374 |
+
<div class="ss-eye {state_cls}">
|
| 375 |
+
<svg viewBox="0 0 220 140" width="100%" xmlns="http://www.w3.org/2000/svg">
|
| 376 |
+
<g class="rays">
|
| 377 |
+
<line x1="110" y1="20" x2="110" y2="6"/>
|
| 378 |
+
<line x1="74" y1="30" x2="63" y2="18"/>
|
| 379 |
+
<line x1="146" y1="30" x2="157" y2="18"/>
|
| 380 |
+
<line x1="28" y1="70" x2="10" y2="70"/>
|
| 381 |
+
<line x1="192" y1="70" x2="210" y2="70"/>
|
| 382 |
+
<line x1="110" y1="120" x2="110" y2="134"/>
|
| 383 |
+
<line x1="74" y1="110" x2="63" y2="122"/>
|
| 384 |
+
<line x1="146" y1="110" x2="157" y2="122"/>
|
| 385 |
+
</g>
|
| 386 |
+
<circle class="ring" cx="110" cy="70" r="56"/>
|
| 387 |
+
<circle class="ring2" cx="110" cy="70" r="63"/>
|
| 388 |
+
<path class="lid" d="M16 70 Q110 -10 204 70 Q110 150 16 70 Z"/>
|
| 389 |
+
<circle class="iris" cx="110" cy="70" r="30"/>
|
| 390 |
+
<circle class="iris-ring" cx="110" cy="70" r="22"/>
|
| 391 |
+
<circle class="pupil" cx="110" cy="70" r="13"/>
|
| 392 |
+
<circle class="glint" cx="118" cy="61" r="4.5"/>
|
| 393 |
+
<circle class="glint2" cx="103" cy="78" r="2"/>
|
| 394 |
+
</svg>
|
| 395 |
+
</div>
|
| 396 |
+
</div>"""
|
| 397 |
+
|
| 398 |
+
|
| 399 |
+
def loading_html() -> str:
|
| 400 |
+
return (
|
| 401 |
+
'<div class="ss-loading"><span class="ss-mini-eye">◉</span>'
|
| 402 |
+
"The Sage is watching your digital shadow"
|
| 403 |
+
'<span class="dots"><i>.</i><i>.</i><i>.</i></span></div>'
|
| 404 |
+
)
|
| 405 |
+
|
| 406 |
+
|
| 407 |
+
def error_html(message: str) -> str:
|
| 408 |
+
return f'<div class="ss-error">{html.escape(message)}</div>'
|
| 409 |
+
|
| 410 |
+
|
| 411 |
+
def result_card(res: dict, inner: str = "") -> str:
|
| 412 |
+
"""Shared result frame: score + verdict + risk bar, module-specific inner HTML."""
|
| 413 |
+
cls = res["verdict_cls"]
|
| 414 |
+
return (
|
| 415 |
+
'<div class="ss-card">'
|
| 416 |
+
'<div class="ss-card-head">'
|
| 417 |
+
f'<span class="ss-score ss-score-{cls}">{res["risk"]}</span>'
|
| 418 |
+
'<div>'
|
| 419 |
+
f'<span class="ss-verdict ss-verdict-{cls}">{html.escape(res["verdict"])}</span>'
|
| 420 |
+
f'<div class="ss-detail">{html.escape(res["detail"])}</div>'
|
| 421 |
+
"</div></div>"
|
| 422 |
+
f'<div class="ss-bar"><div class="ss-bar-fill ss-fill-{cls}" style="width:{res["risk"]}%"></div></div>'
|
| 423 |
+
f"{inner}</div>"
|
| 424 |
+
)
|
| 425 |
+
|
| 426 |
+
|
| 427 |
+
def _stats_row(pairs: list[tuple[str, str]]) -> str:
|
| 428 |
+
cells = "".join(f'<span class="ss-stat"><b>{k}</b>{html.escape(v)}</span>' for k, v in pairs)
|
| 429 |
+
return f'<div class="ss-stats">{cells}</div>'
|
| 430 |
+
|
| 431 |
+
|
| 432 |
+
# ---------- per-module result renderers ----------
|
| 433 |
+
|
| 434 |
+
def phishing_html(res: dict) -> str:
|
| 435 |
+
if not res.get("ok"):
|
| 436 |
+
return error_html(res.get("error", "Something blocked the Sage's vision."))
|
| 437 |
+
|
| 438 |
+
stats = [("engine", "DistilBERT + heuristics" if res["mode"] == "ml" else "rule engine")]
|
| 439 |
+
if res["ml_prob"] is not None:
|
| 440 |
+
stats.append(("P(phishing)", f"{res['ml_prob']:.2f}"))
|
| 441 |
+
stats.append(("keyword score", str(res["keyword_score"])))
|
| 442 |
+
if res["url_score"] is not None:
|
| 443 |
+
stats.append(("URL score", str(res["url_score"])))
|
| 444 |
+
stats.append(("links found", str(len(res["urls"]))))
|
| 445 |
+
|
| 446 |
+
inner = _stats_row(stats)
|
| 447 |
+
|
| 448 |
+
inner += '<div class="ss-sub">Highlighted signals</div>'
|
| 449 |
+
inner += f'<div class="ss-highlight">{res["highlight_html"]}</div>'
|
| 450 |
+
|
| 451 |
+
if res["triggers"]:
|
| 452 |
+
items = "".join(
|
| 453 |
+
f'<li><span class="pts{" neg" if t["points"] < 0 else ""}">{t["points"]:+d}</span>'
|
| 454 |
+
f'{html.escape(t["reason"])} — “{html.escape(t["snippet"])}”</li>'
|
| 455 |
+
for t in res["triggers"]
|
| 456 |
+
)
|
| 457 |
+
inner += '<div class="ss-sub">Why the Sage judged this</div>'
|
| 458 |
+
inner += f'<ul class="ss-triggers">{items}</ul>'
|
| 459 |
+
|
| 460 |
+
return result_card(res, inner)
|
| 461 |
+
|
| 462 |
+
|
| 463 |
+
def footprint_html(res: dict) -> str:
|
| 464 |
+
if not res.get("ok"):
|
| 465 |
+
return error_html(res.get("error", "The Sage could not read that shadow."))
|
| 466 |
+
stats = [("mode", "LIVE HIBP" if res["mode"] == "live" else "demo dataset"),
|
| 467 |
+
("breaches", str(len(res["breaches"])))]
|
| 468 |
+
inner = _stats_row(stats) + res["html"]
|
| 469 |
+
return result_card(res, inner)
|
| 470 |
+
|
| 471 |
+
|
| 472 |
+
def behavior_html(res: dict) -> str:
|
| 473 |
+
if not res.get("ok"):
|
| 474 |
+
return error_html(res.get("error", "The logs are unreadable."))
|
| 475 |
+
gt = res["ground_truth"]
|
| 476 |
+
stats = [
|
| 477 |
+
("logins", str(res["n_events"])),
|
| 478 |
+
("anomalies", str(res["n_anomalies"])),
|
| 479 |
+
("peak severity", str(res["peak_severity"])),
|
| 480 |
+
("vs planted truth", f"precision {gt['precision']:.2f} · recall {gt['recall']:.2f}"),
|
| 481 |
+
]
|
| 482 |
+
inner = _stats_row(stats)
|
| 483 |
+
inner += '<div class="ss-sub">Login timeline — flagged events glow red</div>'
|
| 484 |
+
inner += res["html"]
|
| 485 |
+
return result_card(res, inner)
|
| 486 |
+
|
| 487 |
+
|
| 488 |
+
MODULE_LABELS = {"phishing": "Phishing", "footprint": "Footprint", "behavior": "Behavior"}
|
| 489 |
+
|
| 490 |
+
|
| 491 |
+
def sage_html(state: dict) -> str:
|
| 492 |
+
"""The Sage Verdict tab body. Recomputed after every module run so
|
| 493 |
+
returning to this tab always shows the current truth."""
|
| 494 |
+
sv = scoring.sage_verdict(state.get("phishing"), state.get("footprint"), state.get("behavior"))
|
| 495 |
+
if not sv["ok"]:
|
| 496 |
+
return (
|
| 497 |
+
'<div class="ss-card"><div class="ss-loading" style="border:none">'
|
| 498 |
+
'<span class="ss-mini-eye">◉</span>'
|
| 499 |
+
"The Sage awaits — run the three scanners below, then consult the verdict"
|
| 500 |
+
"</div></div>"
|
| 501 |
+
)
|
| 502 |
+
|
| 503 |
+
cards = []
|
| 504 |
+
for key in ("phishing", "footprint", "behavior"):
|
| 505 |
+
m = sv["modules"].get(key)
|
| 506 |
+
if m:
|
| 507 |
+
cards.append(
|
| 508 |
+
f'<div class="ss-mod ss-mod-{m["verdict_cls"]}">'
|
| 509 |
+
f'<div class="ss-mod-label">{m["label"]}</div>'
|
| 510 |
+
f'<div class="ss-mod-score">{m["score"]}</div>'
|
| 511 |
+
f'<div class="ss-mod-verdict">{html.escape(m["verdict"])}</div>'
|
| 512 |
+
f'<div class="ss-mod-detail">{html.escape(m["detail"])}</div></div>'
|
| 513 |
+
)
|
| 514 |
+
else:
|
| 515 |
+
cards.append(
|
| 516 |
+
f'<div class="ss-mod ss-mod-idle"><div class="ss-mod-label">{MODULE_LABELS[key]}</div>'
|
| 517 |
+
f'<div class="ss-mod-score">—</div><div class="ss-mod-verdict">not run</div>'
|
| 518 |
+
f'<div class="ss-mod-detail">visit the {MODULE_LABELS[key]} tab</div></div>'
|
| 519 |
+
)
|
| 520 |
+
|
| 521 |
+
recs = "".join(f"<li>{html.escape(r)}</li>" for r in sv["recommendations"])
|
| 522 |
+
cls = sv["verdict_cls"]
|
| 523 |
+
inner = (
|
| 524 |
+
'<div class="ss-sage-hero">'
|
| 525 |
+
f'<span class="ss-score ss-sage-score ss-score-{cls}">{sv["sage_score"]}</span>'
|
| 526 |
+
'<div>'
|
| 527 |
+
f'<span class="ss-verdict ss-sage-verdict ss-verdict-{cls}">{html.escape(sv["verdict"])}</span>'
|
| 528 |
+
f'<div class="ss-detail">{html.escape(sv["detail"])}</div>'
|
| 529 |
+
"</div></div>"
|
| 530 |
+
f'<div class="ss-modules">{"".join(cards)}</div>'
|
| 531 |
+
'<div class="ss-sub">The Sage’s guidance</div>'
|
| 532 |
+
f'<div class="ss-recs"><ol>{recs}</ol></div>'
|
| 533 |
+
)
|
| 534 |
+
# reuse the card frame with the sage "risk" = sage_score
|
| 535 |
+
return result_card(
|
| 536 |
+
{"risk": sv["sage_score"], "verdict": sv["verdict"], "verdict_cls": cls, "detail": sv["detail"]},
|
| 537 |
+
inner,
|
| 538 |
+
)
|
| 539 |
+
|
| 540 |
+
|
| 541 |
+
def _eye_cls_for(state: dict) -> str:
|
| 542 |
+
"""Eye follows the OVERALL current verdict (calm cyan when safe,
|
| 543 |
+
faster pulses as risk rises)."""
|
| 544 |
+
sv = scoring.sage_verdict(state.get("phishing"), state.get("footprint"), state.get("behavior"))
|
| 545 |
+
return {"safe": "calm", "warn": "warn", "bad": "bad", "idle": "calm"}.get(sv["verdict_cls"], "calm")
|
| 546 |
+
|
| 547 |
+
|
| 548 |
+
def training_status_html() -> str:
|
| 549 |
+
s, d = TRAINING_STATUS["state"], TRAINING_STATUS["detail"]
|
| 550 |
+
label = {"idle": "slumbering (rule engine active)", "training": "awakening — fine-tuning DistilBERT",
|
| 551 |
+
"ready": "awake — ML engine online", "failed": "sealed"} .get(s, s)
|
| 552 |
+
if not d and s == "idle":
|
| 553 |
+
d = "ML libraries not installed" if not HAS_ML else "no trained model yet"
|
| 554 |
+
return f'<div class="ss-note">ML engine: <span class="ss-st-{s}">{html.escape(label)}</span> — {html.escape(d)}</div>'
|
| 555 |
+
|
| 556 |
+
|
| 557 |
+
# =====================================================================
|
| 558 |
+
# Event handlers. Each one: (1) brief pause so the "The Sage is
|
| 559 |
+
# watching..." animation is visible, (2) run the module, (3) update
|
| 560 |
+
# the shared state, the Sage's eye AND the Sage Verdict tab.
|
| 561 |
+
# =====================================================================
|
| 562 |
+
def run_phishing(text: str, state: dict):
|
| 563 |
+
time.sleep(1.2) # demo pacing — let the oracle animation breathe
|
| 564 |
+
res = get_classifier().analyze(text)
|
| 565 |
+
if not res.get("ok"):
|
| 566 |
+
return state, sage_eye(_eye_cls_for(state)), error_html(res["error"]), sage_html(state)
|
| 567 |
+
state = {**state, "phishing": res}
|
| 568 |
+
return state, sage_eye(_eye_cls_for(state)), phishing_html(res), sage_html(state)
|
| 569 |
+
|
| 570 |
+
|
| 571 |
+
def run_footprint(target: str, state: dict):
|
| 572 |
+
time.sleep(1.2)
|
| 573 |
+
res = footprint_scanner.scan(target)
|
| 574 |
+
if not res.get("ok"):
|
| 575 |
+
return state, sage_eye(_eye_cls_for(state)), error_html(res["error"]), sage_html(state)
|
| 576 |
+
state = {**state, "footprint": res}
|
| 577 |
+
return state, sage_eye(_eye_cls_for(state)), footprint_html(res), sage_html(state)
|
| 578 |
+
|
| 579 |
+
|
| 580 |
+
def run_behavior(state: dict):
|
| 581 |
+
time.sleep(1.0)
|
| 582 |
+
res = get_monitor().analyze()
|
| 583 |
+
if not res.get("ok"):
|
| 584 |
+
return state, sage_eye(_eye_cls_for(state)), error_html(res["error"]), sage_html(state)
|
| 585 |
+
state = {**state, "behavior": res}
|
| 586 |
+
return state, sage_eye(_eye_cls_for(state)), behavior_html(res), sage_html(state)
|
| 587 |
+
|
| 588 |
+
|
| 589 |
+
def consult_sage(state: dict):
|
| 590 |
+
time.sleep(0.9)
|
| 591 |
+
return sage_html(state), sage_eye(_eye_cls_for(state))
|
| 592 |
+
|
| 593 |
+
|
| 594 |
+
# =====================================================================
|
| 595 |
+
# The Blocks app
|
| 596 |
+
# =====================================================================
|
| 597 |
+
# Decide the ML-engine story BEFORE the UI is built, so the status panel
|
| 598 |
+
# paints correctly on first render:
|
| 599 |
+
# - trained artifacts on disk -> "ready" right away
|
| 600 |
+
# - torch present, no artifacts -> fine-tune in a background thread; the
|
| 601 |
+
# rule engine answers until it finishes (the status panel polls it)
|
| 602 |
+
# - SHADOWSAGE_NO_TRAIN=1 -> stay on rules (tests/CI)
|
| 603 |
+
_clf = get_classifier()
|
| 604 |
+
if _clf.mode == "ml":
|
| 605 |
+
TRAINING_STATUS["state"] = "ready"
|
| 606 |
+
TRAINING_STATUS["detail"] = "DistilBERT loaded from model_artifacts/"
|
| 607 |
+
elif HAS_ML and os.environ.get("SHADOWSAGE_NO_TRAIN") != "1":
|
| 608 |
+
start_background_training()
|
| 609 |
+
|
| 610 |
+
with gr.Blocks(title="ShadowSage AI") as demo:
|
| 611 |
+
state = gr.State({}) # {"phishing": ..., "footprint": ..., "behavior": ...}
|
| 612 |
+
|
| 613 |
+
gr.HTML(FONTS_LINK + _background_html())
|
| 614 |
+
|
| 615 |
+
gr.HTML(
|
| 616 |
+
'<div class="ss-header">'
|
| 617 |
+
'<div class="ss-title">SHADOWSAGE AI</div>'
|
| 618 |
+
'<div class="ss-tagline">The all-seeing guardian of your digital world</div>'
|
| 619 |
+
"</div>"
|
| 620 |
+
)
|
| 621 |
+
eye = gr.HTML(sage_eye("calm"))
|
| 622 |
+
|
| 623 |
+
with gr.Tabs():
|
| 624 |
+
# ------------------------------------------------ Sage Verdict
|
| 625 |
+
with gr.Tab("Sage Verdict"):
|
| 626 |
+
gr.HTML(
|
| 627 |
+
'<div class="ss-intro">Three scrying stones feed the Sage — '
|
| 628 |
+
"<b>the message you fear</b>, <b>the shadow you leave behind</b>, "
|
| 629 |
+
"<b>the rhythm of your logins</b>. Run the analyzers in the tabs below, "
|
| 630 |
+
"then return here for the verdict.</div>"
|
| 631 |
+
)
|
| 632 |
+
sage_out = gr.HTML(sage_html({}))
|
| 633 |
+
consult_btn = gr.Button("Consult the Sage", elem_classes="ss-btn")
|
| 634 |
+
|
| 635 |
+
# ------------------------------------------------ Phishing Analyzer
|
| 636 |
+
with gr.Tab("Phishing Analyzer"):
|
| 637 |
+
gr.HTML(
|
| 638 |
+
'<div class="ss-intro">Paste an email, SMS or a bare URL. The Sage reads the '
|
| 639 |
+
"<b>wording with a fine-tuned DistilBERT</b> and dissects every link for the "
|
| 640 |
+
"classic lures — typosquats, raw-IP hosts, abuse-prone TLDs, urgency spells.</div>"
|
| 641 |
+
)
|
| 642 |
+
phish_in = gr.Textbox(
|
| 643 |
+
label="Suspicious message or URL",
|
| 644 |
+
placeholder="Dear customer, your account will be suspended within 24 hours…",
|
| 645 |
+
lines=7,
|
| 646 |
+
)
|
| 647 |
+
phish_btn = gr.Button("Divine the Truth", elem_classes="ss-btn")
|
| 648 |
+
phish_out = gr.HTML()
|
| 649 |
+
status_out = gr.HTML(training_status_html())
|
| 650 |
+
gr.Examples(
|
| 651 |
+
examples=[
|
| 652 |
+
["Dear customer, your account will be suspended within 24 hours. "
|
| 653 |
+
"Verify your identity now at http://secure.chase.com.session-8412.xyz/verify "
|
| 654 |
+
"to avoid losing access."],
|
| 655 |
+
["URGENT: We detected unusual sign-in activity. Confirm your password "
|
| 656 |
+
"immediately: http://xn--paypa-2ve.com/login/verify/account/update"],
|
| 657 |
+
["Hi team, attached is the Q3 planning doc for tomorrow's sync. "
|
| 658 |
+
"See you at 10 — Sarah"],
|
| 659 |
+
["Thanks for subscribing to the Rust Weekly digest. You can unsubscribe "
|
| 660 |
+
"at any time. This week's issue: https://rust-weekly.com/issues/342"],
|
| 661 |
+
],
|
| 662 |
+
inputs=[phish_in],
|
| 663 |
+
label="Try the Sage on these",
|
| 664 |
+
)
|
| 665 |
+
|
| 666 |
+
# ------------------------------------------------ Footprint Scanner
|
| 667 |
+
with gr.Tab("Footprint Scanner"):
|
| 668 |
+
gr.HTML(
|
| 669 |
+
'<div class="ss-intro">Every account you have ever made leaves a mark. '
|
| 670 |
+
"The Sage consults <b>HaveIBeenPwned v3</b> — live if the "
|
| 671 |
+
"<b>HIBP_API_KEY</b> environment variable is set, otherwise a "
|
| 672 |
+
"<b>clearly-labeled demo dataset</b>. No key is ever required.</div>"
|
| 673 |
+
)
|
| 674 |
+
foot_in = gr.Textbox(
|
| 675 |
+
label="Email address or domain",
|
| 676 |
+
placeholder="aria.chen@example.com",
|
| 677 |
+
lines=1,
|
| 678 |
+
)
|
| 679 |
+
foot_btn = gr.Button("Reveal the Footprint", elem_classes="ss-btn")
|
| 680 |
+
foot_out = gr.HTML()
|
| 681 |
+
gr.Examples(
|
| 682 |
+
examples=[["aria.chen@example.com"], ["shadowops.dev"], ["you@yourdomain.com"]],
|
| 683 |
+
inputs=[foot_in],
|
| 684 |
+
label="Try the Sage on these",
|
| 685 |
+
)
|
| 686 |
+
|
| 687 |
+
# ------------------------------------------------ Behavior Monitor
|
| 688 |
+
with gr.Tab("Behavior Monitor"):
|
| 689 |
+
gr.HTML(
|
| 690 |
+
'<div class="ss-intro">Ninety days of login history for <b>aria.chen</b> '
|
| 691 |
+
"(data/login_activity.csv) with <b>seven planted intrusions</b>. An Isolation "
|
| 692 |
+
"Forest learned her normal rhythm — hour, city, device, travel speed — and "
|
| 693 |
+
"flags whatever breaks it.</div>"
|
| 694 |
+
)
|
| 695 |
+
beh_btn = gr.Button("Scan the Logs", elem_classes="ss-btn")
|
| 696 |
+
beh_out = gr.HTML()
|
| 697 |
+
|
| 698 |
+
# ---- wiring: show the oracle animation first, then run the module ----
|
| 699 |
+
phish_btn.click(fn=loading_html, outputs=[phish_out]).then(
|
| 700 |
+
fn=run_phishing, inputs=[phish_in, state],
|
| 701 |
+
outputs=[state, eye, phish_out, sage_out],
|
| 702 |
+
)
|
| 703 |
+
foot_btn.click(fn=loading_html, outputs=[foot_out]).then(
|
| 704 |
+
fn=run_footprint, inputs=[foot_in, state],
|
| 705 |
+
outputs=[state, eye, foot_out, sage_out],
|
| 706 |
+
)
|
| 707 |
+
beh_btn.click(fn=loading_html, outputs=[beh_out]).then(
|
| 708 |
+
fn=run_behavior, inputs=[state],
|
| 709 |
+
outputs=[state, eye, beh_out, sage_out],
|
| 710 |
+
)
|
| 711 |
+
consult_btn.click(fn=loading_html, outputs=[sage_out]).then(
|
| 712 |
+
fn=consult_sage, inputs=[state], outputs=[sage_out, eye],
|
| 713 |
+
)
|
| 714 |
+
# Gradio 6 polls with a Timer component (the old every=... is gone)
|
| 715 |
+
status_timer = gr.Timer(15)
|
| 716 |
+
status_timer.tick(fn=training_status_html, outputs=[status_out])
|
| 717 |
+
|
| 718 |
+
if __name__ == "__main__":
|
| 719 |
+
demo.queue()
|
| 720 |
+
demo.launch(server_name="0.0.0.0", server_port=int(os.environ.get("PORT", "7860")), css=CSS)
|
data/generate_login_data.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Generate data/login_activity.csv — synthetic login telemetry for the Behavioral Anomaly Monitor.
|
| 2 |
+
|
| 3 |
+
This script is NOT needed at runtime; it regenerates the bundled demo dataset.
|
| 4 |
+
It uses only the Python standard library and a fixed random seed, so the CSV it
|
| 5 |
+
produces is deterministic (important for reproducible demos and for explaining
|
| 6 |
+
results in a viva/judging round).
|
| 7 |
+
|
| 8 |
+
The fictional user is "aria.chen":
|
| 9 |
+
- Lives in Austin, TX. Works weekday office hours, occasionally logs in evenings.
|
| 10 |
+
- Travels now and then inside the US (Dallas, Houston, one week in San Francisco).
|
| 11 |
+
- Uses three devices regularly: Windows 11 + Chrome (main), iPhone 15 + Safari, MacBook + Firefox.
|
| 12 |
+
|
| 13 |
+
Seven anomalous events are injected (known_anomaly=1, with a human-readable note):
|
| 14 |
+
1. 2026-07-09 03:14 — Bucharest, RO on a never-seen "Linux - curl" CLI client (new country + night + new device)
|
| 15 |
+
2. 2026-07-22 10:05 — Moscow, RU 23 minutes after a San Francisco login (impossible travel)
|
| 16 |
+
3. 2026-08-03 02:51 — Austin, TX but from a Tor exit node IP at 2:51 AM on an unknown device
|
| 17 |
+
4. 2026-08-17 16:20 — Lagos, NG from a new Android device (new country + device)
|
| 18 |
+
5. 2026-08-25 01:33 — Hanoi, VN on a new Android device at 1:33 AM
|
| 19 |
+
6. 2026-06-30 14:10 — Amsterdam, NL (subtle: new country, but normal hours -> harder to catch)
|
| 20 |
+
7. 2026-08-14 22:47 — Austin, TX but new IP range at an unusual late hour (mild)
|
| 21 |
+
|
| 22 |
+
Run: python data/generate_login_data.py
|
| 23 |
+
"""
|
| 24 |
+
|
| 25 |
+
import csv
|
| 26 |
+
import random
|
| 27 |
+
from datetime import date, datetime, timedelta
|
| 28 |
+
from pathlib import Path
|
| 29 |
+
|
| 30 |
+
random.seed(42) # fixed seed -> deterministic dataset
|
| 31 |
+
|
| 32 |
+
OUT_PATH = Path(__file__).resolve().parent / "login_activity.csv"
|
| 33 |
+
|
| 34 |
+
USER = "aria.chen"
|
| 35 |
+
|
| 36 |
+
# Per-city IP pools. Realistic-looking residential ranges per metro; anomalies use
|
| 37 |
+
# ranges that belong to the anomalous country / known Tor exits.
|
| 38 |
+
IP_POOLS = {
|
| 39 |
+
"Austin": ["76.183.{}.{}", "76.183.{}.{}", "98.8.{}.{}", "24.28.{}.{}"],
|
| 40 |
+
"Dallas": ["72.178.{}.{}", "72.178.{}.{}"],
|
| 41 |
+
"Houston": ["24.167.{}.{}", "24.167.{}.{}"],
|
| 42 |
+
"San Francisco": ["73.92.{}.{}", "73.92.{}.{}"],
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
# Regular devices with weights (Windows desktop at work, phone on the go, laptop at home).
|
| 46 |
+
DEVICES = [("Windows 11 - Chrome", 0.58), ("iPhone 15 - Safari", 0.30), ("MacBook Pro - Firefox", 0.12)]
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def pick_device(rng):
|
| 50 |
+
r = rng.random()
|
| 51 |
+
acc = 0.0
|
| 52 |
+
for name, w in DEVICES:
|
| 53 |
+
acc += w
|
| 54 |
+
if r <= acc:
|
| 55 |
+
return name
|
| 56 |
+
return DEVICES[0][0]
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def pick_ip(rng, city):
|
| 60 |
+
tpl = rng.choice(IP_POOLS[city])
|
| 61 |
+
return tpl.format(rng.randint(10, 220), rng.randint(2, 250))
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def rand_time(rng, day, lo_h, hi_h):
|
| 65 |
+
h = rng.uniform(lo_h, hi_h)
|
| 66 |
+
return datetime(day.year, day.month, day.day) + timedelta(hours=h, minutes=rng.randint(0, 59), seconds=rng.randint(0, 59))
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
rows = []
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def add(dt, city, country, device, ip, known_anomaly, note):
|
| 73 |
+
rows.append(
|
| 74 |
+
{
|
| 75 |
+
"timestamp": dt.strftime("%Y-%m-%d %H:%M:%S"),
|
| 76 |
+
"user": USER,
|
| 77 |
+
"city": city,
|
| 78 |
+
"country": country,
|
| 79 |
+
"device": device,
|
| 80 |
+
"ip": ip,
|
| 81 |
+
"known_anomaly": known_anomaly,
|
| 82 |
+
"note": note,
|
| 83 |
+
}
|
| 84 |
+
)
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
# ---- Normal baseline: June 1 - Aug 28, 2026 -------------------------------------
|
| 88 |
+
day = date(2026, 6, 1)
|
| 89 |
+
while day <= date(2026, 8, 28):
|
| 90 |
+
is_weekend = day.weekday() >= 5
|
| 91 |
+
|
| 92 |
+
# A few multi-day trips inside the US (they repeat, so the model learns they are normal)
|
| 93 |
+
on_dallas_trip = day in [date(2026, 6, 18), date(2026, 6, 19), date(2026, 7, 16), date(2026, 7, 17), date(2026, 8, 20)]
|
| 94 |
+
on_houston_trip = day in [date(2026, 6, 27), date(2026, 6, 28)]
|
| 95 |
+
on_sf_trip = date(2026, 7, 7) <= day <= date(2026, 7, 11)
|
| 96 |
+
|
| 97 |
+
if on_dallas_trip:
|
| 98 |
+
city, country = "Dallas", "US"
|
| 99 |
+
elif on_houston_trip:
|
| 100 |
+
city, country = "Houston", "US"
|
| 101 |
+
elif on_sf_trip:
|
| 102 |
+
city, country = "San Francisco", "US"
|
| 103 |
+
else:
|
| 104 |
+
city, country = "Austin", "US"
|
| 105 |
+
|
| 106 |
+
if is_weekend:
|
| 107 |
+
# Weekends: ~55% chance of a single midday login
|
| 108 |
+
if random.random() < 0.55:
|
| 109 |
+
add(rand_time(random, day, 10, 18), city, country, pick_device(random), pick_ip(random, city), 0, "")
|
| 110 |
+
else:
|
| 111 |
+
# Weekdays: a morning login, plus an evening login ~70% of the time
|
| 112 |
+
add(rand_time(random, day, 7.5, 9.5), city, country, pick_device(random), pick_ip(random, city), 0, "")
|
| 113 |
+
if random.random() < 0.70:
|
| 114 |
+
add(rand_time(random, day, 17, 20.5), city, country, pick_device(random), pick_ip(random, city), 0, "")
|
| 115 |
+
|
| 116 |
+
day += timedelta(days=1)
|
| 117 |
+
|
| 118 |
+
# ---- Injected anomalies -----------------------------------------------------------
|
| 119 |
+
# (timestamp, city, country, device, ip, note)
|
| 120 |
+
ANOMALIES = [
|
| 121 |
+
(
|
| 122 |
+
datetime(2026, 6, 30, 14, 10),
|
| 123 |
+
"Amsterdam",
|
| 124 |
+
"NL",
|
| 125 |
+
"MacBook Pro - Firefox",
|
| 126 |
+
"145.101.88.4",
|
| 127 |
+
"first login from NL - subtle (normal working hours)",
|
| 128 |
+
),
|
| 129 |
+
(
|
| 130 |
+
datetime(2026, 7, 9, 3, 14),
|
| 131 |
+
"Bucharest",
|
| 132 |
+
"RO",
|
| 133 |
+
"Linux - curl/CLI",
|
| 134 |
+
"188.26.7.204",
|
| 135 |
+
"3 AM login from Romania on a never-seen CLI client",
|
| 136 |
+
),
|
| 137 |
+
(
|
| 138 |
+
datetime(2026, 7, 22, 10, 5),
|
| 139 |
+
"Moscow",
|
| 140 |
+
"RU",
|
| 141 |
+
"Unknown - Firefox",
|
| 142 |
+
"5.101.112.9",
|
| 143 |
+
"impossible travel: SF login 23 minutes earlier",
|
| 144 |
+
),
|
| 145 |
+
(
|
| 146 |
+
datetime(2026, 8, 3, 2, 51),
|
| 147 |
+
"Austin",
|
| 148 |
+
"US",
|
| 149 |
+
"Unknown - Tor Browser",
|
| 150 |
+
"185.220.101.34",
|
| 151 |
+
"Tor exit node IP at 2:51 AM on an unknown device",
|
| 152 |
+
),
|
| 153 |
+
(
|
| 154 |
+
datetime(2026, 8, 14, 22, 47),
|
| 155 |
+
"Austin",
|
| 156 |
+
"US",
|
| 157 |
+
"iPhone 15 - Safari",
|
| 158 |
+
"45.132.19.77",
|
| 159 |
+
"unusual late hour from a new IP range",
|
| 160 |
+
),
|
| 161 |
+
(
|
| 162 |
+
datetime(2026, 8, 17, 16, 20),
|
| 163 |
+
"Lagos",
|
| 164 |
+
"NG",
|
| 165 |
+
"Android - Chrome Mobile",
|
| 166 |
+
"197.210.85.66",
|
| 167 |
+
"first login from Nigeria on a new device",
|
| 168 |
+
),
|
| 169 |
+
(
|
| 170 |
+
datetime(2026, 8, 25, 1, 33),
|
| 171 |
+
"Hanoi",
|
| 172 |
+
"VN",
|
| 173 |
+
"Android - Chrome Mobile",
|
| 174 |
+
"113.161.9.51",
|
| 175 |
+
"new country + new device at 1:33 AM",
|
| 176 |
+
),
|
| 177 |
+
]
|
| 178 |
+
for dt, city, country, device, ip, note in ANOMALIES:
|
| 179 |
+
add(dt, city, country, device, ip, 1, note)
|
| 180 |
+
|
| 181 |
+
# ---- Write, sorted chronologically -------------------------------------------------
|
| 182 |
+
rows.sort(key=lambda r: r["timestamp"])
|
| 183 |
+
|
| 184 |
+
with OUT_PATH.open("w", newline="", encoding="utf-8") as f:
|
| 185 |
+
writer = csv.DictWriter(f, fieldnames=["timestamp", "user", "city", "country", "device", "ip", "known_anomaly", "note"])
|
| 186 |
+
writer.writeheader()
|
| 187 |
+
writer.writerows(rows)
|
| 188 |
+
|
| 189 |
+
n_anom = sum(int(r["known_anomaly"]) for r in rows)
|
| 190 |
+
print(f"Wrote {len(rows)} events ({n_anom} injected anomalies) to {OUT_PATH}")
|
data/login_activity.csv
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
timestamp,user,city,country,device,ip,known_anomaly,note
|
| 2 |
+
2026-06-01 08:48:30,aria.chen,Austin,US,Windows 11 - Chrome,76.183.45.190,0,
|
| 3 |
+
2026-06-01 20:09:37,aria.chen,Austin,US,iPhone 15 - Safari,76.183.17.25,0,
|
| 4 |
+
2026-06-02 08:28:52,aria.chen,Austin,US,Windows 11 - Chrome,76.183.193.168,0,
|
| 5 |
+
2026-06-03 08:48:57,aria.chen,Austin,US,Windows 11 - Chrome,76.183.204.208,0,
|
| 6 |
+
2026-06-03 18:45:53,aria.chen,Austin,US,Windows 11 - Chrome,98.8.36.25,0,
|
| 7 |
+
2026-06-04 08:38:29,aria.chen,Austin,US,Windows 11 - Chrome,98.8.216.13,0,
|
| 8 |
+
2026-06-05 09:33:44,aria.chen,Austin,US,Windows 11 - Chrome,98.8.170.160,0,
|
| 9 |
+
2026-06-06 11:36:19,aria.chen,Austin,US,iPhone 15 - Safari,98.8.30.220,0,
|
| 10 |
+
2026-06-07 11:05:57,aria.chen,Austin,US,iPhone 15 - Safari,98.8.51.96,0,
|
| 11 |
+
2026-06-08 08:54:54,aria.chen,Austin,US,iPhone 15 - Safari,76.183.165.164,0,
|
| 12 |
+
2026-06-08 19:43:35,aria.chen,Austin,US,Windows 11 - Chrome,76.183.185.85,0,
|
| 13 |
+
2026-06-09 10:00:11,aria.chen,Austin,US,Windows 11 - Chrome,76.183.216.82,0,
|
| 14 |
+
2026-06-09 18:12:29,aria.chen,Austin,US,iPhone 15 - Safari,98.8.64.169,0,
|
| 15 |
+
2026-06-10 09:26:52,aria.chen,Austin,US,iPhone 15 - Safari,76.183.77.37,0,
|
| 16 |
+
2026-06-10 19:14:40,aria.chen,Austin,US,iPhone 15 - Safari,24.28.102.58,0,
|
| 17 |
+
2026-06-11 09:38:12,aria.chen,Austin,US,Windows 11 - Chrome,76.183.38.41,0,
|
| 18 |
+
2026-06-11 20:13:58,aria.chen,Austin,US,Windows 11 - Chrome,24.28.162.121,0,
|
| 19 |
+
2026-06-12 09:09:24,aria.chen,Austin,US,MacBook Pro - Firefox,76.183.184.228,0,
|
| 20 |
+
2026-06-12 18:37:22,aria.chen,Austin,US,Windows 11 - Chrome,24.28.50.118,0,
|
| 21 |
+
2026-06-13 16:32:52,aria.chen,Austin,US,MacBook Pro - Firefox,76.183.139.235,0,
|
| 22 |
+
2026-06-14 15:53:48,aria.chen,Austin,US,Windows 11 - Chrome,76.183.49.97,0,
|
| 23 |
+
2026-06-15 09:36:19,aria.chen,Austin,US,MacBook Pro - Firefox,76.183.163.84,0,
|
| 24 |
+
2026-06-15 17:47:25,aria.chen,Austin,US,MacBook Pro - Firefox,98.8.71.16,0,
|
| 25 |
+
2026-06-16 08:34:59,aria.chen,Austin,US,Windows 11 - Chrome,24.28.218.19,0,
|
| 26 |
+
2026-06-17 08:42:03,aria.chen,Austin,US,iPhone 15 - Safari,76.183.77.137,0,
|
| 27 |
+
2026-06-18 08:34:45,aria.chen,Dallas,US,Windows 11 - Chrome,72.178.192.81,0,
|
| 28 |
+
2026-06-18 19:44:31,aria.chen,Dallas,US,MacBook Pro - Firefox,72.178.40.65,0,
|
| 29 |
+
2026-06-19 08:17:58,aria.chen,Dallas,US,iPhone 15 - Safari,72.178.160.58,0,
|
| 30 |
+
2026-06-19 19:31:52,aria.chen,Dallas,US,Windows 11 - Chrome,72.178.94.20,0,
|
| 31 |
+
2026-06-20 12:44:53,aria.chen,Austin,US,Windows 11 - Chrome,24.28.72.202,0,
|
| 32 |
+
2026-06-21 13:21:29,aria.chen,Austin,US,iPhone 15 - Safari,98.8.118.107,0,
|
| 33 |
+
2026-06-22 09:12:05,aria.chen,Austin,US,iPhone 15 - Safari,76.183.25.105,0,
|
| 34 |
+
2026-06-23 09:12:19,aria.chen,Austin,US,Windows 11 - Chrome,24.28.45.110,0,
|
| 35 |
+
2026-06-23 19:33:08,aria.chen,Austin,US,Windows 11 - Chrome,76.183.22.168,0,
|
| 36 |
+
2026-06-24 10:22:54,aria.chen,Austin,US,MacBook Pro - Firefox,76.183.52.106,0,
|
| 37 |
+
2026-06-24 18:10:50,aria.chen,Austin,US,Windows 11 - Chrome,24.28.10.101,0,
|
| 38 |
+
2026-06-25 08:52:39,aria.chen,Austin,US,Windows 11 - Chrome,24.28.188.247,0,
|
| 39 |
+
2026-06-26 09:46:45,aria.chen,Austin,US,Windows 11 - Chrome,76.183.85.57,0,
|
| 40 |
+
2026-06-28 16:02:04,aria.chen,Houston,US,iPhone 15 - Safari,24.167.24.247,0,
|
| 41 |
+
2026-06-29 09:25:07,aria.chen,Austin,US,Windows 11 - Chrome,76.183.182.222,0,
|
| 42 |
+
2026-06-29 18:21:46,aria.chen,Austin,US,Windows 11 - Chrome,76.183.168.22,0,
|
| 43 |
+
2026-06-30 08:57:54,aria.chen,Austin,US,Windows 11 - Chrome,98.8.62.173,0,
|
| 44 |
+
2026-06-30 14:10:00,aria.chen,Amsterdam,NL,MacBook Pro - Firefox,145.101.88.4,1,first login from NL - subtle (normal working hours)
|
| 45 |
+
2026-07-01 08:23:46,aria.chen,Austin,US,iPhone 15 - Safari,98.8.127.82,0,
|
| 46 |
+
2026-07-02 09:22:46,aria.chen,Austin,US,iPhone 15 - Safari,76.183.28.139,0,
|
| 47 |
+
2026-07-02 18:55:03,aria.chen,Austin,US,MacBook Pro - Firefox,76.183.104.74,0,
|
| 48 |
+
2026-07-03 08:42:29,aria.chen,Austin,US,iPhone 15 - Safari,76.183.180.211,0,
|
| 49 |
+
2026-07-03 20:22:36,aria.chen,Austin,US,Windows 11 - Chrome,76.183.37.192,0,
|
| 50 |
+
2026-07-05 15:35:40,aria.chen,Austin,US,Windows 11 - Chrome,98.8.139.127,0,
|
| 51 |
+
2026-07-06 08:59:02,aria.chen,Austin,US,Windows 11 - Chrome,24.28.80.13,0,
|
| 52 |
+
2026-07-06 20:22:12,aria.chen,Austin,US,Windows 11 - Chrome,24.28.151.182,0,
|
| 53 |
+
2026-07-07 08:21:26,aria.chen,San Francisco,US,Windows 11 - Chrome,73.92.149.11,0,
|
| 54 |
+
2026-07-08 08:49:21,aria.chen,San Francisco,US,Windows 11 - Chrome,73.92.103.232,0,
|
| 55 |
+
2026-07-09 03:14:00,aria.chen,Bucharest,RO,Linux - curl/CLI,188.26.7.204,1,3 AM login from Romania on a never-seen CLI client
|
| 56 |
+
2026-07-09 09:29:36,aria.chen,San Francisco,US,Windows 11 - Chrome,73.92.180.28,0,
|
| 57 |
+
2026-07-09 19:53:00,aria.chen,San Francisco,US,MacBook Pro - Firefox,73.92.70.223,0,
|
| 58 |
+
2026-07-10 08:41:21,aria.chen,San Francisco,US,Windows 11 - Chrome,73.92.16.47,0,
|
| 59 |
+
2026-07-11 18:18:29,aria.chen,San Francisco,US,iPhone 15 - Safari,73.92.78.42,0,
|
| 60 |
+
2026-07-13 08:38:00,aria.chen,Austin,US,iPhone 15 - Safari,76.183.61.211,0,
|
| 61 |
+
2026-07-14 09:04:47,aria.chen,Austin,US,iPhone 15 - Safari,76.183.16.170,0,
|
| 62 |
+
2026-07-14 19:04:00,aria.chen,Austin,US,MacBook Pro - Firefox,98.8.99.166,0,
|
| 63 |
+
2026-07-15 09:15:00,aria.chen,Austin,US,Windows 11 - Chrome,76.183.39.226,0,
|
| 64 |
+
2026-07-16 08:07:27,aria.chen,Dallas,US,Windows 11 - Chrome,72.178.98.188,0,
|
| 65 |
+
2026-07-17 08:54:29,aria.chen,Dallas,US,Windows 11 - Chrome,72.178.75.13,0,
|
| 66 |
+
2026-07-18 17:58:59,aria.chen,Austin,US,iPhone 15 - Safari,76.183.103.112,0,
|
| 67 |
+
2026-07-19 15:40:29,aria.chen,Austin,US,Windows 11 - Chrome,76.183.194.232,0,
|
| 68 |
+
2026-07-20 08:25:44,aria.chen,Austin,US,Windows 11 - Chrome,24.28.188.77,0,
|
| 69 |
+
2026-07-20 18:22:41,aria.chen,Austin,US,iPhone 15 - Safari,76.183.167.147,0,
|
| 70 |
+
2026-07-21 08:41:59,aria.chen,Austin,US,Windows 11 - Chrome,98.8.63.112,0,
|
| 71 |
+
2026-07-22 09:03:16,aria.chen,Austin,US,Windows 11 - Chrome,76.183.140.123,0,
|
| 72 |
+
2026-07-22 10:05:00,aria.chen,Moscow,RU,Unknown - Firefox,5.101.112.9,1,impossible travel: SF login 23 minutes earlier
|
| 73 |
+
2026-07-23 10:12:20,aria.chen,Austin,US,iPhone 15 - Safari,98.8.141.171,0,
|
| 74 |
+
2026-07-23 19:03:11,aria.chen,Austin,US,Windows 11 - Chrome,98.8.67.208,0,
|
| 75 |
+
2026-07-24 07:54:55,aria.chen,Austin,US,Windows 11 - Chrome,24.28.166.219,0,
|
| 76 |
+
2026-07-25 17:41:32,aria.chen,Austin,US,iPhone 15 - Safari,24.28.136.104,0,
|
| 77 |
+
2026-07-26 15:15:51,aria.chen,Austin,US,iPhone 15 - Safari,76.183.209.110,0,
|
| 78 |
+
2026-07-27 08:47:59,aria.chen,Austin,US,Windows 11 - Chrome,76.183.152.65,0,
|
| 79 |
+
2026-07-28 07:53:24,aria.chen,Austin,US,Windows 11 - Chrome,98.8.203.230,0,
|
| 80 |
+
2026-07-28 20:48:41,aria.chen,Austin,US,Windows 11 - Chrome,24.28.50.192,0,
|
| 81 |
+
2026-07-29 09:41:38,aria.chen,Austin,US,iPhone 15 - Safari,98.8.206.201,0,
|
| 82 |
+
2026-07-29 19:29:05,aria.chen,Austin,US,Windows 11 - Chrome,98.8.70.71,0,
|
| 83 |
+
2026-07-30 09:07:52,aria.chen,Austin,US,Windows 11 - Chrome,76.183.69.100,0,
|
| 84 |
+
2026-07-30 19:32:46,aria.chen,Austin,US,Windows 11 - Chrome,24.28.116.17,0,
|
| 85 |
+
2026-07-31 08:21:13,aria.chen,Austin,US,MacBook Pro - Firefox,76.183.205.149,0,
|
| 86 |
+
2026-07-31 17:23:33,aria.chen,Austin,US,iPhone 15 - Safari,24.28.147.193,0,
|
| 87 |
+
2026-08-03 02:51:00,aria.chen,Austin,US,Unknown - Tor Browser,185.220.101.34,1,Tor exit node IP at 2:51 AM on an unknown device
|
| 88 |
+
2026-08-03 09:48:58,aria.chen,Austin,US,Windows 11 - Chrome,24.28.17.101,0,
|
| 89 |
+
2026-08-03 19:48:23,aria.chen,Austin,US,Windows 11 - Chrome,24.28.42.161,0,
|
| 90 |
+
2026-08-04 09:32:30,aria.chen,Austin,US,iPhone 15 - Safari,76.183.31.166,0,
|
| 91 |
+
2026-08-04 20:13:05,aria.chen,Austin,US,Windows 11 - Chrome,98.8.64.118,0,
|
| 92 |
+
2026-08-05 08:58:09,aria.chen,Austin,US,Windows 11 - Chrome,24.28.74.215,0,
|
| 93 |
+
2026-08-05 17:38:07,aria.chen,Austin,US,MacBook Pro - Firefox,98.8.67.168,0,
|
| 94 |
+
2026-08-06 08:19:16,aria.chen,Austin,US,iPhone 15 - Safari,76.183.61.216,0,
|
| 95 |
+
2026-08-06 17:40:30,aria.chen,Austin,US,iPhone 15 - Safari,76.183.129.181,0,
|
| 96 |
+
2026-08-07 08:23:54,aria.chen,Austin,US,iPhone 15 - Safari,76.183.209.211,0,
|
| 97 |
+
2026-08-07 18:42:20,aria.chen,Austin,US,MacBook Pro - Firefox,24.28.111.243,0,
|
| 98 |
+
2026-08-09 16:11:44,aria.chen,Austin,US,Windows 11 - Chrome,98.8.185.155,0,
|
| 99 |
+
2026-08-10 09:57:15,aria.chen,Austin,US,iPhone 15 - Safari,98.8.146.111,0,
|
| 100 |
+
2026-08-10 17:55:49,aria.chen,Austin,US,Windows 11 - Chrome,24.28.220.127,0,
|
| 101 |
+
2026-08-11 08:06:19,aria.chen,Austin,US,MacBook Pro - Firefox,24.28.191.41,0,
|
| 102 |
+
2026-08-11 20:15:22,aria.chen,Austin,US,iPhone 15 - Safari,24.28.129.113,0,
|
| 103 |
+
2026-08-12 09:46:22,aria.chen,Austin,US,Windows 11 - Chrome,76.183.32.73,0,
|
| 104 |
+
2026-08-13 08:28:51,aria.chen,Austin,US,iPhone 15 - Safari,24.28.96.9,0,
|
| 105 |
+
2026-08-13 18:39:28,aria.chen,Austin,US,Windows 11 - Chrome,98.8.97.73,0,
|
| 106 |
+
2026-08-14 10:00:34,aria.chen,Austin,US,Windows 11 - Chrome,76.183.142.244,0,
|
| 107 |
+
2026-08-14 18:17:12,aria.chen,Austin,US,Windows 11 - Chrome,76.183.186.123,0,
|
| 108 |
+
2026-08-14 22:47:00,aria.chen,Austin,US,iPhone 15 - Safari,45.132.19.77,1,unusual late hour from a new IP range
|
| 109 |
+
2026-08-16 16:25:54,aria.chen,Austin,US,Windows 11 - Chrome,76.183.88.171,0,
|
| 110 |
+
2026-08-17 09:10:22,aria.chen,Austin,US,Windows 11 - Chrome,24.28.200.142,0,
|
| 111 |
+
2026-08-17 16:20:00,aria.chen,Lagos,NG,Android - Chrome Mobile,197.210.85.66,1,first login from Nigeria on a new device
|
| 112 |
+
2026-08-17 19:44:54,aria.chen,Austin,US,Windows 11 - Chrome,76.183.194.51,0,
|
| 113 |
+
2026-08-18 08:55:25,aria.chen,Austin,US,MacBook Pro - Firefox,76.183.59.57,0,
|
| 114 |
+
2026-08-19 08:40:58,aria.chen,Austin,US,Windows 11 - Chrome,98.8.35.215,0,
|
| 115 |
+
2026-08-19 17:59:05,aria.chen,Austin,US,Windows 11 - Chrome,76.183.80.13,0,
|
| 116 |
+
2026-08-20 10:02:12,aria.chen,Dallas,US,iPhone 15 - Safari,72.178.173.224,0,
|
| 117 |
+
2026-08-21 07:42:54,aria.chen,Austin,US,Windows 11 - Chrome,24.28.122.89,0,
|
| 118 |
+
2026-08-21 18:06:17,aria.chen,Austin,US,Windows 11 - Chrome,76.183.112.127,0,
|
| 119 |
+
2026-08-22 15:05:17,aria.chen,Austin,US,Windows 11 - Chrome,98.8.31.65,0,
|
| 120 |
+
2026-08-23 16:45:33,aria.chen,Austin,US,iPhone 15 - Safari,76.183.208.135,0,
|
| 121 |
+
2026-08-24 09:14:06,aria.chen,Austin,US,Windows 11 - Chrome,24.28.88.147,0,
|
| 122 |
+
2026-08-24 19:55:07,aria.chen,Austin,US,MacBook Pro - Firefox,76.183.170.56,0,
|
| 123 |
+
2026-08-25 01:33:00,aria.chen,Hanoi,VN,Android - Chrome Mobile,113.161.9.51,1,new country + new device at 1:33 AM
|
| 124 |
+
2026-08-25 08:06:55,aria.chen,Austin,US,Windows 11 - Chrome,76.183.50.2,0,
|
| 125 |
+
2026-08-25 19:55:04,aria.chen,Austin,US,Windows 11 - Chrome,98.8.190.74,0,
|
| 126 |
+
2026-08-26 09:23:25,aria.chen,Austin,US,iPhone 15 - Safari,98.8.211.204,0,
|
| 127 |
+
2026-08-26 20:18:02,aria.chen,Austin,US,Windows 11 - Chrome,76.183.175.40,0,
|
| 128 |
+
2026-08-27 10:11:09,aria.chen,Austin,US,Windows 11 - Chrome,76.183.212.80,0,
|
| 129 |
+
2026-08-27 20:51:23,aria.chen,Austin,US,Windows 11 - Chrome,24.28.186.79,0,
|
| 130 |
+
2026-08-28 09:11:30,aria.chen,Austin,US,Windows 11 - Chrome,24.28.30.155,0,
|
| 131 |
+
2026-08-28 18:51:21,aria.chen,Austin,US,Windows 11 - Chrome,76.183.68.248,0,
|
data/phishing_dataset.csv
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
text,label
|
| 2 |
+
"Dear Customer, we detected unusual activity on your Chase account and it will be suspended within 24 hours. Verify your identity now at http://chase-secure-verify.tk/confirm",1
|
| 3 |
+
"URGENT: Your Wells Fargo online banking has been locked after multiple failed login attempts. Restore access immediately at http://wf-restore-account.xyz/login",1
|
| 4 |
+
"Your account will be permanently closed today due to suspicious transactions. Confirm your account details to stop the closure: http://secure-bankupdate.click/verify",1
|
| 5 |
+
"Alert: A new device signed in to your Barclays account from Moscow. If this was not you, verify your identity at http://barclays-verify-account.top/auth",1
|
| 6 |
+
"Dear valued customer, your debit card has been temporarily frozen. Enter your card number and PIN at http://card-unlock-service.gq to unfreeze it",1
|
| 7 |
+
"Your HSBC account needs immediate verification. Failure to verify will result in account suspension. Proceed here: http://hsbc-verify-secure.top/step1",1
|
| 8 |
+
"We have blocked a $4,300 wire transfer from your account. To approve or decline this payment, confirm your password at http://bank-payment-review.xyz",1
|
| 9 |
+
"Your mortgage application has been declined due to an error in your banking details. Update your account information here: http://mortgage-fix-details.click",1
|
| 10 |
+
"Attention: your online banking profile was accessed from an unregistered device. Verify your identity within 12 hours or access will be removed. http://onlinebank-device-check.top",1
|
| 11 |
+
"Final notice: your credit card payment of $289.00 is overdue. Avoid account closure by confirming card details at http://card-payment-finalnotice.xyz",1
|
| 12 |
+
"Your account has been restricted. We need to confirm your identity. Please reply with your full name, date of birth and account password.",1
|
| 13 |
+
"Zelle fraud alert: a payment of $1,950 was attempted from your account. Reply YES to confirm or call our hotline to cancel the transfer.",1
|
| 14 |
+
"Dear account holder, our records show your signature card is missing. Upload a scan of your photo ID and password to continue banking services.",1
|
| 15 |
+
"Your Citibank account shows signs of unauthorized access. Enter your online credentials at http://citibank-login-secure.cf to review the activity",1
|
| 16 |
+
"We could not verify your billing address and have placed a hold on your savings account. Remove the hold: http://savings-hold-removal.gq/update",1
|
| 17 |
+
"Bank of America security team: your session was hijacked. Change your password using this link within 1 hour: http://boa-password-reset-now.xyz",1
|
| 18 |
+
"PayPal: Your account has been limited. We noticed an unusual log in from another device. Resolve now: http://www.paypa1.com-resolution-center.tk",1
|
| 19 |
+
"Amazon: order 402-8873124 could not be delivered because your shipping address was incorrect. Update billing info within 12 hours: http://amzn-support-updates.xyz",1
|
| 20 |
+
"Microsoft account security alert: your password was compromised in a data breach. Verify your recovery information: http://microsoft-verify-login.top/security",1
|
| 21 |
+
"Netflix: your payment was declined. Update your payment details to continue watching: http://netflix-billing-update.click/pay",1
|
| 22 |
+
"Apple ID locked for security reasons. Someone tried to sign in from Beijing. Unlock your account: http://appleid-unlock-verify.xyz",1
|
| 23 |
+
"Your Google account will be deleted in 24 hours unless you confirm this is still your primary account: http://google-account-confirm.top",1
|
| 24 |
+
"Facebook timeline violation detected. Appeal within 48 hours or your profile will be permanently removed: http://fb-appeal-center.click",1
|
| 25 |
+
"Instagram security: your account is flagged for impersonation. Verify it is you: http://insta-verify-identity.xyz/login",1
|
| 26 |
+
"WhatsApp: your phone number will be deregistered because of new terms. Continue registration: http://whatsapp-number-keep.top",1
|
| 27 |
+
"eBay: your seller account is suspended pending identity verification. Upload documents here: http://ebay-seller-verification.gq",1
|
| 28 |
+
"Your Spotify Premium expired today. Renew with 60 percent off before midnight: http://spotify-renew-premium-offer.click",1
|
| 29 |
+
"Zoom: your scheduled meeting recordings are ready. Sign in to view: http://zoom-rec-sharing-secure.xyz",1
|
| 30 |
+
"Dear LinkedIn member, your profile appears in unsafe searches. Restrict visibility now: http://linkedin-privacy-restrict.top",1
|
| 31 |
+
"Adobe Creative Cloud: your license will be deactivated today. Reactivate at 70 percent off: http://adobe-cc-reactivation.xyz",1
|
| 32 |
+
"PayPal dispute opened against you for $699.00. Respond with your account credentials at http://paypal-dispute-response.tk",1
|
| 33 |
+
"Twitter Blue verification is now free for selected accounts. Claim your badge: http://twitter-badge-claim.click/verify",1
|
| 34 |
+
"Congratulations! You were selected to receive 0.75 BTC in our community giveaway. Send 0.075 BTC to the address below to verify your wallet and receive your prize.",1
|
| 35 |
+
"Your Coinbase wallet will be suspended unless you complete KYC verification immediately. Submit your seed phrase to restore access: http://coinbase-kyc-restore.top",1
|
| 36 |
+
"Binance security warning: new regulations require you to withdraw your funds within 24 hours or they will be frozen. Verify at http://binance-withdraw-secure.xyz",1
|
| 37 |
+
"Elon Musk is giving away 100 BTC to the community for the 15th anniversary of Tesla. Double your bitcoin now: http://musk-crypto-doubler.tk",1
|
| 38 |
+
"URGENT: your crypto wallet has been compromised. Move your funds immediately to our secure recovery wallet. Reply with your 12-word recovery phrase to begin.",1
|
| 39 |
+
"Your NFT listing sold for 2.4 ETH! Connect your wallet and sign the transaction to release funds: http://nft-marketplace-release.xyz",1
|
| 40 |
+
"Investment opportunity: guaranteed 300 percent returns in 30 days on our AI trading platform. Deposit minimum 0.5 BTC today. Slots are limited.",1
|
| 41 |
+
"MetaMask: your wallet will be migrated to our new security layer. Enter your seed phrase at http://metamask-migrate-secure.click to keep your funds",1
|
| 42 |
+
"We noticed a login to your Binance account from Indonesia. Cancel it by sending the 6-digit code we texted you to our support agent.",1
|
| 43 |
+
"Bitcoin bonus: you have unclaimed satoshis from 2021. Withdraw them to your wallet at http://satoshi-claim-center.top",1
|
| 44 |
+
"Your KuCoin withdrawal of 1.2 ETH is pending. Confirm the transaction with your 2FA seed phrase: http://kucoin-withdraw-confirm.gq",1
|
| 45 |
+
"The whales are moving! Join our VIP crypto signals group free for 24 hours. Send 0.05 ETH entry fee to lock your spot.",1
|
| 46 |
+
"USPS: your package is held at the warehouse due to an unpaid shipping fee of $1.99. Pay now: http://usps-parcel-redelivery.xyz",1
|
| 47 |
+
"FedEx: we attempted delivery but nobody was home. Reschedule and pay the redelivery charge: http://fedex-reschedule-delivery.top",1
|
| 48 |
+
"DHL Express: shipment AWB7734KJ is stopped at customs. Clear it now to avoid return: http://dhl-customs-clearance.click/track",1
|
| 49 |
+
"UPS final notice: your parcel will be returned to sender unless you update delivery preferences at http://ups-delivery-preferences.gq",1
|
| 50 |
+
"Your Amazon package could not be delivered as the address is incomplete. Confirm your address and pay a $0.50 correction fee: http://amzn-address-fix.xyz",1
|
| 51 |
+
"Royal Mail: customs duty of 2.99 GBP is due on your international parcel. Pay within 3 days: http://royalmail-duty-pay.top",1
|
| 52 |
+
"AusPost: we missed you. Your parcel is waiting at the depot. Book redelivery for $2.50: http://auspost-redelivery-book.click",1
|
| 53 |
+
"Evri: your delivery is on hold. Verify your address to release it: http://evri-address-verify.xyz",1
|
| 54 |
+
"IRS FINAL NOTICE: you owe $4,872.13 in back taxes. A warrant for your arrest will be issued unless you pay immediately in Apple gift cards. Call 202-555-0134 now.",1
|
| 55 |
+
"Your Social Security number has been suspended due to suspicious activity. Press 1 to speak with an agent or verify online: http://ssa-verify-number.xyz",1
|
| 56 |
+
"HMRC: you are eligible for a tax refund of 348.62 GBP. Submit your bank details to receive your refund: http://hmrc-refund-claim.top/claim",1
|
| 57 |
+
"This is the Canada Revenue Agency. A lawsuit has been filed against you for tax fraud. Settle your case by purchasing Google Play cards and calling our agent.",1
|
| 58 |
+
"Your vehicle tax is overdue. A fine of 80 GBP will be applied tonight unless you renew at http://gov-vehicle-tax-renew.top",1
|
| 59 |
+
"Notice of intended prosecution: your vehicle was caught speeding. View the evidence and pay the fine at http://police-speeding-evidence.xyz",1
|
| 60 |
+
"The Department of Education: your student loan forgiveness application requires immediate verification of your FSA ID and password. http://ed-loan-forgiveness-verify.click",1
|
| 61 |
+
"USCIS: your immigration case number has been flagged. Confirm your identity and green card number at http://uscis-case-verify.gq or face deportation proceedings.",1
|
| 62 |
+
"WARNING: your computer has been infected with 4 viruses! Your system files are being deleted. Contact Microsoft Certified Support immediately at 1-888-555-0192.",1
|
| 63 |
+
"We detected unauthorized access attempts on your Windows license. Your license will be deactivated today. Reactivate: http://windows-license-renewal.xyz",1
|
| 64 |
+
"Critical security alert from Apple Support: your iPhone is broadcasting a virus to other devices. Call our toll-free number within 10 minutes.",1
|
| 65 |
+
"Your Norton subscription expired and your device is now unprotected. Renew for $299.99 immediately: http://norton-shield-renewal.click",1
|
| 66 |
+
"A hacker is remotely accessing your webcam. Our technician can fix it now for a one-time fee of $150 in Bitcoin. Call support to begin.",1
|
| 67 |
+
"McAfee total protection has detected trojan JS/CoinMiner on your machine. Enable macros in the attached report to view removal steps.",1
|
| 68 |
+
"Congratulations!!! You are the lucky winner of our international lottery! Claim your $2,500,000.00 prize by sending your full name, address and bank account details to claims@lottery-intl.tk",1
|
| 69 |
+
"You have been selected for a $1000 Walmart gift card. Complete this short survey to claim your reward: http://walmart-gift-rewards.click",1
|
| 70 |
+
"Winner winner! Your phone number won 750,000 EUR in the EU Mobile Lottery. Send 250 EUR processing fee to release your winnings.",1
|
| 71 |
+
"Target customer appreciation: you are 1 of 25 selected today for a free iPhone 16 Pro. Claim within 15 minutes: http://target-prize-claim.top",1
|
| 72 |
+
"Your email won the Microsoft Executive Lottery. To begin the transfer of 850,000 GBP, reply with your full banking details and a copy of your passport.",1
|
| 73 |
+
"Bingo! You are today's lucky visitor. Spin the wheel to claim your bonus prize before it expires: http://lucky-visitor-bonus.xyz",1
|
| 74 |
+
"Publisher's Clearing House: the prize patrol is in your area! Confirm your address and pay the $99.50 insurance fee so we can deliver your check.",1
|
| 75 |
+
"You have 1 unclaimed reward from DoorDash. Redeem your $50 credit: http://doordash-reward-redeem.click/claim",1
|
| 76 |
+
"Earn $5000 per month working from home! No experience needed. Send a $49 registration fee to secure your position. Positions are limited.",1
|
| 77 |
+
"Mystery shopper wanted: evaluate stores in your area and keep the items. Register with your bank details at http://shoppers-jobs-register.xyz",1
|
| 78 |
+
"Congratulations on being shortlisted for the Data Entry Specialist role ($35/hr). To proceed, pay $120 for the background check via Zelle.",1
|
| 79 |
+
"We are hiring personal assistants. You will receive payments from our clients and forward them via Bitcoin. Keep 15 percent commission.",1
|
| 80 |
+
"Your resume was selected! Complete the pre-employment form with your SSN and date of birth to receive the offer letter: http://hr-preemployment-form.gq",1
|
| 81 |
+
"Work from home packing parcels for Amazon. Earn $40 per box. We just need your bank account number and routing number for direct deposit.",1
|
| 82 |
+
"Easy money: drive with missing persons awareness decals on your car and get paid $400 weekly. Send a $5 deposit to start.",1
|
| 83 |
+
"Hi, I am in a meeting and cannot talk. I need you to buy 5 Apple gift cards of $200 each and send me the codes. It is urgent. - your manager",1
|
| 84 |
+
"I need a quick favor. I am traveling and lost my wallet. Could you wire $1,200 via Western Union to my assistant? I will pay you back Friday.",1
|
| 85 |
+
"Are you available? I want you to handle a confidential purchase today. Do not discuss with anyone else. Buy 3 Google Play cards and reply with the codes.",1
|
| 86 |
+
"This is the CEO. I am in a signing ceremony and cannot call. Our supplier needs an urgent wire transfer of $18,500. Here are the new bank details for the payment.",1
|
| 87 |
+
"Can you do me a favor? I need Amazon gift cards for client appreciation. Scratch the back and text me a photo of each code.",1
|
| 88 |
+
"HR here: your direct deposit information did not process. Reply with your account number and password so payroll can fix it before Friday.",1
|
| 89 |
+
"I am at a conference and my phone is dying. Email me the company credit card number and security code so I can book a flight.",1
|
| 90 |
+
"Urgent: our office is switching banks. Update the vendor payment details to the account below before this afternoon's payment run.",1
|
| 91 |
+
"My love, I am stranded at the airport in Istanbul. Customs is holding my luggage and I need $850 to clear it. Please send via wire transfer urgently.",1
|
| 92 |
+
"I cannot stop thinking about you. My daughter is in the hospital and the bill is $2,300. If you truly love me you will help. I will repay you when my oil contract pays out.",1
|
| 93 |
+
"Sweetheart, my business partner cheated me and I cannot access my funds. Could you receive a package of gold and forward it? I will marry you when I visit in spring.",1
|
| 94 |
+
"Hey handsome! I would love to video chat but my camera is broken. Send me $150 for a new phone and I will make it up to you.",1
|
| 95 |
+
"Beloved, the military will not let me access my bank account while deployed. Please receive my retirement fund of $2.7M and keep 20 percent for your trouble.",1
|
| 96 |
+
"Confirm your password: we updated our security policy. Reply with your current password to keep your account active.",1
|
| 97 |
+
"Your verification code is 481922. Share this code with our support agent to cancel the unauthorized transaction on your account.",1
|
| 98 |
+
"Your email storage is full. You will stop receiving emails at midnight. Confirm your password here to increase storage: http://mail-storage-upgrade.xyz",1
|
| 99 |
+
"Webmail account deactivation in progress. To cancel, provide your username, password and date of birth within 24 hours.",1
|
| 100 |
+
"Your mailbox will be permanently deleted because you have not verified it in 2026. Keep your mailbox: http://mailbox-keep-verify.click",1
|
| 101 |
+
"Someone in Lagos just used your password to try to sign in. Block them by confirming your password at http://account-block-signin.top",1
|
| 102 |
+
"IT Service Desk: we are migrating mailboxes. Send your password so we can complete the migration and avoid losing your emails.",1
|
| 103 |
+
"Your One-Time Passcode is 730154. Do not share it with anyone. A second message from our agent will ask you to read back the code to verify your refund.",1
|
| 104 |
+
"Your account security is expiring. Re-validate your credentials now at http://credential-revalidate.gq or lose access forever.",1
|
| 105 |
+
"Hello, this is PayPal support. We have detected fraud on your account. Please tell us the code we just sent so we can secure your funds.",1
|
| 106 |
+
"Please see attached invoice INV-9921 for the outstanding balance. Enable editing and macros to view the document. Payment is due upon receipt.",1
|
| 107 |
+
"You have 1 new encrypted fax document. Download the viewer to access it: http://fax-document-viewer-download.click",1
|
| 108 |
+
"Purchase order 88231 attached. Kindly enable content and macros to display the pricing table correctly. Confirm receipt urgently.",1
|
| 109 |
+
"Your voicemail from +1 202-555-0147 has been received. Play the attached executable file to listen. Do not reply to this automated message.",1
|
| 110 |
+
"DHL shipping label attached. Open the screensaver file to print. Failure to respond within 24 hours will void your shipment.",1
|
| 111 |
+
"Shared document: Q3_financials_final.pdf.kmz - click to sign in with your email password to view: http://sharepoint-doc-view.xyz",1
|
| 112 |
+
"Your Adobe subscription payment failed 3 times. Your account will be downgraded today. Fix payment: http://adobe-payment-fix.xyz",1
|
| 113 |
+
"Spotify: we could not process your payment. Your account will be switched to the free tier in 24 hours. Update billing: http://spotify-billing-fix.top",1
|
| 114 |
+
"Your NordVPN subscription renews today for $399.99. To cancel, call our retention team within 2 hours.",1
|
| 115 |
+
"iCloud storage full: your photos will stop backing up tonight. Verify your Apple ID to expand storage free: http://icloud-storage-expand.click",1
|
| 116 |
+
"Your Microsoft 365 subscription has expired. Renew at the special price of $9.99 before your files are deleted: http://m365-file-keep.xyz",1
|
| 117 |
+
"Thank you for being a Prime member! Your membership is on hold until you reconfirm your card: http://prime-card-reconfirm.top",1
|
| 118 |
+
"YouTube Premium trial ending: your card will be charged $139. To cancel this charge, confirm your card details here: http://yt-premium-cancel.xyz",1
|
| 119 |
+
"Your domain hosting is expiring today. Renew for $499 immediately or lose your website forever: http://domain-hosting-keep.gq",1
|
| 120 |
+
"You have a pending document to review and sign from DocuSign. View it here: http://docusign-view-document-secure.xyz/review",1
|
| 121 |
+
"DocuSign envelope from IT Helpdesk: payroll direct deposit change form requires your signature today. http://docusign-payroll-form.click",1
|
| 122 |
+
"You have 1 missed Adobe Acrobat Sign document. Open with your email password: http://acrobat-sign-secure.top",1
|
| 123 |
+
"Zoom: your meeting recording is available for 24 hours only. Download at http://zoom-rec-download-secure.xyz",1
|
| 124 |
+
"OneDrive: Jessica shared Phase2_Planning.xlsx with you. Sign in to your account to view: http://onedrive-file-secure-view.gq",1
|
| 125 |
+
"Dropbox link: 4 files shared with you. Access with your email credentials: http://dropbox-files-access-secure.top",1
|
| 126 |
+
"Instagram: your account will be deleted for violating our terms. Appeal within 24 hours: http://insta-appeal-verify.top",1
|
| 127 |
+
"Facebook friend request from an unknown user plus a message: is this you in this video? http://fb-video-secure-lookup.xyz",1
|
| 128 |
+
"TikTok creator fund: your account qualifies for payout. Verify your identity and bank details: http://tiktok-creator-payout.click",1
|
| 129 |
+
"Snapchat: your account was accessed from a new device. Confirm it is you with your password: http://snap-verify-device.xyz",1
|
| 130 |
+
"Telegram premium giveaway: 1 year free for active users. Claim: http://telegram-premium-claim.top",1
|
| 131 |
+
"LinkedIn: 3 recruiters viewed your profile. Unlock who they are for a $1 verification fee: http://linkedin-recruiter-unlock.gq",1
|
| 132 |
+
"X (Twitter) account flagged for bot behavior. Verify humanity within 12 hours: http://x-human-verify.click",1
|
| 133 |
+
"WhatsApp: your account will be transferred to another number. Reply STOP or verify here: http://wa-number-transfer.xyz",1
|
| 134 |
+
"SMS: URGENT! Bank of America Fraud Dept: did you attempt a $2,400 Zelle transfer? Reply YES or NO. If NO call 855-555-0199 immediately.",1
|
| 135 |
+
"SMS: MOM its me i lost my phone txt me at this new number 555-0147 i need help asap",1
|
| 136 |
+
"SMS: [PayPal] Your account is limited. Verify at paypa1-secure-verify.com",1
|
| 137 |
+
"SMS: Your USPS package is arriving today but needs a reschedule fee. bit.ly/9sK2lP",1
|
| 138 |
+
"SMS: CONGRATS! You won a $500 Amazon gift card! Claim at amazon-gc-claim.top within 15 min",1
|
| 139 |
+
"SMS: Your Apple ID was used to sign in on a new device. Verify now: appleid-verify-secure.xyz",1
|
| 140 |
+
"SMS: (CVS Pharmacy) Your prescription is ready. Confirm your SSN and DOB to pick up: cvs-rx-confirm.click",1
|
| 141 |
+
"SMS: Your account is suspended. Reactivate: http://account-reactivate-now.xyz/sms",1
|
| 142 |
+
"http://192.168.4.22/paypal/account/verify/login",1
|
| 143 |
+
"http://secure-login-amazon-co-verification.xyz/webscr?cmd=account-update",1
|
| 144 |
+
"https://xn--paypa-2ve.com/login",1
|
| 145 |
+
"http://update-account-details.netfirms.com/paypal.com/secure/verify",1
|
| 146 |
+
"http://appleid.apple.com.verify-account-security.top/unlock",1
|
| 147 |
+
"http://85.214.132.9:8080/bank/login",1
|
| 148 |
+
"https://microsoft-login.verify-outlook.top/session/renew?email=user",1
|
| 149 |
+
"http://secure.chase.com.session-8412.xyz/",1
|
| 150 |
+
"Hi team, quick reminder about sprint planning tomorrow at 10:30am in Conference Room B. The agenda is in the shared doc.",0
|
| 151 |
+
"Can you review my PR when you get a chance? It is a small refactor of the auth middleware. No rush.",0
|
| 152 |
+
"Attaching the updated project timeline. Let me know if the Q3 milestones look off to you.",0
|
| 153 |
+
"Great work on the release today. The retro is moved to Thursday 3pm.",0
|
| 154 |
+
"The staging deployment finished successfully and all smoke tests passed.",0
|
| 155 |
+
"Lunch tomorrow? Torchy's at noon, my treat.",0
|
| 156 |
+
"Here are the notes from today's architecture review. Action items are at the bottom of the doc.",0
|
| 157 |
+
"HR: your benefits enrollment window closes on the 15th. No action needed if you are keeping your current elections.",0
|
| 158 |
+
"Please find the Q2 sales deck attached. Numbers are embargoed until the board meeting.",0
|
| 159 |
+
"The on-call rotation for September is posted. Ping me if you need a swap.",0
|
| 160 |
+
"Server maintenance is scheduled for Saturday 2-4am UTC. Expect brief downtime on the API.",0
|
| 161 |
+
"Welcome to the team! Your onboarding buddy will reach out this afternoon.",0
|
| 162 |
+
"The client approved the mockups. Can you start on the landing page Monday?",0
|
| 163 |
+
"Reminder: expense reports are due by end of week.",0
|
| 164 |
+
"Thanks for jumping on that incident call last night. I started the postmortem doc.",0
|
| 165 |
+
"All-hands moved to 11am Friday to avoid conflicting with the company picnic.",0
|
| 166 |
+
"Your order from Amazon.com has shipped and should arrive Thursday. Track your package at https://www.amazon.com/your-orders",0
|
| 167 |
+
"Your receipt from Blue Bottle Coffee: $8.75. Thanks for your purchase.",0
|
| 168 |
+
"Your DoorDash order from Thai Fresh is on its way. Estimated delivery in 25 minutes.",0
|
| 169 |
+
"Order confirmation: Organic Chemistry textbook, $124.99. Arrives Aug 19.",0
|
| 170 |
+
"Your table at Uchiko is confirmed for Friday at 8pm. Reply to this email to modify.",0
|
| 171 |
+
"Booking confirmation: Hyatt Place Austin Downtown, Sep 2-4, king room, 2 guests.",0
|
| 172 |
+
"Your flight UA 342 SFO to AUS on Aug 14 is confirmed. Check in opens 24 hours before departure.",0
|
| 173 |
+
"Your Verizon bill is ready to view in the app. Amount due: $84.13.",0
|
| 174 |
+
"Ticket confirmation: Austin City Limits Festival, Weekend Two, 3-day pass.",0
|
| 175 |
+
"Your Instacart order from H-E-B has been delivered. Rate your shopper in the app.",0
|
| 176 |
+
"Auto-pay processed: $1,349.00 mortgage payment received. Thank you.",0
|
| 177 |
+
"Your package was delivered to your front door at 2:14pm.",0
|
| 178 |
+
"Return received: running shoes, refund of $118.00 issued to your card.",0
|
| 179 |
+
"Your car service is complete. The invoice is attached.",0
|
| 180 |
+
"Dominos: your pizza is out for delivery! Track it at https://www.dominos.com/track",0
|
| 181 |
+
"Your Audible credit was applied. The Seven Moons of Maali Almeida is now in your library.",0
|
| 182 |
+
"Your weekly digest from The Verge: the biggest tech stories of the week.",0
|
| 183 |
+
"Cloud Security Weekly: Zero Trust patterns and the latest CVEs. Read the issue online.",0
|
| 184 |
+
"The Pragmatic Engineer: Why monorepos came back. This week's issue is ready.",0
|
| 185 |
+
"PyTorch newsletter: new release highlights and community tutorials.",0
|
| 186 |
+
"Morning Brew: What to know about this week's market moves.",0
|
| 187 |
+
"Hacker Newsletter: the top 20 links of the week, curated by hand.",0
|
| 188 |
+
"Your Substack digest: 5 new posts from writers you follow.",0
|
| 189 |
+
"The Atlantic Daily: today's top stories and analysis.",0
|
| 190 |
+
"Axios AM: the stories shaping your day in 5 minutes.",0
|
| 191 |
+
"ACM TechNews: research highlights for this week.",0
|
| 192 |
+
"Your GitHub Explore digest: trending repositories in machine-learning.",0
|
| 193 |
+
"Stack Overflow Blog: the state of CSS in 2026.",0
|
| 194 |
+
"Waypoint from Vice Gaming: reviews and features, weekly.",0
|
| 195 |
+
"Data Is Plural: a weekly newsletter of useful datasets. Issue 224.",0
|
| 196 |
+
"Password reset requested: we received a request to reset your password. If this was not you, you can safely ignore this email; the link expires in 60 minutes.",0
|
| 197 |
+
"You are receiving this email because you signed up for updates. Unsubscribe anytime using the link at the bottom.",0
|
| 198 |
+
"Security alert: new sign-in on Chrome for Windows from Austin, TX. This looks like you, but you can review recent activity in your account settings.",0
|
| 199 |
+
"Two-factor authentication enabled successfully. Keep your recovery codes somewhere safe.",0
|
| 200 |
+
"You have been logged out of all devices as requested. Sign in again to continue.",0
|
| 201 |
+
"Your account email was changed successfully. No action is required.",0
|
| 202 |
+
"Verify your email address to finish creating your account. Welcome aboard!",0
|
| 203 |
+
"Backup codes regenerated. Your old codes no longer work.",0
|
| 204 |
+
"A new recovery email was added to your account. If you did not do this, please contact support.",0
|
| 205 |
+
"Password changed successfully. This is just a confirmation; no action is needed.",0
|
| 206 |
+
"Your trial ends in 7 days. You can keep your workspace settings either way.",0
|
| 207 |
+
"We updated our Privacy Policy. You can read the summary of changes on our site.",0
|
| 208 |
+
"Session alert: your API key expires in 14 days. Rotate it from settings if needed.",0
|
| 209 |
+
"Confirm your email preferences so we only send what you want. Manage subscriptions anytime.",0
|
| 210 |
+
"Invitation: birthday dinner for Sarah, Saturday 7pm at Torchy's Tacos. RSVP yes or no.",0
|
| 211 |
+
"Calendar update: standup moved to 9:15am all week.",0
|
| 212 |
+
"Event reminder: Austin Tech Meetup tomorrow 6:30pm at Capital Factory.",0
|
| 213 |
+
"Your reservation for the company holiday party is confirmed. Plus one allowed.",0
|
| 214 |
+
"Webinar invitation: Zero Trust Architecture in Practice, Thursday 2pm ET. Register at https://zoom.us/webinar",0
|
| 215 |
+
"Reminder: dentist appointment Tuesday at 3:45pm.",0
|
| 216 |
+
"Evite: housewarming party, Oct 12. See you there!",0
|
| 217 |
+
"Poll: which date works best for the team offsite? Vote by Friday.",0
|
| 218 |
+
"Google Calendar: your daily schedule for October 3 is ready.",0
|
| 219 |
+
"You have been invited to collaborate on a Notion page: Roadmap 2027.",0
|
| 220 |
+
"Meetup: PyTorch Austin - attention and transformers talk next Thursday.",0
|
| 221 |
+
"RSVP requested: retirement party for James in the break room Friday 4pm.",0
|
| 222 |
+
"GitHub: you have 3 unread notifications from repositories you watch.",0
|
| 223 |
+
"Your Vercel deployment succeeded: shadowsage-docs is live in production.",0
|
| 224 |
+
"CircleCI: your build passed on branch feature/auth-refactor.",0
|
| 225 |
+
"Dependabot: a new version of transformers is available. Review the pull request.",0
|
| 226 |
+
"Sentry: a new issue was resolved in project web-frontend.",0
|
| 227 |
+
"Docker Hub: your image was pushed successfully. Tags: latest, 1.4.2.",0
|
| 228 |
+
"Stack Overflow: your answer was accepted for how to normalize embeddings in PyTorch.",0
|
| 229 |
+
"npm weekly report: 4 packages updated in your project.",0
|
| 230 |
+
"Security advisory: CVE-2026-12345 affects lodash versions before 4.17.21. Upgrade recommended.",0
|
| 231 |
+
"GitLab: pipeline passed. Coverage increased to 87 percent.",0
|
| 232 |
+
"Your Kaggle notebook finished running. View the output logs online.",0
|
| 233 |
+
"Netlify: deploy preview ready for pull request 128.",0
|
| 234 |
+
"Your monthly bank statement is ready to view in the Chase mobile app.",0
|
| 235 |
+
"Your tax documents for 2025 are available for download.",0
|
| 236 |
+
"Fidelity: your Q2 retirement statement is now online.",0
|
| 237 |
+
"Invoice #INV-1043 from Acme Corp is attached. Please remit payment by the 15th.",0
|
| 238 |
+
"Payment received: $450.00 from Taylor Reed. Thank you.",0
|
| 239 |
+
"Your credit score is updated: 764, up 6 points this month. No action needed.",0
|
| 240 |
+
"Schwab: your scheduled transfer of $500 to savings completed.",0
|
| 241 |
+
"QuickBooks: your bookkeeper left 3 notes on the August transactions.",0
|
| 242 |
+
"Your annual mortgage statement is available in your document vault.",0
|
| 243 |
+
"PayPal receipt: you paid $28.00 to City Bike Share. This is a receipt for a completed purchase.",0
|
| 244 |
+
"Check-in for flight AA 1204 opens in 24 hours. Seat 14C confirmed.",0
|
| 245 |
+
"Your hotel reservation at the Marriott Marquis is confirmed for Dec 10-13.",0
|
| 246 |
+
"TSA PreCheck renewal is available 60 days before expiration. Renew online.",0
|
| 247 |
+
"Your rental car booking: midsize SUV, pickup at AUS airport, unlimited miles.",0
|
| 248 |
+
"Trip itinerary: Austin to Denver, Oct 5-8. Flights and hotel confirmed.",0
|
| 249 |
+
"Airline credit of $125 applied to your account after the delay. No action needed.",0
|
| 250 |
+
"Your Global Entry interview is scheduled for Nov 2 at 10:00am.",0
|
| 251 |
+
"Amtrak: your Texas Eagle reservation for Friday evening is confirmed.",0
|
| 252 |
+
"Course announcement: the CS-501 midterm is moved to March 3rd.",0
|
| 253 |
+
"Your grade for Problem Set 2 has been posted: 94/100.",0
|
| 254 |
+
"Office hours this week: Tuesday 2-4pm, or by appointment.",0
|
| 255 |
+
"Library notice: the book you requested is ready for pickup at the front desk.",0
|
| 256 |
+
"Coursera: you completed Machine Learning Specialization, Course 1. Your certificate is attached.",0
|
| 257 |
+
"Canvas: assignment due Thursday 11:59pm. Submit via the portal.",0
|
| 258 |
+
"Duolingo streak reminder: practice today to keep your 47-day streak alive.",0
|
| 259 |
+
"edX enrollment confirmed: Introduction to Cybersecurity, starts Monday.",0
|
| 260 |
+
"Your Google Workspace storage: you are using 12 GB of 30 GB.",0
|
| 261 |
+
"Nextdoor weekly digest: 14 new posts from your neighborhood.",0
|
| 262 |
+
"Yelp: your table is confirmed for Friday at 8pm.",0
|
| 263 |
+
"Vaccine appointment reminder for Tuesday at 3:45pm at the Austin Public Health clinic.",0
|
| 264 |
+
"Amazon: your subscribe and save order ships next week. Manage items anytime.",0
|
| 265 |
+
"Spotify Wrapped is here: you listened to 412 artists this year.",0
|
| 266 |
+
"Your ancestry DNA results are ready to view in your account.",0
|
| 267 |
+
"Yelp Elite Squad: your application is under review.",0
|
| 268 |
+
"Nextdoor: new recommendation for a plumber in your area.",0
|
| 269 |
+
"The weather tomorrow: 92 and sunny. Details in the app.",0
|
| 270 |
+
"Vote reminder: early voting in your county runs through Nov 3.",0
|
| 271 |
+
"UPS My Choice: delivery scheduled for tomorrow between 12-4pm.",0
|
| 272 |
+
"Your library hold is ready: The Three-Body Problem.",0
|
| 273 |
+
"Khan Academy: your child finished 3 lessons this week. Great progress!",0
|
| 274 |
+
"SMS: Your DoorDash order arrived. Rate your Dasher: ddash.co/r/8sJ2",0
|
| 275 |
+
"SMS: Reminder: dentist tomorrow 3:45pm. Reply C to confirm.",0
|
| 276 |
+
"SMS: Your verification code is 552981. Do not share it with anyone.",0
|
| 277 |
+
"SMS: USPS: your package was delivered at 2:14pm. Track at usps.com",0
|
| 278 |
+
"SMS: Happy birthday from the whole team at Initech!",0
|
| 279 |
+
"SMS: Your Uber is arriving now. License plate 7XYZ892.",0
|
| 280 |
+
"https://www.github.com/shadowsage-ai/shadowsage",0
|
| 281 |
+
"https://docs.python.org/3/library/asyncio.html",0
|
| 282 |
+
"https://www.amazon.com/dp/B08N5WRWNW",0
|
| 283 |
+
"https://arxiv.org/abs/2401.04088",0
|
| 284 |
+
"https://news.ycombinator.com/",0
|
| 285 |
+
"https://pypi.org/project/gradio/",0
|
model/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# ShadowSage AI model package.
|
model/behavior_monitor.py
ADDED
|
@@ -0,0 +1,368 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ShadowSage AI — Behavioral Anomaly Monitor
|
| 2 |
+
# -------------------------------------------------------------
|
| 3 |
+
# Trains an Isolation Forest (scikit-learn) on the synthetic
|
| 4 |
+
# login history in data/login_activity.csv and flags anomalous
|
| 5 |
+
# events (new country at 3am, impossible travel, Tor exit node,
|
| 6 |
+
# never-seen device, ...).
|
| 7 |
+
#
|
| 8 |
+
# Why Isolation Forest? It is an UNSUPERVISED model: it needs no
|
| 9 |
+
# labels, it learns "what normal looks like" by randomly cutting
|
| 10 |
+
# the feature space and measuring how few cuts isolate a point.
|
| 11 |
+
# Points that are easy to isolate are rare/weird -> anomalies.
|
| 12 |
+
# This lets ShadowSage work on real logs without ground truth.
|
| 13 |
+
#
|
| 14 |
+
# Evaluation against the `known_anomaly` column is done ONLY for
|
| 15 |
+
# the demo/viva (we generated the data, so we know the truth).
|
| 16 |
+
# -------------------------------------------------------------
|
| 17 |
+
|
| 18 |
+
from __future__ import annotations
|
| 19 |
+
|
| 20 |
+
import math
|
| 21 |
+
from pathlib import Path
|
| 22 |
+
|
| 23 |
+
import numpy as np
|
| 24 |
+
import pandas as pd
|
| 25 |
+
from sklearn.ensemble import IsolationForest
|
| 26 |
+
|
| 27 |
+
ROOT = Path(__file__).resolve().parent.parent
|
| 28 |
+
DATA_PATH = ROOT / "data" / "login_activity.csv"
|
| 29 |
+
|
| 30 |
+
SEED = 42
|
| 31 |
+
# 7 of ~130 events are anomalies (~5%). contamination tells the
|
| 32 |
+
# IsolationForest what fraction of outliers to expect.
|
| 33 |
+
CONTAMINATION = 0.06
|
| 34 |
+
|
| 35 |
+
# Rough coordinates for the cities that appear in the synthetic
|
| 36 |
+
# dataset (used only for distance / travel-speed features).
|
| 37 |
+
CITY_COORDS = {
|
| 38 |
+
"Austin": (30.27, -97.74),
|
| 39 |
+
"Dallas": (32.78, -96.80),
|
| 40 |
+
"Houston": (29.76, -95.37),
|
| 41 |
+
"San Francisco": (37.77, -122.42),
|
| 42 |
+
"Amsterdam": (52.37, 4.90),
|
| 43 |
+
"Bucharest": (44.43, 26.10),
|
| 44 |
+
"Moscow": (55.76, 37.62),
|
| 45 |
+
"Lagos": (6.52, 3.38),
|
| 46 |
+
"Hanoi": (21.03, 105.85),
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
# A tiny sample of well-known Tor exit-node IP ranges. The real
|
| 50 |
+
# list is thousands of prefixes; a few are enough for the demo
|
| 51 |
+
# and the point is to show HOW such intel plugs into scoring.
|
| 52 |
+
TOR_EXIT_PREFIXES = ("185.220.101.", "185.220.102.", "199.249.230.", "171.25.193.")
|
| 53 |
+
|
| 54 |
+
# Anything faster than a commercial airliner between two
|
| 55 |
+
# consecutive logins is physically impossible -> credential
|
| 56 |
+
# theft / session hijack indicator.
|
| 57 |
+
IMPOSSIBLE_TRAVEL_KMH = 900.0
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def _haversine_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
| 61 |
+
"""Great-circle distance in km between two lat/lon points."""
|
| 62 |
+
r = 6371.0
|
| 63 |
+
p1, p2 = math.radians(lat1), math.radians(lat2)
|
| 64 |
+
dp = p2 - p1
|
| 65 |
+
dl = math.radians(lon2 - lon1)
|
| 66 |
+
a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
|
| 67 |
+
return 2 * r * math.asin(math.sqrt(a))
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def _ip_prefix(ip: str) -> str:
|
| 71 |
+
"""First two octets, e.g. '76.183.45.190' -> '76.183'."""
|
| 72 |
+
parts = ip.split(".")
|
| 73 |
+
return ".".join(parts[:2]) if len(parts) == 4 else ip
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
class BehaviorMonitor:
|
| 77 |
+
"""Loads the login history, engineers features, fits an
|
| 78 |
+
Isolation Forest, and scores every event."""
|
| 79 |
+
|
| 80 |
+
def __init__(self, csv_path: Path | str = DATA_PATH):
|
| 81 |
+
self.csv_path = Path(csv_path)
|
| 82 |
+
self.df = pd.read_csv(self.csv_path, parse_dates=["timestamp"])
|
| 83 |
+
self.df = self.df.sort_values("timestamp").reset_index(drop=True)
|
| 84 |
+
self.home_city = self.df["city"].mode().iat[0] # most common city
|
| 85 |
+
self._engineer()
|
| 86 |
+
self._fit()
|
| 87 |
+
self._label()
|
| 88 |
+
|
| 89 |
+
# ---------------- feature engineering ----------------
|
| 90 |
+
|
| 91 |
+
def _engineer(self) -> None:
|
| 92 |
+
"""Build the numeric feature matrix.
|
| 93 |
+
|
| 94 |
+
Feature intuition (each one makes a specific kind of
|
| 95 |
+
"weirdness" measurable):
|
| 96 |
+
hour_sin/cos — time of day on a circle so 23:00 and
|
| 97 |
+
01:00 are close (both "night").
|
| 98 |
+
is_night — direct 0/1 flag for 00:00-06:00 logins.
|
| 99 |
+
is_weekend — weekday/weekend rhythm.
|
| 100 |
+
country_freq — share of history from that country; a
|
| 101 |
+
brand-new country -> ~0 -> anomalous.
|
| 102 |
+
device_freq — same idea for devices.
|
| 103 |
+
city_freq — same idea for cities.
|
| 104 |
+
dist_from_home— km from the user's home city (log-
|
| 105 |
+
compressed so huge distances dominate).
|
| 106 |
+
travel_speed — km/h implied by the gap since the
|
| 107 |
+
PREVIOUS login; catches impossible
|
| 108 |
+
travel (e.g. SF -> Moscow in 23 min).
|
| 109 |
+
ip_prefix_freq— how often this /16 network appeared.
|
| 110 |
+
"""
|
| 111 |
+
df = self.df
|
| 112 |
+
hours = df["timestamp"].dt.hour + df["timestamp"].dt.minute / 60.0
|
| 113 |
+
|
| 114 |
+
# Frequencies computed over the whole history. The 7
|
| 115 |
+
# injected anomalies are ~5% of rows, so normal behaviour
|
| 116 |
+
# still dominates -> this stays honest & unsupervised.
|
| 117 |
+
country_freq = df["country"].map(df["country"].value_counts(normalize=True))
|
| 118 |
+
device_freq = df["device"].map(df["device"].value_counts(normalize=True))
|
| 119 |
+
city_freq = df["city"].map(df["city"].value_counts(normalize=True))
|
| 120 |
+
prefix_counts = df["ip"].map(_ip_prefix).value_counts(normalize=True)
|
| 121 |
+
prefix_freq = df["ip"].map(lambda ip: prefix_counts.get(_ip_prefix(ip), 0.0))
|
| 122 |
+
|
| 123 |
+
dist_home = []
|
| 124 |
+
for city in df["city"]:
|
| 125 |
+
if city in CITY_COORDS and self.home_city in CITY_COORDS:
|
| 126 |
+
lat1, lon1 = CITY_COORDS[self.home_city]
|
| 127 |
+
lat2, lon2 = CITY_COORDS[city]
|
| 128 |
+
dist_home.append(_haversine_km(lat1, lon1, lat2, lon2))
|
| 129 |
+
else:
|
| 130 |
+
dist_home.append(0.0)
|
| 131 |
+
|
| 132 |
+
speed = [0.0]
|
| 133 |
+
for i in range(1, len(df)):
|
| 134 |
+
gap_h = (df["timestamp"].iat[i] - df["timestamp"].iat[i - 1]).total_seconds() / 3600.0
|
| 135 |
+
c_prev, c_cur = df["city"].iat[i - 1], df["city"].iat[i]
|
| 136 |
+
if gap_h > 0 and c_prev in CITY_COORDS and c_cur in CITY_COORDS:
|
| 137 |
+
lat1, lon1 = CITY_COORDS[c_prev]
|
| 138 |
+
lat2, lon2 = CITY_COORDS[c_cur]
|
| 139 |
+
km = _haversine_km(lat1, lon1, lat2, lon2)
|
| 140 |
+
speed.append(km / gap_h)
|
| 141 |
+
else:
|
| 142 |
+
speed.append(0.0)
|
| 143 |
+
|
| 144 |
+
feats = pd.DataFrame(
|
| 145 |
+
{
|
| 146 |
+
"hour_sin": np.sin(2 * np.pi * hours / 24.0),
|
| 147 |
+
"hour_cos": np.cos(2 * np.pi * hours / 24.0),
|
| 148 |
+
"is_night": (hours < 6).astype(float),
|
| 149 |
+
"is_weekend": (df["timestamp"].dt.dayofweek >= 5).astype(float),
|
| 150 |
+
"country_freq": country_freq.astype(float),
|
| 151 |
+
"device_freq": device_freq.astype(float),
|
| 152 |
+
"city_freq": city_freq.astype(float),
|
| 153 |
+
"dist_home": np.log1p(dist_home),
|
| 154 |
+
"travel_speed": np.log1p(speed),
|
| 155 |
+
"ip_prefix_freq": prefix_freq.astype(float),
|
| 156 |
+
}
|
| 157 |
+
)
|
| 158 |
+
self.features = feats
|
| 159 |
+
self._dist_home_km = dist_home
|
| 160 |
+
self._travel_speed_kmh = speed
|
| 161 |
+
|
| 162 |
+
# ---------------- model ----------------
|
| 163 |
+
|
| 164 |
+
def _fit(self) -> None:
|
| 165 |
+
self.model = IsolationForest(
|
| 166 |
+
n_estimators=200, # more trees -> stabler scores
|
| 167 |
+
contamination=CONTAMINATION,
|
| 168 |
+
random_state=SEED,
|
| 169 |
+
n_jobs=-1,
|
| 170 |
+
)
|
| 171 |
+
self.model.fit(self.features)
|
| 172 |
+
# decision_function: higher = more normal, roughly [-0.5, 0.5]
|
| 173 |
+
self._scores = self.model.decision_function(self.features)
|
| 174 |
+
self._predicted = self.model.predict(self.features) # -1 anomaly, 1 normal
|
| 175 |
+
self._threshold = np.quantile(self._scores, CONTAMINATION)
|
| 176 |
+
|
| 177 |
+
def _label(self) -> None:
|
| 178 |
+
df = self.df
|
| 179 |
+
df["anomaly"] = self._predicted == -1
|
| 180 |
+
df["score"] = self._scores
|
| 181 |
+
# Severity in 0..100 for FLAGGED events: how far below the
|
| 182 |
+
# decision threshold the point sits, rescaled by the worst
|
| 183 |
+
# score in the dataset.
|
| 184 |
+
span = max(1e-9, self._threshold - self._scores.min())
|
| 185 |
+
df["severity"] = np.where(
|
| 186 |
+
df["anomaly"],
|
| 187 |
+
np.clip((self._threshold - self._scores) / span, 0.0, 1.0) * 100.0,
|
| 188 |
+
0.0,
|
| 189 |
+
)
|
| 190 |
+
df["reasons"] = [self._reasons(i) for i in range(len(df))]
|
| 191 |
+
|
| 192 |
+
def _reasons(self, i: int) -> list[str]:
|
| 193 |
+
"""Human-readable explanation of WHY an event was flagged.
|
| 194 |
+
Only meaningful for flagged rows, but computed for all —
|
| 195 |
+
cheap and useful for debugging."""
|
| 196 |
+
row = self.df.iloc[i]
|
| 197 |
+
feat = self.features.iloc[i]
|
| 198 |
+
reasons: list[str] = []
|
| 199 |
+
hour = row["timestamp"].hour
|
| 200 |
+
if hour < 6:
|
| 201 |
+
reasons.append(f"login at {row['timestamp']:%H:%M} in the dead of night")
|
| 202 |
+
if row["country"] not in self.df["country"].value_counts().index[:2]:
|
| 203 |
+
reasons.append(f"first-seen country ({row['country']})")
|
| 204 |
+
if feat["device_freq"] < 0.02:
|
| 205 |
+
reasons.append(f"never-seen device ({row['device']})")
|
| 206 |
+
if row["city"] != self.home_city and feat["city_freq"] < 0.02:
|
| 207 |
+
reasons.append(f"rare city ({row['city']})")
|
| 208 |
+
if self._travel_speed_kmh[i] > IMPOSSIBLE_TRAVEL_KMH:
|
| 209 |
+
prev = self.df.iloc[i - 1] if i > 0 else None
|
| 210 |
+
if prev is not None:
|
| 211 |
+
reasons.append(
|
| 212 |
+
f"impossible travel: {prev['city']} -> {row['city']} in "
|
| 213 |
+
f"{int((row['timestamp'] - prev['timestamp']).total_seconds() / 60)} min "
|
| 214 |
+
f"(~{int(self._travel_speed_kmh[i])} km/h)"
|
| 215 |
+
)
|
| 216 |
+
if str(row["ip"]).startswith(TOR_EXIT_PREFIXES):
|
| 217 |
+
reasons.append("IP belongs to a known Tor exit node")
|
| 218 |
+
if feat["ip_prefix_freq"] < 0.01:
|
| 219 |
+
reasons.append(f"IP range never seen before ({row['ip']})")
|
| 220 |
+
if self._dist_home_km[i] > 5000:
|
| 221 |
+
reasons.append(f"{int(self._dist_home_km[i]):,} km from home")
|
| 222 |
+
if not reasons and bool(row.get("anomaly", False)):
|
| 223 |
+
# Flagged by the model but no single feature screams —
|
| 224 |
+
# the COMBINATION is unusual (that is exactly what
|
| 225 |
+
# isolation trees are good at).
|
| 226 |
+
reasons.append("subtle outlier — unusual combination of hour, city and device")
|
| 227 |
+
return reasons
|
| 228 |
+
|
| 229 |
+
# ---------------- public API ----------------
|
| 230 |
+
|
| 231 |
+
@property
|
| 232 |
+
def n_anomalies(self) -> int:
|
| 233 |
+
return int(self.df["anomaly"].sum())
|
| 234 |
+
|
| 235 |
+
def analyze(self) -> dict:
|
| 236 |
+
"""Full result consumed by the app + utils/scoring.py."""
|
| 237 |
+
df = self.df
|
| 238 |
+
|
| 239 |
+
# ---- overall behavioural risk score (0-100) ----
|
| 240 |
+
# Formula (explainable, three components):
|
| 241 |
+
# risk = 0.5 * peak_severity -> how bad the worst event is
|
| 242 |
+
# + 0.25 * anomaly_density -> anomalies / (15% of history)
|
| 243 |
+
# + 0.25 * recency -> 100 if within 7 days,
|
| 244 |
+
# 50 within 30, else 0
|
| 245 |
+
peak_sev = float(df["severity"].max()) if df["anomaly"].any() else 0.0
|
| 246 |
+
density = min(100.0, self.n_anomalies / (0.15 * len(df)) * 100.0)
|
| 247 |
+
last_anom = df.loc[df["anomaly"], "timestamp"]
|
| 248 |
+
if not last_anom.empty:
|
| 249 |
+
days = (df["timestamp"].iat[-1] - last_anom.iat[-1]).days
|
| 250 |
+
recency = 100.0 if days <= 7 else (50.0 if days <= 30 else 0.0)
|
| 251 |
+
else:
|
| 252 |
+
recency = 0.0
|
| 253 |
+
risk = round(0.5 * peak_sev + 0.25 * density + 0.25 * recency)
|
| 254 |
+
|
| 255 |
+
if risk >= 70:
|
| 256 |
+
verdict, verdict_cls = "Malicious", "bad"
|
| 257 |
+
elif risk >= 35:
|
| 258 |
+
verdict, verdict_cls = "Suspicious", "warn"
|
| 259 |
+
else:
|
| 260 |
+
verdict, verdict_cls = "Safe", "safe"
|
| 261 |
+
|
| 262 |
+
# ---- ground-truth check (demo only: the CSV was
|
| 263 |
+
# generated with a known_anomaly column) ----
|
| 264 |
+
truth = df["known_anomaly"] == 1
|
| 265 |
+
pred = df["anomaly"]
|
| 266 |
+
tp = int((pred & truth).sum())
|
| 267 |
+
fp = int((pred & ~truth).sum())
|
| 268 |
+
fn = int((~pred & truth).sum())
|
| 269 |
+
precision = tp / (tp + fp) if tp + fp else 0.0
|
| 270 |
+
recall = tp / (tp + fn) if tp + fn else 0.0
|
| 271 |
+
|
| 272 |
+
flagged = df[df["anomaly"]]
|
| 273 |
+
if len(flagged):
|
| 274 |
+
detail = (
|
| 275 |
+
f"{self.n_anomalies} anomalous events detected. Most severe: "
|
| 276 |
+
f"{flagged.iloc[-1]['timestamp']:%b %d %H:%M} — "
|
| 277 |
+
+ "; ".join(flagged.iloc[-1]["reasons"][:2])
|
| 278 |
+
)
|
| 279 |
+
else:
|
| 280 |
+
detail = "No anomalous login activity detected."
|
| 281 |
+
|
| 282 |
+
return {
|
| 283 |
+
"ok": True,
|
| 284 |
+
"error": "",
|
| 285 |
+
"risk": int(risk),
|
| 286 |
+
"verdict": verdict,
|
| 287 |
+
"verdict_cls": verdict_cls,
|
| 288 |
+
"detail": detail,
|
| 289 |
+
"n_events": len(df),
|
| 290 |
+
"n_anomalies": self.n_anomalies,
|
| 291 |
+
"peak_severity": round(peak_sev),
|
| 292 |
+
"density": round(density),
|
| 293 |
+
"recency": round(recency),
|
| 294 |
+
"events": self.events_list(),
|
| 295 |
+
"html": self.timeline_html(),
|
| 296 |
+
"ground_truth": {
|
| 297 |
+
"true_positives": tp,
|
| 298 |
+
"false_positives": fp,
|
| 299 |
+
"false_negatives": fn,
|
| 300 |
+
"precision": round(precision, 2),
|
| 301 |
+
"recall": round(recall, 2),
|
| 302 |
+
},
|
| 303 |
+
}
|
| 304 |
+
|
| 305 |
+
def events_list(self) -> list[dict]:
|
| 306 |
+
"""Flat list of dicts (timestamp, city, ..., flagged, reasons)
|
| 307 |
+
for rendering as a table anywhere."""
|
| 308 |
+
out = []
|
| 309 |
+
for _, r in self.df.iterrows():
|
| 310 |
+
out.append(
|
| 311 |
+
{
|
| 312 |
+
"timestamp": r["timestamp"].strftime("%Y-%m-%d %H:%M"),
|
| 313 |
+
"city": r["city"],
|
| 314 |
+
"country": r["country"],
|
| 315 |
+
"device": r["device"],
|
| 316 |
+
"ip": r["ip"],
|
| 317 |
+
"flagged": bool(r["anomaly"]),
|
| 318 |
+
"severity": round(float(r["severity"])),
|
| 319 |
+
"reasons": r["reasons"],
|
| 320 |
+
}
|
| 321 |
+
)
|
| 322 |
+
return out
|
| 323 |
+
|
| 324 |
+
def timeline_html(self) -> str:
|
| 325 |
+
"""Styled HTML timeline (CSS classes are themed in app.py).
|
| 326 |
+
Anomalous rows get the 'ss-anom' class -> red glow."""
|
| 327 |
+
rows = []
|
| 328 |
+
for e in self.events_list():
|
| 329 |
+
cls = "ss-anom" if e["flagged"] else ""
|
| 330 |
+
badge = '<span class="ss-badge ss-badge-bad">ANOMALY</span>' if e["flagged"] else ""
|
| 331 |
+
why = "; ".join(e["reasons"]) if e["reasons"] else "consistent with normal routine"
|
| 332 |
+
rows.append(
|
| 333 |
+
f'<div class="ss-ev {cls}">'
|
| 334 |
+
f'<span class="ss-ev-t">{e["timestamp"]}</span>'
|
| 335 |
+
f'<span class="ss-ev-loc">{e["city"]}, {e["country"]}</span>'
|
| 336 |
+
f'<span class="ss-ev-dev">{e["device"]}</span>'
|
| 337 |
+
f'<span class="ss-ev-ip">{e["ip"]}</span>'
|
| 338 |
+
f'{badge}<div class="ss-ev-why">{why}</div></div>'
|
| 339 |
+
)
|
| 340 |
+
return (
|
| 341 |
+
'<div class="ss-timeline">'
|
| 342 |
+
'<div class="ss-ev ss-ev-head"><span class="ss-ev-t">WHEN</span>'
|
| 343 |
+
'<span class="ss-ev-loc">WHERE</span><span class="ss-ev-dev">DEVICE</span>'
|
| 344 |
+
'<span class="ss-ev-ip">IP</span><span></span><div class="ss-ev-why">WHY</div></div>'
|
| 345 |
+
+ "".join(rows)
|
| 346 |
+
+ "</div>"
|
| 347 |
+
)
|
| 348 |
+
|
| 349 |
+
|
| 350 |
+
_MONITOR: BehaviorMonitor | None = None
|
| 351 |
+
|
| 352 |
+
|
| 353 |
+
def get_monitor() -> BehaviorMonitor:
|
| 354 |
+
"""Singleton — training Isolation Forest takes milliseconds, but
|
| 355 |
+
we still only want to load/fit once per process."""
|
| 356 |
+
global _MONITOR
|
| 357 |
+
if _MONITOR is None:
|
| 358 |
+
_MONITOR = BehaviorMonitor()
|
| 359 |
+
return _MONITOR
|
| 360 |
+
|
| 361 |
+
|
| 362 |
+
if __name__ == "__main__":
|
| 363 |
+
res = get_monitor().analyze()
|
| 364 |
+
print(f"events={res['n_events']} anomalies={res['n_anomalies']} risk={res['risk']} ({res['verdict']})")
|
| 365 |
+
print("ground truth:", res["ground_truth"])
|
| 366 |
+
for e in res["events"]:
|
| 367 |
+
if e["flagged"]:
|
| 368 |
+
print(f" FLAG {e['timestamp']} {e['city']:15s} sev={e['severity']:3d} :: {'; '.join(e['reasons'])}")
|
model/phishing_classifier.py
ADDED
|
@@ -0,0 +1,550 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""ShadowSage AI — Phishing & Scam Analyzer.
|
| 2 |
+
|
| 3 |
+
Two scoring engines, layered:
|
| 4 |
+
|
| 5 |
+
1. ML engine : a DistilBERT text classifier fine-tuned on data/phishing_dataset.csv
|
| 6 |
+
(label 1 = phishing, label 0 = legitimate). Training uses a plain
|
| 7 |
+
PyTorch loop (no Trainer/accelerate dependency) so it runs on any
|
| 8 |
+
CPU. If torch/transformers are unavailable, or the model has not
|
| 9 |
+
been trained yet, the app transparently falls back to engine 2.
|
| 10 |
+
|
| 11 |
+
2. Rule engine : transparent keyword + URL heuristics. Always runs, both as the
|
| 12 |
+
fallback scorer and as the explainability layer (the "why" behind
|
| 13 |
+
a verdict). URL heuristics (IP hosts, suspicious TLDs, brand
|
| 14 |
+
typosquatting, entropy, ...) are computed for any input that
|
| 15 |
+
contains a URL, and always contribute to the final risk score.
|
| 16 |
+
|
| 17 |
+
Final risk blend (see analyze()):
|
| 18 |
+
ML mode : risk = max(ML blend, rule blend). The ML blend is the primary signal:
|
| 19 |
+
100 * (0.65 * P(phishing|text) + 0.35 * url_score/100) [URL present]
|
| 20 |
+
100 * P(phishing|text) [no URL]
|
| 21 |
+
The rule blend (same formula as rule mode) is a guardrail: the bundled
|
| 22 |
+
dataset is tiny, so the net has blind spots — if the transparent
|
| 23 |
+
heuristics are more alarmed than the model, the higher score wins.
|
| 24 |
+
Rule mode: risk = 0.60 * keyword_score + 0.40 * url_score [URL present]
|
| 25 |
+
risk = keyword_score [no URL]
|
| 26 |
+
Verdicts : risk < 35 Safe | 35-69 Suspicious | >= 70 Malicious
|
| 27 |
+
|
| 28 |
+
Retraining on a new dataset: replace data/phishing_dataset.csv (columns: text,label),
|
| 29 |
+
delete model_artifacts/phishing_distilbert/, then run:
|
| 30 |
+
python -m model.phishing_classifier (or let the app retrain in background)
|
| 31 |
+
"""
|
| 32 |
+
|
| 33 |
+
import difflib
|
| 34 |
+
import html
|
| 35 |
+
import math
|
| 36 |
+
import re
|
| 37 |
+
import threading
|
| 38 |
+
import time
|
| 39 |
+
from pathlib import Path
|
| 40 |
+
|
| 41 |
+
ROOT = Path(__file__).resolve().parent.parent
|
| 42 |
+
DATASET_PATH = ROOT / "data" / "phishing_dataset.csv"
|
| 43 |
+
ARTIFACTS_DIR = ROOT / "model_artifacts" / "phishing_distilbert"
|
| 44 |
+
|
| 45 |
+
# ---- Training hyperparameters (small on purpose: CPU-only, live-demo friendly) ----
|
| 46 |
+
MODEL_NAME = "distilbert-base-uncased" # swap for e.g. "prajjwal1/bert-tiny" for an even smaller model
|
| 47 |
+
EPOCHS = 2
|
| 48 |
+
BATCH_SIZE = 8
|
| 49 |
+
MAX_LENGTH = 128 # phishing SMS/emails are short; 128 tokens is plenty and keeps CPU fast
|
| 50 |
+
LR = 2e-5
|
| 51 |
+
VAL_SPLIT = 0.15
|
| 52 |
+
SEED = 42
|
| 53 |
+
|
| 54 |
+
# ML stack is imported lazily so the rest of the app works without torch installed.
|
| 55 |
+
try:
|
| 56 |
+
import torch # noqa: F401
|
| 57 |
+
import transformers
|
| 58 |
+
from transformers import AutoModelForSequenceClassification, AutoTokenizer
|
| 59 |
+
|
| 60 |
+
HAS_ML = True
|
| 61 |
+
except Exception: # pragma: no cover - torch/transformers simply not installed
|
| 62 |
+
HAS_ML = False
|
| 63 |
+
|
| 64 |
+
# Shared status dict surfaced in the UI ("The Sage is awakening...").
|
| 65 |
+
TRAINING_STATUS = {"state": "idle", "detail": ""}
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
# =====================================================================
|
| 69 |
+
# Rule engine — suspicious text patterns
|
| 70 |
+
# Each rule: (regex, human-readable reason, points added to keyword_score).
|
| 71 |
+
# Points are additive and capped at 100. The point values are judgement calls
|
| 72 |
+
# refined on the bundled dataset: direct credential requests and malware-enable
|
| 73 |
+
# instructions score highest because they appear almost exclusively in phishing.
|
| 74 |
+
# =====================================================================
|
| 75 |
+
SUSPICIOUS_PATTERNS = [
|
| 76 |
+
(r"\burgent(ly)?\b", "urgency pressure", 10),
|
| 77 |
+
(r"\bimmediately\b|\bright now\b|\bact now\b|\bat once\b", "urgency pressure", 10),
|
| 78 |
+
(r"within (?:24|48|12) hours|final notice|last warning|before midnight", "artificial deadline", 12),
|
| 79 |
+
(r"account (?:will be|has been|is) (?:suspended|locked|closed|frozen|restricted)", "threat of account loss", 12),
|
| 80 |
+
(r"verify (?:your|the) (?:identity|account|information|billing)", "credential-harvesting language", 15),
|
| 81 |
+
(r"confirm (?:your )?(?:password|identity|account details|card details)", "credential-harvesting language", 18),
|
| 82 |
+
(r"(?:send|enter|provide|reply with|tell us|submit)[^.]{0,40}\b(?:password|pin\b|ssn|social security|seed phrase|recovery phrase|private key)", "direct request for secrets", 25),
|
| 83 |
+
(r"\bseed phrase\b|\brecovery phrase\b|\bprivate key\b|\bwallet address\b", "crypto-wallet targeting", 20),
|
| 84 |
+
(r"gift cards?(?![^.]*\b(sell|selling|bought)\b)", "gift-card scam pattern", 20),
|
| 85 |
+
(r"wire transfer|western union|moneygram", "irreversible payment method", 18),
|
| 86 |
+
(r"(?:double|triple) your (?:bitcoin|btc|crypto|money)", "too-good-to-be-true promise", 20),
|
| 87 |
+
(r"crypto (?:giveaway|investment|profit)|guaranteed \d+ percent returns", "too-good-to-true promise", 15),
|
| 88 |
+
(r"\blottery\b|\bwinner\b|\byou (?:'re|are) (?:a )?winner\b|\bprize\b|claim your (?:prize|reward|winnings)", "too-good-to-be-true reward", 15),
|
| 89 |
+
(r"\birs\b|internal revenue|tax evasion|warrant for your arrest", "government impersonation", 15),
|
| 90 |
+
(r"microsoft (?:certified )?support|your computer (?:is|has been) infected|viruses? detected|call (?:now|immediately)", "fake tech support", 15),
|
| 91 |
+
(r"dear (?:customer|user|account holder|valued customer|member)", "generic greeting (no personalization)", 8),
|
| 92 |
+
(r"unusual (?:sign-?in|activity|login)|your (?:password|credentials) (?:was|were) compromised", "fear of compromise", 8),
|
| 93 |
+
(r"update (?:your )?(?:payment|billing|card)", "payment-info harvesting", 15),
|
| 94 |
+
(r"enable (?:macros|editing|content)", "malware-enabling instruction", 22),
|
| 95 |
+
(r"\bmystery shopper\b|\bwork from home\b.{0,60}\$|earn \$\d+ (?:per|a) (?:week|month)", "job-scam pattern", 12),
|
| 96 |
+
(r"stranded|lost my wallet|i need \$\d+", "stranded-friend/romance scam pattern", 12),
|
| 97 |
+
]
|
| 98 |
+
|
| 99 |
+
# Mild counter-evidence: signals that appear in legitimate mail. Small negative
|
| 100 |
+
# points so a benign newsletter with "unsubscribe" is not pushed above threshold
|
| 101 |
+
# by one accidental keyword match.
|
| 102 |
+
SAFE_PATTERNS = [
|
| 103 |
+
(r"\bunsubscribe\b", "contains unsubscribe link (legit-mail convention)", -6),
|
| 104 |
+
(r"if (?:this was not you|you didn'?t (?:request|make) this|did not request)", "user-initiated action phrasing", -8),
|
| 105 |
+
]
|
| 106 |
+
|
| 107 |
+
# Brand names used for typosquatting detection: brand -> genuine domain.
|
| 108 |
+
BRANDS = {
|
| 109 |
+
"paypal": "paypal.com",
|
| 110 |
+
"amazon": "amazon.com",
|
| 111 |
+
"microsoft": "microsoft.com",
|
| 112 |
+
"apple": "apple.com",
|
| 113 |
+
"netflix": "netflix.com",
|
| 114 |
+
"google": "google.com",
|
| 115 |
+
"facebook": "facebook.com",
|
| 116 |
+
"instagram": "instagram.com",
|
| 117 |
+
"chase": "chase.com",
|
| 118 |
+
"wellsfargo": "wellsfargo.com",
|
| 119 |
+
"coinbase": "coinbase.com",
|
| 120 |
+
"binance": "binance.com",
|
| 121 |
+
"irs": "irs.gov",
|
| 122 |
+
"hsbc": "hsbc.com",
|
| 123 |
+
"barclays": "barclays.com",
|
| 124 |
+
"dhl": "dhl.com",
|
| 125 |
+
"fedex": "fedex.com",
|
| 126 |
+
"ups": "ups.com",
|
| 127 |
+
"usps": "usps.com",
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
# Legitimate infrastructure whose hostnames legitimately *contain* brand-like
|
| 131 |
+
# labels (without this allowlist, "aws.amazon.com" would be flagged as a fake of amazon).
|
| 132 |
+
BRAND_ALLOWLIST_SUFFIXES = (
|
| 133 |
+
"amazonaws.com",
|
| 134 |
+
"googleapis.com",
|
| 135 |
+
"microsoftonline.com",
|
| 136 |
+
"office.com",
|
| 137 |
+
"icloud.com",
|
| 138 |
+
"github.io",
|
| 139 |
+
"vercel.app",
|
| 140 |
+
"netlify.app",
|
| 141 |
+
"zoom.us",
|
| 142 |
+
"dominos.com",
|
| 143 |
+
)
|
| 144 |
+
|
| 145 |
+
# TLDs with a disproportionate share of abuse (cheap/free registrations).
|
| 146 |
+
SUSPICIOUS_TLDS = {
|
| 147 |
+
"xyz", "tk", "top", "gq", "ml", "cf", "buzz", "click", "country",
|
| 148 |
+
"work", "link", "loan", "men", "party", "review", "stream", "download",
|
| 149 |
+
"zip", "mov", "rest", "cyou",
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
# Action words commonly planted in phishing paths to look "official".
|
| 153 |
+
URL_ACTION_WORDS = ("login", "verify", "secure", "account", "update", "confirm", "billing", "signin", "unlock", "webscr")
|
| 154 |
+
|
| 155 |
+
URL_RE = re.compile(r"(?:https?://\S+|www\.[^\s,;|]+|\b[a-z0-9][a-z0-9-]{2,}\.(?:com|net|org|xyz|tk|top|gq|ml|cf|click|buzz|info|io|co|us|uk|ru|zip)\b(?:/\S*)?)", re.IGNORECASE)
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def shannon_entropy(s: str) -> float:
|
| 159 |
+
"""Shannon entropy of a string, in bits/char. Generated (DGA-style) hostnames
|
| 160 |
+
like 'x7fk2q9z' have high entropy; human-chosen hosts like 'mail.google' do not."""
|
| 161 |
+
if not s:
|
| 162 |
+
return 0.0
|
| 163 |
+
freq = {c: s.count(c) for c in set(s)}
|
| 164 |
+
n = len(s)
|
| 165 |
+
return -sum((v / n) * math.log2(v / n) for v in freq.values())
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
def _host_of(url: str):
|
| 169 |
+
"""Extract (host, had_userinfo, used_http) from a raw URL string."""
|
| 170 |
+
raw = url.strip()
|
| 171 |
+
had_userinfo = "@" in raw.split("/", 1)[0] if "://" in raw else "@" in raw
|
| 172 |
+
if "://" not in raw:
|
| 173 |
+
raw = "http://" + raw # bare-domain input like 'paypa1-secure-verify.com'
|
| 174 |
+
m = re.match(r"^[a-z]+://([^/@]+@)?([^/:?#]+)", raw, re.IGNORECASE)
|
| 175 |
+
if not m:
|
| 176 |
+
return None, had_userinfo, not raw.startswith("https")
|
| 177 |
+
host = m.group(2).lower()
|
| 178 |
+
if ":" in host: # strip port
|
| 179 |
+
host = host.split(":", 1)[0]
|
| 180 |
+
return host, bool(m.group(1)), not raw.lower().startswith("https")
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
def analyze_url(url: str) -> dict:
|
| 184 |
+
"""Score a single URL with transparent heuristics. Returns 0-100 plus the
|
| 185 |
+
individual triggers for the explainability panel. Every check below is a
|
| 186 |
+
well-documented phishing indicator (see e.g. APWG/PhishTank reports)."""
|
| 187 |
+
triggers = []
|
| 188 |
+
|
| 189 |
+
def add(reason, points):
|
| 190 |
+
triggers.append({"reason": reason, "snippet": url[:70], "points": points})
|
| 191 |
+
|
| 192 |
+
host, had_userinfo, is_http = _host_of(url)
|
| 193 |
+
if host is None:
|
| 194 |
+
return {"score": 0, "triggers": triggers, "host": ""}
|
| 195 |
+
|
| 196 |
+
# Genuine brand domains (and allowlisted infra) exit early with a clean slate.
|
| 197 |
+
host_is_official = any(host == d or host.endswith("." + d) for d in BRANDS.values())
|
| 198 |
+
host_allowlisted = any(host == s or host.endswith("." + s) for s in BRAND_ALLOWLIST_SUFFIXES)
|
| 199 |
+
|
| 200 |
+
if is_http:
|
| 201 |
+
add("unencrypted http:// connection", 8)
|
| 202 |
+
if had_userinfo:
|
| 203 |
+
# http://paypal.com@evil.io renders evil.io in most browsers
|
| 204 |
+
add("user-info '@' redirect trick in the URL", 15)
|
| 205 |
+
if re.fullmatch(r"(?:\d{1,3}\.){3}\d{1,3}", host):
|
| 206 |
+
add("raw IP address instead of a domain name", 35)
|
| 207 |
+
if host.startswith("xn--") or ".xn--" in host:
|
| 208 |
+
add("punycode (non-ASCII lookalike characters)", 25)
|
| 209 |
+
tld = host.rsplit(".", 1)[-1] if "." in host else ""
|
| 210 |
+
if tld in SUSPICIOUS_TLDS:
|
| 211 |
+
add(f"abuse-prone TLD .{tld}", 18)
|
| 212 |
+
labels = host.split(".") if "." in host else [host]
|
| 213 |
+
if len(labels) > 3:
|
| 214 |
+
add(f"excessive subdomains ({len(labels) - 1} levels)", 12)
|
| 215 |
+
if host.count("-") >= 3:
|
| 216 |
+
add("many hyphens in hostname (brand-word salad)", 8)
|
| 217 |
+
if len(url) > 90:
|
| 218 |
+
add(f"very long URL ({len(url)} chars) - hides the real host", 8)
|
| 219 |
+
if len(host) > 6 and shannon_entropy(host) > 3.4:
|
| 220 |
+
add("high-entropy (machine-generated) hostname", 8)
|
| 221 |
+
if sum(c.isdigit() for c in host) > 3:
|
| 222 |
+
add("digits stuffed into the hostname", 6)
|
| 223 |
+
|
| 224 |
+
if not host_is_official and not host_allowlisted:
|
| 225 |
+
# Brand impersonation checks. Candidates are (1) whole dot-labels and
|
| 226 |
+
# (2) hyphen sub-parts, because phishers hide the brand in hyphen
|
| 227 |
+
# salads: 'paypa1-secure-verify.xyz' -> sub-part 'paypa1' ~ 'paypal'.
|
| 228 |
+
# - a whole dot-label equal to a brand ('paypal' in 'paypal.com.evil.io')
|
| 229 |
+
# is damning on its own;
|
| 230 |
+
# - a near-miss spelling ('paypa1' vs 'paypal') is damning on its own;
|
| 231 |
+
# - an EXACT brand word found only inside a hyphen salad needs
|
| 232 |
+
# corroboration, or 'apple-pie-recipes.com' would false-positive.
|
| 233 |
+
dot_labels = [l for l in labels if len(l) >= 4]
|
| 234 |
+
hyphen_parts = [p for l in labels for p in l.split("-") if len(p) >= 4 and p not in dot_labels]
|
| 235 |
+
corroborated = bool(triggers)
|
| 236 |
+
for brand, official in BRANDS.items():
|
| 237 |
+
if host == official or host.endswith("." + official):
|
| 238 |
+
continue
|
| 239 |
+
if brand in dot_labels:
|
| 240 |
+
add(f"brand name '{brand}' embedded outside its real domain", 30)
|
| 241 |
+
break
|
| 242 |
+
if brand in hyphen_parts and corroborated:
|
| 243 |
+
add(f"brand name '{brand}' embedded outside its real domain", 30)
|
| 244 |
+
break
|
| 245 |
+
for part in dot_labels + hyphen_parts:
|
| 246 |
+
if part != brand and difflib.SequenceMatcher(None, part, brand).ratio() >= 0.72:
|
| 247 |
+
add(f"possible typosquat of '{brand}'", 30)
|
| 248 |
+
break
|
| 249 |
+
else:
|
| 250 |
+
continue
|
| 251 |
+
break
|
| 252 |
+
|
| 253 |
+
# Action words in the path add a little nudge (legit sites use them too, so weight is low)
|
| 254 |
+
path = url.lower()
|
| 255 |
+
hits = sum(1 for w in URL_ACTION_WORDS if w in path)
|
| 256 |
+
if hits >= 2:
|
| 257 |
+
add("credential-themed words stacked in the URL path", 10)
|
| 258 |
+
|
| 259 |
+
score = min(100, sum(t["points"] for t in triggers))
|
| 260 |
+
return {"score": score, "triggers": triggers, "host": host}
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
def _rule_score_text(text: str):
|
| 264 |
+
"""Keyword scoring + the matched snippets used for highlighting."""
|
| 265 |
+
triggers = []
|
| 266 |
+
spans = [] # (start, end) of matches in the original text, for highlight rendering
|
| 267 |
+
for pattern, reason, points in SUSPICIOUS_PATTERNS:
|
| 268 |
+
for m in re.finditer(pattern, text, re.IGNORECASE):
|
| 269 |
+
triggers.append({"reason": reason, "snippet": m.group(0)[:60], "points": points})
|
| 270 |
+
spans.append((m.start(), m.end()))
|
| 271 |
+
break # one hit per pattern category is enough signal; avoids double-counting repeats
|
| 272 |
+
for pattern, reason, points in SAFE_PATTERNS:
|
| 273 |
+
m = re.search(pattern, text, re.IGNORECASE)
|
| 274 |
+
if m:
|
| 275 |
+
triggers.append({"reason": reason, "snippet": m.group(0)[:60], "points": points})
|
| 276 |
+
return max(0, min(100, sum(t["points"] for t in triggers))), triggers, spans
|
| 277 |
+
|
| 278 |
+
|
| 279 |
+
def highlight_spans(text: str, spans):
|
| 280 |
+
"""Escape untrusted text for safe HTML rendering, then wrap the matched
|
| 281 |
+
regions in <mark> tags. Runs on the RAW text first, escapes piecewise — never
|
| 282 |
+
inject unescaped user input into the page (XSS-safe by construction)."""
|
| 283 |
+
if not spans:
|
| 284 |
+
return html.escape(text)
|
| 285 |
+
spans = sorted(set(spans))
|
| 286 |
+
merged = [list(spans[0])]
|
| 287 |
+
for s, e in spans[1:]:
|
| 288 |
+
if s <= merged[-1][1]: # overlapping match: extend
|
| 289 |
+
merged[-1][1] = max(merged[-1][1], e)
|
| 290 |
+
else:
|
| 291 |
+
merged.append([s, e])
|
| 292 |
+
out, pos = [], 0
|
| 293 |
+
for s, e in merged:
|
| 294 |
+
out.append(html.escape(text[pos:s]))
|
| 295 |
+
out.append('<mark class="ss-mark">' + html.escape(text[s:e]) + "</mark>")
|
| 296 |
+
pos = e
|
| 297 |
+
out.append(html.escape(text[pos:]))
|
| 298 |
+
return "".join(out)
|
| 299 |
+
|
| 300 |
+
|
| 301 |
+
# =====================================================================
|
| 302 |
+
# ML engine — DistilBERT fine-tuning + inference (plain PyTorch)
|
| 303 |
+
# =====================================================================
|
| 304 |
+
def train(dataset_csv=None, epochs=EPOCHS, limit=None, quiet=False):
|
| 305 |
+
"""Fine-tune DistilBERT on the phishing dataset and save to ARTIFACTS_DIR.
|
| 306 |
+
|
| 307 |
+
Returns a metrics dict. A manual training loop (rather than HF Trainer) keeps
|
| 308 |
+
the dependency surface small and makes every step explainable in a viva.
|
| 309 |
+
"""
|
| 310 |
+
import pandas as pd
|
| 311 |
+
from sklearn.model_selection import train_test_split
|
| 312 |
+
from torch.utils.data import DataLoader, TensorDataset
|
| 313 |
+
|
| 314 |
+
torch.manual_seed(SEED)
|
| 315 |
+
torch.set_num_threads(2) # leave head-room for the Gradio server on 2-vCPU hosts
|
| 316 |
+
|
| 317 |
+
csv_path = Path(dataset_csv) if dataset_csv else DATASET_PATH
|
| 318 |
+
df = pd.read_csv(csv_path)
|
| 319 |
+
if limit:
|
| 320 |
+
df = df.head(limit)
|
| 321 |
+
train_df, val_df = train_test_split(
|
| 322 |
+
df, test_size=VAL_SPLIT, stratify=df["label"], random_state=SEED
|
| 323 |
+
)
|
| 324 |
+
|
| 325 |
+
tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)
|
| 326 |
+
model = AutoModelForSequenceClassification.from_pretrained(MODEL_NAME, num_labels=2)
|
| 327 |
+
|
| 328 |
+
def encode(frame):
|
| 329 |
+
enc = tokenizer(
|
| 330 |
+
frame["text"].tolist(),
|
| 331 |
+
truncation=True,
|
| 332 |
+
padding="max_length",
|
| 333 |
+
max_length=MAX_LENGTH,
|
| 334 |
+
return_tensors="pt",
|
| 335 |
+
)
|
| 336 |
+
labels = torch.tensor(frame["label"].values, dtype=torch.long)
|
| 337 |
+
return TensorDataset(enc["input_ids"], enc["attention_mask"], labels)
|
| 338 |
+
|
| 339 |
+
train_loader = DataLoader(encode(train_df), batch_size=BATCH_SIZE, shuffle=True)
|
| 340 |
+
|
| 341 |
+
optimizer = torch.optim.AdamW(model.parameters(), lr=LR)
|
| 342 |
+
model.train()
|
| 343 |
+
n_steps = 0
|
| 344 |
+
for epoch in range(epochs):
|
| 345 |
+
running = 0.0
|
| 346 |
+
for input_ids, attention_mask, labels in train_loader:
|
| 347 |
+
optimizer.zero_grad()
|
| 348 |
+
out = model(input_ids=input_ids, attention_mask=attention_mask, labels=labels)
|
| 349 |
+
out.loss.backward()
|
| 350 |
+
optimizer.step()
|
| 351 |
+
running += out.loss.item()
|
| 352 |
+
n_steps += 1
|
| 353 |
+
if not quiet:
|
| 354 |
+
print(f"epoch {epoch + 1}/{epochs} - train loss {running / len(train_loader):.4f}")
|
| 355 |
+
|
| 356 |
+
# Validation accuracy on the held-out split
|
| 357 |
+
model.eval()
|
| 358 |
+
correct = total = 0
|
| 359 |
+
with torch.no_grad():
|
| 360 |
+
enc = tokenizer(
|
| 361 |
+
val_df["text"].tolist(), truncation=True, padding="max_length", max_length=MAX_LENGTH, return_tensors="pt"
|
| 362 |
+
)
|
| 363 |
+
preds = model(**enc).logits.argmax(dim=-1)
|
| 364 |
+
correct = int((preds == torch.tensor(val_df["label"].values)).sum())
|
| 365 |
+
total = len(val_df)
|
| 366 |
+
val_acc = correct / max(1, total)
|
| 367 |
+
|
| 368 |
+
ARTIFACTS_DIR.mkdir(parents=True, exist_ok=True)
|
| 369 |
+
model.save_pretrained(ARTIFACTS_DIR)
|
| 370 |
+
tokenizer.save_pretrained(ARTIFACTS_DIR)
|
| 371 |
+
import json
|
| 372 |
+
|
| 373 |
+
(ARTIFACTS_DIR / "metrics.json").write_text(
|
| 374 |
+
json.dumps(
|
| 375 |
+
{
|
| 376 |
+
"val_accuracy": round(val_acc, 4),
|
| 377 |
+
"train_examples": len(train_df),
|
| 378 |
+
"val_examples": total,
|
| 379 |
+
"epochs": epochs,
|
| 380 |
+
"max_length": MAX_LENGTH,
|
| 381 |
+
"trained_at": time.strftime("%Y-%m-%d %H:%M:%S"),
|
| 382 |
+
},
|
| 383 |
+
indent=2,
|
| 384 |
+
)
|
| 385 |
+
)
|
| 386 |
+
metrics = {"val_accuracy": round(val_acc, 4), "train_examples": len(train_df), "val_examples": total, "steps": n_steps}
|
| 387 |
+
if not quiet:
|
| 388 |
+
print(f"saved model to {ARTIFACTS_DIR} - val accuracy {val_acc:.3f}")
|
| 389 |
+
return metrics
|
| 390 |
+
|
| 391 |
+
|
| 392 |
+
class PhishingClassifier:
|
| 393 |
+
"""Facade used by the app. Mode is 'ml' when a fine-tuned model is loaded,
|
| 394 |
+
otherwise 'rules' (fully functional heuristic fallback)."""
|
| 395 |
+
|
| 396 |
+
def __init__(self):
|
| 397 |
+
self.mode = "rules"
|
| 398 |
+
self.mode_detail = "rule-based fallback"
|
| 399 |
+
self._model = None
|
| 400 |
+
self._tokenizer = None
|
| 401 |
+
self._lock = threading.Lock()
|
| 402 |
+
|
| 403 |
+
def try_load_artifacts(self):
|
| 404 |
+
"""Load fine-tuned weights if they exist on disk."""
|
| 405 |
+
if not HAS_ML or not (ARTIFACTS_DIR / "config.json").exists():
|
| 406 |
+
return False
|
| 407 |
+
try:
|
| 408 |
+
with self._lock:
|
| 409 |
+
self._tokenizer = AutoTokenizer.from_pretrained(ARTIFACTS_DIR)
|
| 410 |
+
self._model = AutoModelForSequenceClassification.from_pretrained(ARTIFACTS_DIR)
|
| 411 |
+
self._model.eval()
|
| 412 |
+
self.mode = "ml"
|
| 413 |
+
self.mode_detail = "DistilBERT fine-tuned on bundled dataset"
|
| 414 |
+
return True
|
| 415 |
+
except Exception as e:
|
| 416 |
+
TRAINING_STATUS["state"] = "failed"
|
| 417 |
+
TRAINING_STATUS["detail"] = f"model load failed: {e}"
|
| 418 |
+
return False
|
| 419 |
+
|
| 420 |
+
def _predict(self, text: str):
|
| 421 |
+
"""P(phishing) via softmax over the 2-class logits."""
|
| 422 |
+
with torch.no_grad():
|
| 423 |
+
enc = self._tokenizer(text, truncation=True, max_length=MAX_LENGTH, return_tensors="pt")
|
| 424 |
+
probs = torch.softmax(self._model(**enc).logits, dim=-1)[0]
|
| 425 |
+
return float(probs[1])
|
| 426 |
+
|
| 427 |
+
def analyze(self, text: str) -> dict:
|
| 428 |
+
"""Full analysis of pasted email/SMS text or a bare URL. Returns the risk
|
| 429 |
+
score, verdict, engine details and per-trigger explainability."""
|
| 430 |
+
text = (text or "").strip()
|
| 431 |
+
if not text:
|
| 432 |
+
return {"ok": False, "error": "The Sage sees nothing — paste a message or URL first."}
|
| 433 |
+
|
| 434 |
+
# Collect every URL in the input; URL heuristics always run.
|
| 435 |
+
urls = [u for u in URL_RE.findall(text)]
|
| 436 |
+
is_bare_url = len(urls) == 1 and text.strip() == urls[0].strip()
|
| 437 |
+
url_triggers = []
|
| 438 |
+
url_score = 0
|
| 439 |
+
for u in urls[:5]: # cap at 5 to bound work on link-stuffed inputs
|
| 440 |
+
r = analyze_url(u)
|
| 441 |
+
url_triggers.extend(r["triggers"])
|
| 442 |
+
url_score = max(url_score, r["score"]) # the worst link defines the risk
|
| 443 |
+
|
| 444 |
+
keyword_score, kw_triggers, spans = _rule_score_text(text)
|
| 445 |
+
# URLs themselves get highlighted too
|
| 446 |
+
for m in URL_RE.finditer(text):
|
| 447 |
+
spans.append((m.start(), m.end()))
|
| 448 |
+
|
| 449 |
+
ml_prob = None
|
| 450 |
+
if self.mode == "ml":
|
| 451 |
+
ml_prob = self._predict(text)
|
| 452 |
+
# Blend: the ML model judges the wording; URL heuristics judge the link.
|
| 453 |
+
# A benign-sounding text with a malicious link is still phishing, so the
|
| 454 |
+
# URL score can pull the number up; strong benign text pulls it down.
|
| 455 |
+
if urls:
|
| 456 |
+
ml_risk = 100 * (0.65 * ml_prob + 0.35 * url_score / 100)
|
| 457 |
+
detail = f"P(phishing)={ml_prob:.2f} (DistilBERT) + URL score {url_score}/100 (blend 65/35)"
|
| 458 |
+
else:
|
| 459 |
+
ml_risk = 100 * ml_prob
|
| 460 |
+
detail = f"P(phishing)={ml_prob:.2f} (DistilBERT, no URL in input)"
|
| 461 |
+
# Guardrail: a few hundred training examples leave the net with blind
|
| 462 |
+
# spots, so if the transparent heuristics are MORE alarmed than the
|
| 463 |
+
# model (an obvious scam pattern the net never saw), the higher score
|
| 464 |
+
# wins. The ML engine never drags a rule-flagged message to "Safe".
|
| 465 |
+
rule_risk = round(0.60 * keyword_score + 0.40 * url_score) if urls else keyword_score
|
| 466 |
+
if rule_risk > ml_risk:
|
| 467 |
+
risk = rule_risk
|
| 468 |
+
detail = (
|
| 469 |
+
f"rule-engine guardrail: keyword {keyword_score}/100 + URL "
|
| 470 |
+
f"{url_score if urls else 0}/100 outweighed P(phishing)={ml_prob:.2f}"
|
| 471 |
+
)
|
| 472 |
+
else:
|
| 473 |
+
risk = round(ml_risk)
|
| 474 |
+
else:
|
| 475 |
+
if urls:
|
| 476 |
+
risk = round(0.60 * keyword_score + 0.40 * url_score)
|
| 477 |
+
detail = f"keyword score {keyword_score}/100 + URL score {url_score}/100 (blend 60/40)"
|
| 478 |
+
else:
|
| 479 |
+
risk = keyword_score
|
| 480 |
+
detail = f"keyword score {keyword_score}/100 (no URL in input)"
|
| 481 |
+
|
| 482 |
+
# Verdict bands: 0-34 Safe, 35-69 Suspicious, 70-100 Malicious.
|
| 483 |
+
if risk >= 70:
|
| 484 |
+
verdict, cls = "Malicious", "bad"
|
| 485 |
+
elif risk >= 35:
|
| 486 |
+
verdict, cls = "Suspicious", "warn"
|
| 487 |
+
else:
|
| 488 |
+
verdict, cls = "Safe", "safe"
|
| 489 |
+
|
| 490 |
+
triggers = kw_triggers + url_triggers
|
| 491 |
+
return {
|
| 492 |
+
"ok": True,
|
| 493 |
+
"risk": risk,
|
| 494 |
+
"verdict": verdict,
|
| 495 |
+
"verdict_cls": cls,
|
| 496 |
+
"mode": self.mode,
|
| 497 |
+
"mode_detail": self.mode_detail,
|
| 498 |
+
"detail": detail,
|
| 499 |
+
"ml_prob": ml_prob,
|
| 500 |
+
"keyword_score": keyword_score,
|
| 501 |
+
"url_score": url_score if urls else None,
|
| 502 |
+
"urls": urls,
|
| 503 |
+
"is_bare_url": is_bare_url,
|
| 504 |
+
"triggers": triggers,
|
| 505 |
+
"highlight_html": highlight_spans(text, spans),
|
| 506 |
+
}
|
| 507 |
+
|
| 508 |
+
|
| 509 |
+
_CLASSIFIER = None
|
| 510 |
+
|
| 511 |
+
|
| 512 |
+
def get_classifier() -> PhishingClassifier:
|
| 513 |
+
"""Singleton accessor: loads saved artifacts if present, else rules mode."""
|
| 514 |
+
global _CLASSIFIER
|
| 515 |
+
if _CLASSIFIER is None:
|
| 516 |
+
_CLASSIFIER = PhishingClassifier()
|
| 517 |
+
_CLASSIFIER.try_load_artifacts()
|
| 518 |
+
return _CLASSIFIER
|
| 519 |
+
|
| 520 |
+
|
| 521 |
+
def start_background_training(epochs=EPOCHS):
|
| 522 |
+
"""Kick off fine-tuning in a daemon thread. Until it finishes the app answers
|
| 523 |
+
with the rule engine, so the demo never blocks."""
|
| 524 |
+
|
| 525 |
+
def _worker():
|
| 526 |
+
TRAINING_STATUS["state"] = "training"
|
| 527 |
+
TRAINING_STATUS["detail"] = "fine-tuning DistilBERT on the bundled dataset..."
|
| 528 |
+
t0 = time.time()
|
| 529 |
+
try:
|
| 530 |
+
train(epochs=epochs)
|
| 531 |
+
get_classifier().try_load_artifacts()
|
| 532 |
+
TRAINING_STATUS["state"] = "ready"
|
| 533 |
+
TRAINING_STATUS["detail"] = f"DistilBERT trained in {time.time() - t0:.0f}s - ML engine online"
|
| 534 |
+
except Exception as e:
|
| 535 |
+
TRAINING_STATUS["state"] = "failed"
|
| 536 |
+
TRAINING_STATUS["detail"] = f"ML training unavailable ({type(e).__name__}: {e}) - rule engine stays active"
|
| 537 |
+
|
| 538 |
+
threading.Thread(target=_worker, daemon=True, name="sage-phish-training").start()
|
| 539 |
+
|
| 540 |
+
|
| 541 |
+
if __name__ == "__main__":
|
| 542 |
+
import argparse
|
| 543 |
+
|
| 544 |
+
p = argparse.ArgumentParser(description="Train the ShadowSage phishing classifier")
|
| 545 |
+
p.add_argument("--epochs", type=int, default=EPOCHS)
|
| 546 |
+
p.add_argument("--limit", type=int, default=None, help="train on the first N rows only (quick test)")
|
| 547 |
+
args = p.parse_args()
|
| 548 |
+
if not HAS_ML:
|
| 549 |
+
raise SystemExit("torch/transformers are not installed - cannot train")
|
| 550 |
+
print(train(epochs=args.epochs, limit=args.limit))
|
requirements.txt
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
gradio>=6.0,<7
|
| 2 |
+
torch>=2.2
|
| 3 |
+
transformers>=4.44
|
| 4 |
+
scikit-learn>=1.5
|
| 5 |
+
pandas>=2.2
|
| 6 |
+
numpy>=1.26
|
| 7 |
+
requests>=2.31
|
utils/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# ShadowSage AI utils package.
|
utils/footprint_scanner.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ShadowSage AI — Digital Footprint Scanner
|
| 2 |
+
# -------------------------------------------------------------
|
| 3 |
+
# Wraps the free HaveIBeenPwned (HIBP) API v3. If the
|
| 4 |
+
# HIBP_API_KEY environment variable is set, we do a LIVE lookup;
|
| 5 |
+
# otherwise we return a deterministic, CLEARLY-LABELED mock
|
| 6 |
+
# response so the demo always works with zero API keys.
|
| 7 |
+
#
|
| 8 |
+
# HIBP v3 notes (viva talking points):
|
| 9 |
+
# - Endpoint: GET /api/v3/breachedaccount/{email}
|
| 10 |
+
# - Requires the `hibp-api-key` header (free tier exists).
|
| 11 |
+
# - HTTP 404 means "no breaches found" (that is a GOOD result),
|
| 12 |
+
# not an error.
|
| 13 |
+
# - Domain lookups use /api/v3/breaches?domain=... which is
|
| 14 |
+
# free and needs no key.
|
| 15 |
+
# -------------------------------------------------------------
|
| 16 |
+
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
|
| 19 |
+
import hashlib
|
| 20 |
+
import html
|
| 21 |
+
import os
|
| 22 |
+
import random
|
| 23 |
+
import re
|
| 24 |
+
|
| 25 |
+
import requests
|
| 26 |
+
|
| 27 |
+
API_BASE = "https://haveibeenpwned.com/api/v3"
|
| 28 |
+
TIMEOUT = 10 # seconds; live demos must never hang
|
| 29 |
+
|
| 30 |
+
# Data classes that make a breach genuinely dangerous (vs. just
|
| 31 |
+
# an email address floating around). Used for the exposure score.
|
| 32 |
+
CRITICAL_CLASSES = {"Passwords", "Password hints", "Password hashes",
|
| 33 |
+
"Salted password hashes", "Auth tokens", "Security questions and answers"}
|
| 34 |
+
SENSITIVE_CLASSES = {"Email addresses", "Usernames", "Names", "Phone numbers",
|
| 35 |
+
"Dates of birth", "Physical addresses", "IP addresses",
|
| 36 |
+
"Geographic locations", "Payment cards"}
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
# ---- demo breach library -------------------------------------
|
| 40 |
+
# Real, well-known incidents with public facts — used when no
|
| 41 |
+
# API key is configured so the UI is never empty.
|
| 42 |
+
DEMO_BREACHES = [
|
| 43 |
+
{"Name": "LinkedIn", "BreachDate": "2021-06-01", "PwnCount": 700_000_000,
|
| 44 |
+
"DataClasses": ["Email addresses", "Passwords", "Phone numbers", "Names"]},
|
| 45 |
+
{"Name": "Adobe", "BreachDate": "2013-10-04", "PwnCount": 152_445_165,
|
| 46 |
+
"DataClasses": ["Email addresses", "Password hints", "Passwords", "Usernames"]},
|
| 47 |
+
{"Name": "Canva", "BreachDate": "2019-05-24", "PwnCount": 137_272_116,
|
| 48 |
+
"DataClasses": ["Email addresses", "Names", "Passwords", "Usernames"]},
|
| 49 |
+
{"Name": "Dropbox", "BreachDate": "2012-07-01", "PwnCount": 68_648_009,
|
| 50 |
+
"DataClasses": ["Email addresses", "Passwords"]},
|
| 51 |
+
{"Name": "MyFitnessPal", "BreachDate": "2018-02-01", "PwnCount": 143_606_147,
|
| 52 |
+
"DataClasses": ["Email addresses", "IP addresses", "Passwords", "Usernames"]},
|
| 53 |
+
{"Name": "Collection #1", "BreachDate": "2019-01-07", "PwnCount": 769_117_041,
|
| 54 |
+
"DataClasses": ["Email addresses", "Passwords"]},
|
| 55 |
+
{"Name": "Ticketmaster", "BreachDate": "2024-05-20", "PwnCount": 560_000_000,
|
| 56 |
+
"DataClasses": ["Email addresses", "Names", "Phone numbers", "Payment cards"]},
|
| 57 |
+
{"Name": "AT&T", "BreachDate": "2024-03-30", "PwnCount": 73_000_000,
|
| 58 |
+
"DataClasses": ["Email addresses", "Names", "Physical addresses", "Dates of birth"]},
|
| 59 |
+
{"Name": "Twitter (X)", "BreachDate": "2022-01-01", "PwnCount": 6_700_000,
|
| 60 |
+
"DataClasses": ["Email addresses", "Names", "Phone numbers", "Usernames"]},
|
| 61 |
+
{"Name": "Zynga", "BreachDate": "2019-09-01", "PwnCount": 173_000_000,
|
| 62 |
+
"DataClasses": ["Email addresses", "Passwords", "Phone numbers", "Usernames"]},
|
| 63 |
+
]
|
| 64 |
+
|
| 65 |
+
EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def _is_email(value: str) -> bool:
|
| 69 |
+
return bool(EMAIL_RE.match(value.strip()))
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def _mock_breaches(target: str) -> list[dict]:
|
| 73 |
+
"""Deterministic demo breaches: the same input always yields
|
| 74 |
+
the same result (seeded by a hash of the target)."""
|
| 75 |
+
seed = int(hashlib.sha256(target.lower().encode()).hexdigest(), 16)
|
| 76 |
+
rng = random.Random(seed)
|
| 77 |
+
count = rng.randint(2, 6)
|
| 78 |
+
return rng.sample(DEMO_BREACHES, count)
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def _live_lookup(target: str, api_key: str) -> list[dict]:
|
| 82 |
+
"""Call HIBP. Returns the breach list. Raises on network/API
|
| 83 |
+
failure so the caller can fall back to demo mode."""
|
| 84 |
+
headers = {"hibp-api-key": api_key, "user-agent": "ShadowSageAI-Hackathon"}
|
| 85 |
+
if _is_email(target):
|
| 86 |
+
url = f"{API_BASE}/breachedaccount/{target}?truncateResponse=false"
|
| 87 |
+
resp = requests.get(url, headers=headers, timeout=TIMEOUT)
|
| 88 |
+
if resp.status_code == 404:
|
| 89 |
+
return [] # account is clean — not an error
|
| 90 |
+
resp.raise_for_status()
|
| 91 |
+
return resp.json()
|
| 92 |
+
# Domain scan: public endpoint, key optional
|
| 93 |
+
url = f"{API_BASE}/breaches?domain={target.strip()}"
|
| 94 |
+
resp = requests.get(url, headers=headers, timeout=TIMEOUT)
|
| 95 |
+
resp.raise_for_status()
|
| 96 |
+
return resp.json()
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def _score_breaches(breaches: list[dict]) -> int:
|
| 100 |
+
"""Exposure score 0-100.
|
| 101 |
+
|
| 102 |
+
Formula (additive, capped):
|
| 103 |
+
+16 per breach containing password/credential data
|
| 104 |
+
+8 per breach that is 'data only' (emails, names, ...)
|
| 105 |
+
+6 extra for breaches in the last 3 years (fresh creds are
|
| 106 |
+
more likely to be replayed in credential-stuffing attacks)
|
| 107 |
+
Capped at 100. A single old email-only leak stays low; any
|
| 108 |
+
recent password leak pushes the score into dangerous territory.
|
| 109 |
+
"""
|
| 110 |
+
score = 0
|
| 111 |
+
for b in breaches:
|
| 112 |
+
classes = set(b.get("DataClasses", []))
|
| 113 |
+
score += 16 if classes & CRITICAL_CLASSES else 8
|
| 114 |
+
if b.get("BreachDate", "") >= "2023-09-01":
|
| 115 |
+
score += 6
|
| 116 |
+
return min(100, score)
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def _breach_html(breaches: list[dict], demo: bool) -> str:
|
| 120 |
+
cards = []
|
| 121 |
+
for b in breaches:
|
| 122 |
+
classes = ", ".join(b.get("DataClasses", []))
|
| 123 |
+
critical = "ss-breach-crit" if set(b.get("DataClasses", [])) & CRITICAL_CLASSES else ""
|
| 124 |
+
cards.append(
|
| 125 |
+
f'<div class="ss-breach {critical}">'
|
| 126 |
+
f'<span class="ss-breach-name">{html.escape(str(b.get("Name", "?")))}</span>'
|
| 127 |
+
f'<span class="ss-breach-date">{html.escape(str(b.get("BreachDate", "?")))}</span>'
|
| 128 |
+
f'<span class="ss-breach-data">{html.escape(classes)}</span></div>'
|
| 129 |
+
)
|
| 130 |
+
banner = (
|
| 131 |
+
'<div class="ss-demo-banner">DEMO DATA — set the HIBP_API_KEY environment '
|
| 132 |
+
"variable for live HaveIBeenPwned results</div>"
|
| 133 |
+
if demo
|
| 134 |
+
else '<div class="ss-live-banner">LIVE DATA — HaveIBeenPwned API v3</div>'
|
| 135 |
+
)
|
| 136 |
+
body = "".join(cards) if cards else '<div class="ss-breach">No breaches found. Your shadow is clean.</div>'
|
| 137 |
+
return banner + f'<div class="ss-breach-list">{body}</div>'
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def scan(target: str) -> dict:
|
| 141 |
+
"""Scan an email address or domain. Never raises: any failure
|
| 142 |
+
degrades to the clearly-labeled demo dataset."""
|
| 143 |
+
target = (target or "").strip()
|
| 144 |
+
if not target:
|
| 145 |
+
return {"ok": False, "error": "Enter an email address or a domain to scan.",
|
| 146 |
+
"risk": 0, "verdict": "—", "verdict_cls": "safe", "breaches": [],
|
| 147 |
+
"detail": "", "html": "", "mode": "demo"}
|
| 148 |
+
|
| 149 |
+
api_key = os.environ.get("HIBP_API_KEY", "").strip()
|
| 150 |
+
breaches: list[dict] = []
|
| 151 |
+
mode = "demo"
|
| 152 |
+
demo_reason = ""
|
| 153 |
+
|
| 154 |
+
if api_key:
|
| 155 |
+
try:
|
| 156 |
+
breaches = _live_lookup(target, api_key)
|
| 157 |
+
mode = "live"
|
| 158 |
+
except Exception as exc: # noqa: BLE001 — any failure must degrade gracefully
|
| 159 |
+
mode, demo_reason = "demo", f"live lookup failed ({exc.__class__.__name__})"
|
| 160 |
+
|
| 161 |
+
if mode == "demo":
|
| 162 |
+
breaches = _mock_breaches(target)
|
| 163 |
+
|
| 164 |
+
risk = _score_breaches(breaches)
|
| 165 |
+
if risk >= 70:
|
| 166 |
+
verdict, verdict_cls = "Exposed", "bad"
|
| 167 |
+
elif risk >= 35:
|
| 168 |
+
verdict, verdict_cls = ("Watch", "warn")
|
| 169 |
+
else:
|
| 170 |
+
verdict, verdict_cls = "Clean", "safe"
|
| 171 |
+
|
| 172 |
+
n = len(breaches)
|
| 173 |
+
has_pw = any(set(b.get("DataClasses", [])) & CRITICAL_CLASSES for b in breaches)
|
| 174 |
+
if n == 0:
|
| 175 |
+
detail = "No breaches found for this target."
|
| 176 |
+
else:
|
| 177 |
+
detail = (
|
| 178 |
+
f"Found in {n} breach{'es' if n != 1 else ''}"
|
| 179 |
+
+ (" — including password data." if has_pw else " — no password data involved.")
|
| 180 |
+
)
|
| 181 |
+
|
| 182 |
+
return {
|
| 183 |
+
"ok": True,
|
| 184 |
+
"error": "",
|
| 185 |
+
"risk": risk,
|
| 186 |
+
"verdict": verdict,
|
| 187 |
+
"verdict_cls": verdict_cls,
|
| 188 |
+
"breaches": breaches,
|
| 189 |
+
"detail": detail + (f" ({demo_reason})" if demo_reason else ""),
|
| 190 |
+
"html": _breach_html(breaches, demo=(mode == "demo")),
|
| 191 |
+
"mode": mode,
|
| 192 |
+
}
|
utils/scoring.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ShadowSage AI — Sage Score & Recommendations
|
| 2 |
+
# -------------------------------------------------------------
|
| 3 |
+
# Combines the three module results into one Sage Score and
|
| 4 |
+
# generates personalized, plain-English advice.
|
| 5 |
+
#
|
| 6 |
+
# SAGE SCORE FORMULA (weighted average of available modules):
|
| 7 |
+
# phishing 0.40 — phishing is the #1 attack vector, and
|
| 8 |
+
# our confidence in that module is highest
|
| 9 |
+
# footprint 0.35 — breached credentials are the fuel for
|
| 10 |
+
# every follow-up account takeover
|
| 11 |
+
# behavior 0.25 — real-time account compromise is the
|
| 12 |
+
# rarest but most urgent signal
|
| 13 |
+
# Weights are renormalized over the modules the user has
|
| 14 |
+
# actually run, so the Sage can speak with partial knowledge.
|
| 15 |
+
# -------------------------------------------------------------
|
| 16 |
+
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
|
| 19 |
+
WEIGHTS = {"phishing": 0.40, "footprint": 0.35, "behavior": 0.25}
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def sage_verdict(phishing: dict | None, footprint: dict | None, behavior: dict | None) -> dict:
|
| 23 |
+
"""phishing/footprint/behavior are the result dicts produced by
|
| 24 |
+
the three modules (None = module not run yet)."""
|
| 25 |
+
modules = {"phishing": phishing, "footprint": footprint, "behavior": behavior}
|
| 26 |
+
available = {k: v for k, v in modules.items() if v and v.get("ok")}
|
| 27 |
+
|
| 28 |
+
if not available:
|
| 29 |
+
return {
|
| 30 |
+
"ok": False,
|
| 31 |
+
"sage_score": None,
|
| 32 |
+
"verdict": "Awaiting your shadows",
|
| 33 |
+
"verdict_cls": "idle",
|
| 34 |
+
"modules": {},
|
| 35 |
+
"recommendations": [],
|
| 36 |
+
"detail": "Run the Phishing Analyzer, Footprint Scanner and Behavior Monitor — "
|
| 37 |
+
"the Sage will then weigh your digital shadow.",
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
# ---- weighted average over available modules ----
|
| 41 |
+
total_w = sum(WEIGHTS[k] for k in available)
|
| 42 |
+
sage_score = round(sum(WEIGHTS[k] * available[k]["risk"] for k in available) / total_w)
|
| 43 |
+
|
| 44 |
+
if sage_score >= 70:
|
| 45 |
+
verdict, verdict_cls = "Your digital shadow is in danger", "bad"
|
| 46 |
+
elif sage_score >= 35:
|
| 47 |
+
verdict, verdict_cls = "Shadows gather at the edges", "warn"
|
| 48 |
+
else:
|
| 49 |
+
verdict, verdict_cls = "Your digital shadow is calm", "safe"
|
| 50 |
+
|
| 51 |
+
module_cards = {}
|
| 52 |
+
labels = {"phishing": "Phishing", "footprint": "Footprint", "behavior": "Behavior"}
|
| 53 |
+
for key, res in available.items():
|
| 54 |
+
module_cards[key] = {
|
| 55 |
+
"label": labels[key],
|
| 56 |
+
"score": res["risk"],
|
| 57 |
+
"verdict": res["verdict"],
|
| 58 |
+
"verdict_cls": res["verdict_cls"],
|
| 59 |
+
"detail": res.get("detail", ""),
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
recs = _recommendations(available)
|
| 63 |
+
|
| 64 |
+
highest = max(available.values(), key=lambda r: r["risk"])
|
| 65 |
+
detail = (
|
| 66 |
+
f"Sage Score {sage_score}/100 from {len(available)} module"
|
| 67 |
+
f"{'s' if len(available) != 1 else ''}. "
|
| 68 |
+
f"Greatest threat right now: {highest['verdict'].lower()} signals in "
|
| 69 |
+
f"{labels[[k for k, v in available.items() if v is highest][0]].lower()}."
|
| 70 |
+
)
|
| 71 |
+
|
| 72 |
+
return {
|
| 73 |
+
"ok": True,
|
| 74 |
+
"sage_score": sage_score,
|
| 75 |
+
"verdict": verdict,
|
| 76 |
+
"verdict_cls": verdict_cls,
|
| 77 |
+
"modules": module_cards,
|
| 78 |
+
"recommendations": recs,
|
| 79 |
+
"detail": detail,
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def _recommendations(available: dict[str, dict]) -> list[str]:
|
| 84 |
+
"""Rule-based, personalized advice. Each rule fires only when
|
| 85 |
+
its module was actually run AND crossed a threshold, so advice
|
| 86 |
+
always reflects the user's own results. Ordered by urgency,
|
| 87 |
+
capped at 3 (short, actionable beats a wall of text)."""
|
| 88 |
+
recs: list[tuple[int, str]] = [] # (priority, text)
|
| 89 |
+
|
| 90 |
+
ph = available.get("phishing")
|
| 91 |
+
if ph:
|
| 92 |
+
if ph["risk"] >= 70:
|
| 93 |
+
recs.append((1, "Do NOT click anything in that message. If it claims to be your bank or a "
|
| 94 |
+
"service you use, open a new tab, go to the official site yourself, and change "
|
| 95 |
+
"that password now."))
|
| 96 |
+
recs.append((2, "Report the message as phishing and delete it — forwarding it only helps "
|
| 97 |
+
"attackers confirm your address is live."))
|
| 98 |
+
elif ph["risk"] >= 35:
|
| 99 |
+
recs.append((2, "Treat that message with suspicion: verify the sender through an official "
|
| 100 |
+
"app or phone number before acting on any link or request."))
|
| 101 |
+
|
| 102 |
+
fp = available.get("footprint")
|
| 103 |
+
if fp:
|
| 104 |
+
if fp["risk"] >= 70:
|
| 105 |
+
recs.append((1, "Your credentials appear in serious breaches. Rotate the password on every "
|
| 106 |
+
"account that shares it, starting with email and banking, and enable two-factor "
|
| 107 |
+
"authentication (an authenticator app, not SMS)."))
|
| 108 |
+
recs.append((3, "Adopt a password manager so every account gets a unique password — breached "
|
| 109 |
+
"credentials only hurt when they are reused."))
|
| 110 |
+
elif fp["risk"] >= 35:
|
| 111 |
+
recs.append((3, "Some of your data has surfaced in breaches. Check which passwords are exposed "
|
| 112 |
+
"and update the oldest ones, prioritizing accounts without 2FA."))
|
| 113 |
+
|
| 114 |
+
bh = available.get("behavior")
|
| 115 |
+
if bh:
|
| 116 |
+
if bh["risk"] >= 70:
|
| 117 |
+
recs.append((1, "Anomalous logins detected on your account — this looks like an active "
|
| 118 |
+
"intrusion. Change your password from a trusted device, sign out of all "
|
| 119 |
+
"sessions, and re-enroll your 2FA."))
|
| 120 |
+
recs.append((2, "Review the flagged events in the Behavior Monitor timeline: unfamiliar "
|
| 121 |
+
"country, device or impossible-travel logins are the attacker's fingerprints."))
|
| 122 |
+
elif bh["risk"] >= 35:
|
| 123 |
+
recs.append((2, "Some login activity looks unusual. Check the flagged rows — if you do not "
|
| 124 |
+
"recognize them, refresh your credentials and review active sessions."))
|
| 125 |
+
|
| 126 |
+
if not recs:
|
| 127 |
+
recs.append((3, "Nothing alarming today. Keep your defenses strong: unique passwords, 2FA "
|
| 128 |
+
"everywhere, and a quarterly run of this Sage to re-check your shadow."))
|
| 129 |
+
recs.append((3, "Stay skeptical of urgency in emails and texts — 'act now or lose access' is "
|
| 130 |
+
"the oldest trick in the grimoire."))
|
| 131 |
+
|
| 132 |
+
recs.sort(key=lambda p: p[0])
|
| 133 |
+
return [text for _, text in recs[:3]]
|