saibafatima commited on
Commit
0318213
·
verified ·
1 Parent(s): 3303764

ShadowSage AI: 4-tab Gradio app — DistilBERT + rule guardrail, HIBP footprint, Isolation Forest

Browse files
.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: Shadowsage Ai
3
- emoji: 🦀
4
- colorFrom: green
5
- colorTo: pink
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
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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&nbsp;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]]