Spaces:
Running on Zero
Running on Zero
Commit ·
77c5607
1
Parent(s): f642077
scamshield slim - code only, weights via model repo
Browse files- .gitignore +3 -0
- app.py +152 -0
- requirements.txt +11 -0
- smishing_detector/adversarial/__init__.py +0 -0
- smishing_detector/adversarial/evasion.py +113 -0
- smishing_detector/app/flask_api.py +192 -0
- smishing_detector/app/streamlit_app.py +147 -0
- smishing_detector/app/templates/index.html +933 -0
- smishing_detector/evaluate.py +116 -0
- smishing_detector/evaluate_custom.py +176 -0
- smishing_detector/evaluate_multilingual.py +139 -0
- smishing_detector/explainability/__init__.py +0 -0
- smishing_detector/explainability/shap_explainer.py +105 -0
- smishing_detector/manual_test.py +133 -0
- smishing_detector/models/__init__.py +0 -0
- smishing_detector/models/dataset.py +93 -0
- smishing_detector/models/model.py +61 -0
- smishing_detector/predictor.py +390 -0
- smishing_detector/requirements.txt +22 -0
- smishing_detector/train.py +200 -0
- smishing_detector/utils/__init__.py +0 -0
- smishing_detector/utils/data_loader.py +403 -0
- smishing_detector/utils/encryption.py +82 -0
- smishing_detector/utils/multilingual_loader.py +206 -0
- smishing_detector/utils/safe_browsing.py +208 -0
.gitignore
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
*.pyc
|
| 3 |
+
.env
|
app.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import sys, os
|
| 2 |
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "smishing_detector"))
|
| 3 |
+
|
| 4 |
+
import json
|
| 5 |
+
import gradio as gr
|
| 6 |
+
from predictor import SmishingPredictor
|
| 7 |
+
from explainability.shap_explainer import SmishingExplainer
|
| 8 |
+
|
| 9 |
+
CHECKPOINT = os.path.join(os.path.dirname(__file__), "smishing_detector", "best_model.pt")
|
| 10 |
+
|
| 11 |
+
print("Loading ScamShield model...")
|
| 12 |
+
predictor = SmishingPredictor(CHECKPOINT)
|
| 13 |
+
print("Loading SHAP explainer...")
|
| 14 |
+
explainer = SmishingExplainer(predictor)
|
| 15 |
+
print("Model loaded. Ready.")
|
| 16 |
+
|
| 17 |
+
def analyze(message):
|
| 18 |
+
if not message or not message.strip():
|
| 19 |
+
return "⚠️ Please enter a message.", None, None
|
| 20 |
+
|
| 21 |
+
result = predictor.predict(message)
|
| 22 |
+
|
| 23 |
+
label = result["label"]
|
| 24 |
+
risk = result["risk_level"]
|
| 25 |
+
conf = result["confidence"]
|
| 26 |
+
lang = result.get("language", "?")
|
| 27 |
+
gc = result.get("green_channel", False)
|
| 28 |
+
gc_reason = result.get("green_reason", "")
|
| 29 |
+
|
| 30 |
+
# --- Verdict ---
|
| 31 |
+
if label == "safe":
|
| 32 |
+
verdict = f"## ✅ Safe\nGreen Channel cleared: {gc_reason}"
|
| 33 |
+
elif label == "spam":
|
| 34 |
+
emoji = "🔴" if risk == "high" else "🟠"
|
| 35 |
+
verdict = f"## {emoji} {label.upper()} — {risk.upper()} RISK"
|
| 36 |
+
else:
|
| 37 |
+
verdict = f"## 🟢 {label.upper()} — LOW RISK"
|
| 38 |
+
|
| 39 |
+
verdict += f"\nConfidence: **{conf:.1%}** | Threshold: {predictor.threshold} | Language: {lang}"
|
| 40 |
+
|
| 41 |
+
if gc:
|
| 42 |
+
verdict += f"\n\n🛡️ Green Channel: {gc_reason}"
|
| 43 |
+
|
| 44 |
+
# --- URL signals ---
|
| 45 |
+
urls = result.get("url_signals", {})
|
| 46 |
+
url_text = ""
|
| 47 |
+
if urls.get("has_url"):
|
| 48 |
+
url_text = "### 🔗 URL Analysis\n"
|
| 49 |
+
flags = []
|
| 50 |
+
if urls.get("has_ip_url"): flags.append("🔴 IP address instead of domain")
|
| 51 |
+
if urls.get("has_shortened_url"): flags.append("🔴 URL shortener — hides real destination")
|
| 52 |
+
if urls.get("suspicious_tld"): flags.append("🟠 Suspicious TLD (.xyz, .tk, .top etc.)")
|
| 53 |
+
if urls.get("has_http") and not urls.get("has_https"):
|
| 54 |
+
flags.append("🟠 HTTP (not encrypted)")
|
| 55 |
+
if urls.get("has_legit_domain"): flags.append("🟢 Legitimate domain detected")
|
| 56 |
+
url_text += "\n".join(f"- {f}" for f in flags) if flags else "- No obvious threats"
|
| 57 |
+
url_text += f"\nLinks found: {urls.get('num_urls', 0)}"
|
| 58 |
+
|
| 59 |
+
# --- Text signals ---
|
| 60 |
+
text = result.get("text_signals", {})
|
| 61 |
+
text_info = "### 📝 Text Signals\n"
|
| 62 |
+
text_info += f"- Chars: {text.get('num_chars', 0)} | Words: {text.get('num_words', 0)}\n"
|
| 63 |
+
text_info += f"- Uppercase: {text.get('pct_upper', 0)*100:.0f}% | Digits: {text.get('pct_digits', 0)*100:.0f}%\n"
|
| 64 |
+
text_info += f"- Urgency keywords: {text.get('urgency_count', 0)}\n"
|
| 65 |
+
text_info += f"- Phone number: {'Yes' if text.get('has_phone') else 'No'} | Currency symbol: {'Yes' if text.get('has_currency') else 'No'}"
|
| 66 |
+
|
| 67 |
+
# --- Internal scores ---
|
| 68 |
+
scores = "### ⚙️ Scores\n"
|
| 69 |
+
scores += f"Neural: {result.get('_neural_score', 0):.3f} | Rule: {result.get('_rule_score', 0):.3f} | Ham: {result.get('_ham_rule_score', 0):.3f}"
|
| 70 |
+
|
| 71 |
+
# --- SHAP explanation ---
|
| 72 |
+
shap_text = "### 🔬 SHAP Explainability\n"
|
| 73 |
+
try:
|
| 74 |
+
explanation = explainer.explain_text(message, num_features=8)
|
| 75 |
+
if explanation["top_spam_words"]:
|
| 76 |
+
shap_text += "*Spam indicators:*\n"
|
| 77 |
+
for word, val in explanation["top_spam_words"]:
|
| 78 |
+
shap_text += f"- `{word}` +{val:.3f}\n"
|
| 79 |
+
if explanation["top_ham_words"]:
|
| 80 |
+
shap_text += "*Ham indicators:*\n"
|
| 81 |
+
for word, val in explanation["top_ham_words"]:
|
| 82 |
+
shap_text += f"- `{word}` {val:.3f}\n"
|
| 83 |
+
if not explanation["top_spam_words"] and not explanation["top_ham_words"]:
|
| 84 |
+
shap_text += "No strong word-level signals found."
|
| 85 |
+
except Exception:
|
| 86 |
+
shap_text += "SHAP explanation unavailable for this message."
|
| 87 |
+
|
| 88 |
+
return verdict, url_text if url_text else "No URLs found", text_info + "\n\n" + scores, shap_text
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
examples = [
|
| 92 |
+
"URGENT: Your SBI account has been suspended. Verify KYC now at http://sbi-kyc-verify.xyz or funds frozen.",
|
| 93 |
+
"HDFC Bank: Rs.25,000 credited to a/c XX4521. Avl bal: Rs.1,42,356. -HDFC Bank",
|
| 94 |
+
"Aapka bijli connection aaj raat 9:30 baje band ho jayega. Turant call karein: 9876543210",
|
| 95 |
+
"Airtel: Your recharge of Rs.239 is successful. Validity 28 days. -Airtel",
|
| 96 |
+
"CBI notice: Aapke khilaf money laundering case darj. Digital arrest warrant. Call 9911000000.",
|
| 97 |
+
"PhonePe: Rs.500 received from Rahul Kumar. UPI Ref: 4893721019.",
|
| 98 |
+
"बधाई हो! आपको KBC लॉटरी में ₹25,00,000 का इनाम मिला। http://kbc-lottery.ml/win",
|
| 99 |
+
"Your OTP for SBI login is 483921. Valid 10 mins. Do NOT share. -SBI",
|
| 100 |
+
"Dear user, your UPI ID expires in 24h. Renew at http://upi-renew.club/verify",
|
| 101 |
+
"Mom, I landed safely. Will call from the hotel in an hour.",
|
| 102 |
+
]
|
| 103 |
+
|
| 104 |
+
with gr.Blocks(title="ScamShield — Smishing Detector") as demo:
|
| 105 |
+
gr.Markdown("# 🛡️ ScamShield — Multilingual Smishing Detector")
|
| 106 |
+
gr.Markdown("Detect SMS phishing (smishing) in English, Hindi, and Hinglish. "
|
| 107 |
+
"Powered by XLM-RoBERTa + hand-crafted URL/text features + Google Safe Browsing.")
|
| 108 |
+
|
| 109 |
+
with gr.Row():
|
| 110 |
+
with gr.Column(scale=2):
|
| 111 |
+
inp = gr.Textbox(label="SMS Message", placeholder="Paste the SMS message here...",
|
| 112 |
+
lines=4, max_lines=10)
|
| 113 |
+
btn = gr.Button("🔍 Analyze", variant="primary", size="lg")
|
| 114 |
+
|
| 115 |
+
with gr.Column(scale=3):
|
| 116 |
+
verdict = gr.Markdown(label="Verdict")
|
| 117 |
+
with gr.Tabs():
|
| 118 |
+
with gr.TabItem("URL Analysis"):
|
| 119 |
+
url_box = gr.Markdown()
|
| 120 |
+
with gr.TabItem("Text Signals"):
|
| 121 |
+
text_box = gr.Markdown()
|
| 122 |
+
with gr.TabItem("SHAP Explainability"):
|
| 123 |
+
shap_box = gr.Markdown()
|
| 124 |
+
|
| 125 |
+
btn.click(fn=analyze, inputs=inp, outputs=[verdict, url_box, text_box, shap_box])
|
| 126 |
+
inp.submit(fn=analyze, inputs=inp, outputs=[verdict, url_box, text_box, shap_box])
|
| 127 |
+
|
| 128 |
+
gr.Examples(examples=examples, inputs=inp, label="Try these examples")
|
| 129 |
+
|
| 130 |
+
gr.Markdown("---\n"
|
| 131 |
+
"**Model:** XLM-RoBERTa (xlm-roberta-base) | **Threshold:** 0.55 | "
|
| 132 |
+
"**Languages:** English · Hindi · Hinglish | "
|
| 133 |
+
"**Features:** 9 URL signals + 8 text signals | **F1:** 0.98")
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def api_predict(message: str) -> str:
|
| 137 |
+
return json.dumps(predictor.predict(message), ensure_ascii=False)
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
api = gr.Interface(
|
| 141 |
+
fn=api_predict,
|
| 142 |
+
inputs=gr.Textbox(label="message"),
|
| 143 |
+
outputs=gr.Textbox(label="result"),
|
| 144 |
+
api_name="predict",
|
| 145 |
+
title="ScamShield API",
|
| 146 |
+
)
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
if __name__ == "__main__":
|
| 150 |
+
demo.queue(default_concurrency_limit=1)
|
| 151 |
+
gr.mount_gradio_app(demo, api, path="/api")
|
| 152 |
+
demo.launch()
|
requirements.txt
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
torch>=2.0.0
|
| 2 |
+
transformers>=4.35.0
|
| 3 |
+
tldextract>=3.4.0
|
| 4 |
+
numpy>=1.24.0
|
| 5 |
+
sentencepiece>=0.1.99
|
| 6 |
+
protobuf>=3.20.0
|
| 7 |
+
requests>=2.31.0
|
| 8 |
+
python-dotenv>=1.0.0
|
| 9 |
+
gradio>=5.0.0
|
| 10 |
+
pandas>=2.0.0
|
| 11 |
+
shap>=0.43.0
|
smishing_detector/adversarial/__init__.py
ADDED
|
File without changes
|
smishing_detector/adversarial/evasion.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import random
|
| 2 |
+
import pandas as pd
|
| 3 |
+
import numpy as np
|
| 4 |
+
from sklearn.metrics import f1_score
|
| 5 |
+
import os
|
| 6 |
+
import sys
|
| 7 |
+
|
| 8 |
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 9 |
+
|
| 10 |
+
from predictor import SmishingPredictor
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
CHARSWAP_MAP = {
|
| 14 |
+
'a': '@', 'e': '3', 'i': '1', 'o': '0',
|
| 15 |
+
's': '$', 't': '7', 'l': '1', 'g': '9'
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def charswap(text: str, rate: float = 0.3) -> str:
|
| 20 |
+
chars = list(text)
|
| 21 |
+
eligible_indices = [i for i, c in enumerate(chars) if c.lower() in CHARSWAP_MAP]
|
| 22 |
+
|
| 23 |
+
if not eligible_indices:
|
| 24 |
+
return text
|
| 25 |
+
|
| 26 |
+
num_swaps = max(1, int(len(eligible_indices) * rate))
|
| 27 |
+
indices_to_swap = random.sample(eligible_indices, min(num_swaps, len(eligible_indices)))
|
| 28 |
+
|
| 29 |
+
for i in indices_to_swap:
|
| 30 |
+
chars[i] = CHARSWAP_MAP[chars[i].lower()]
|
| 31 |
+
|
| 32 |
+
return ''.join(chars)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def eda_attack(text: str, rate: float = 0.2) -> str:
|
| 36 |
+
words = text.split()
|
| 37 |
+
|
| 38 |
+
if len(words) < 2:
|
| 39 |
+
return text
|
| 40 |
+
|
| 41 |
+
num_to_delete = max(1, int(len(words) * rate))
|
| 42 |
+
indices_to_delete = random.sample(range(len(words)), num_to_delete)
|
| 43 |
+
|
| 44 |
+
words = [w for i, w in enumerate(words) if i not in indices_to_delete]
|
| 45 |
+
|
| 46 |
+
if len(words) >= 2:
|
| 47 |
+
for i in range(0, len(words) - 1, 2):
|
| 48 |
+
if random.random() < 0.5:
|
| 49 |
+
words[i], words[i + 1] = words[i + 1], words[i]
|
| 50 |
+
|
| 51 |
+
return ' '.join(words)
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def spacing_attack(text: str) -> str:
|
| 55 |
+
urgency_words = {'urgent', 'winner', 'free', 'prize', 'cash', 'claim', 'verify', 'act now'}
|
| 56 |
+
|
| 57 |
+
words = text.split()
|
| 58 |
+
modified = []
|
| 59 |
+
|
| 60 |
+
for word in words:
|
| 61 |
+
if word.lower() in urgency_words:
|
| 62 |
+
modified.append(' '.join(list(word)))
|
| 63 |
+
else:
|
| 64 |
+
modified.append(word)
|
| 65 |
+
|
| 66 |
+
return ' '.join(modified)
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def hybrid_attack(text: str) -> str:
|
| 70 |
+
text = charswap(text, rate=0.3)
|
| 71 |
+
text = eda_attack(text, rate=0.2)
|
| 72 |
+
text = spacing_attack(text)
|
| 73 |
+
return text
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def run_adversarial_evaluation(
|
| 77 |
+
predictor: SmishingPredictor,
|
| 78 |
+
test_df: pd.DataFrame,
|
| 79 |
+
spam_only: bool = True
|
| 80 |
+
) -> pd.DataFrame:
|
| 81 |
+
if spam_only:
|
| 82 |
+
messages = test_df[test_df['label'] == 1]['message'].tolist()
|
| 83 |
+
true_labels = [1] * len(messages)
|
| 84 |
+
else:
|
| 85 |
+
messages = test_df['message'].tolist()
|
| 86 |
+
true_labels = test_df['label'].tolist()
|
| 87 |
+
|
| 88 |
+
results = []
|
| 89 |
+
|
| 90 |
+
for attack_name, attack_fn in [('CharSwap', charswap), ('EDA', eda_attack), ('Spacing', spacing_attack), ('Hybrid', hybrid_attack)]:
|
| 91 |
+
original_preds = []
|
| 92 |
+
attacked_preds = []
|
| 93 |
+
|
| 94 |
+
for msg in messages:
|
| 95 |
+
original_result = predictor.predict(msg)
|
| 96 |
+
original_preds.append(1 if original_result['label'] == 'spam' else 0)
|
| 97 |
+
|
| 98 |
+
attacked_msg = attack_fn(msg)
|
| 99 |
+
attacked_result = predictor.predict(attacked_msg)
|
| 100 |
+
attacked_preds.append(1 if attacked_result['label'] == 'spam' else 0)
|
| 101 |
+
|
| 102 |
+
original_f1 = f1_score(true_labels, original_preds, zero_division=0)
|
| 103 |
+
attacked_f1 = f1_score(true_labels, attacked_preds, zero_division=0)
|
| 104 |
+
accuracy_drop = original_f1 - attacked_f1
|
| 105 |
+
|
| 106 |
+
results.append({
|
| 107 |
+
'attack_type': attack_name,
|
| 108 |
+
'original_f1': original_f1,
|
| 109 |
+
'attacked_f1': attacked_f1,
|
| 110 |
+
'accuracy_drop': accuracy_drop
|
| 111 |
+
})
|
| 112 |
+
|
| 113 |
+
return pd.DataFrame(results)
|
smishing_detector/app/flask_api.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import sys
|
| 3 |
+
from flask import Flask, request, jsonify, render_template
|
| 4 |
+
from flask_cors import CORS
|
| 5 |
+
|
| 6 |
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 7 |
+
|
| 8 |
+
from predictor import SmishingPredictor
|
| 9 |
+
from explainability.shap_explainer import SmishingExplainer
|
| 10 |
+
from utils.safe_browsing import check_domain_status
|
| 11 |
+
from utils.encryption import decrypt_message, get_key_hex
|
| 12 |
+
|
| 13 |
+
app = Flask(__name__, template_folder=os.path.join(os.path.dirname(__file__), 'templates'))
|
| 14 |
+
CORS(app)
|
| 15 |
+
|
| 16 |
+
# Model checkpoint path - relative to smishing_detector root
|
| 17 |
+
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 18 |
+
CHECKPOINT_PATH = os.path.join(PROJECT_ROOT, "best_model.pt")
|
| 19 |
+
|
| 20 |
+
predictor = None
|
| 21 |
+
explainer = None
|
| 22 |
+
|
| 23 |
+
if os.path.exists(CHECKPOINT_PATH):
|
| 24 |
+
try:
|
| 25 |
+
predictor = SmishingPredictor(CHECKPOINT_PATH)
|
| 26 |
+
explainer = SmishingExplainer(predictor)
|
| 27 |
+
except Exception as e:
|
| 28 |
+
print(f"Warning: Failed to load model: {e}")
|
| 29 |
+
predictor = None
|
| 30 |
+
explainer = None
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
@app.route('/')
|
| 34 |
+
def index():
|
| 35 |
+
return render_template('index.html')
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
@app.route('/check-domain', methods=['POST'])
|
| 40 |
+
def check_domain():
|
| 41 |
+
"""Check if a domain is legitimate using Google Safe Browsing API."""
|
| 42 |
+
if not request.get_json() or 'domain' not in request.get_json():
|
| 43 |
+
return jsonify({"error": "Missing 'domain' field"}), 400
|
| 44 |
+
|
| 45 |
+
domain = request.get_json()['domain'].lower().strip()
|
| 46 |
+
|
| 47 |
+
if not domain:
|
| 48 |
+
return jsonify({"error": "Domain cannot be empty"}), 400
|
| 49 |
+
|
| 50 |
+
try:
|
| 51 |
+
status = check_domain_status(domain)
|
| 52 |
+
return jsonify({
|
| 53 |
+
"domain": domain,
|
| 54 |
+
"is_legitimate": status == "known_safe",
|
| 55 |
+
"is_malicious": status == "known_malicious",
|
| 56 |
+
"status": status
|
| 57 |
+
})
|
| 58 |
+
except Exception as e:
|
| 59 |
+
return jsonify({
|
| 60 |
+
"error": f"Failed to check domain: {str(e)}"
|
| 61 |
+
}), 500
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
@app.route('/health', methods=['GET'])
|
| 65 |
+
def health():
|
| 66 |
+
if predictor is None:
|
| 67 |
+
return jsonify({
|
| 68 |
+
"status": "error",
|
| 69 |
+
"model": "SmishingDetector (XLM-RoBERTa + URL features)",
|
| 70 |
+
"error": "Model checkpoint not found. Run train.py first."
|
| 71 |
+
}), 503
|
| 72 |
+
|
| 73 |
+
return jsonify({
|
| 74 |
+
"status": "ok",
|
| 75 |
+
"model": "SmishingDetector (XLM-RoBERTa · EN/HI/Hinglish)",
|
| 76 |
+
"encryption": "AES-256-CBC"
|
| 77 |
+
})
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
@app.route('/predict', methods=['POST'])
|
| 81 |
+
def predict():
|
| 82 |
+
if predictor is None:
|
| 83 |
+
return jsonify({
|
| 84 |
+
"error": "Model checkpoint not found. Run train.py first."
|
| 85 |
+
}), 503
|
| 86 |
+
|
| 87 |
+
data = request.get_json()
|
| 88 |
+
if not data or 'message' not in data:
|
| 89 |
+
return jsonify({"error": "Missing 'message' field"}), 400
|
| 90 |
+
|
| 91 |
+
message = data['message']
|
| 92 |
+
result = predictor.predict(message)
|
| 93 |
+
|
| 94 |
+
result['message'] = message
|
| 95 |
+
|
| 96 |
+
return jsonify(result)
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
@app.route('/batch_predict', methods=['POST'])
|
| 100 |
+
def batch_predict():
|
| 101 |
+
if predictor is None:
|
| 102 |
+
return jsonify({
|
| 103 |
+
"error": "Model checkpoint not found. Run train.py first."
|
| 104 |
+
}), 503
|
| 105 |
+
|
| 106 |
+
data = request.get_json()
|
| 107 |
+
if not data or 'messages' not in data:
|
| 108 |
+
return jsonify({"error": "Missing 'messages' field"}), 400
|
| 109 |
+
|
| 110 |
+
messages = data['messages']
|
| 111 |
+
results = [predictor.predict(msg) for msg in messages]
|
| 112 |
+
|
| 113 |
+
return jsonify({
|
| 114 |
+
"results": results,
|
| 115 |
+
"count": len(results)
|
| 116 |
+
})
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
@app.route('/predict_secure', methods=['POST'])
|
| 120 |
+
def predict_secure():
|
| 121 |
+
"""
|
| 122 |
+
Encrypted prediction endpoint for mobile app.
|
| 123 |
+
Expects: { "message": "<AES-256-CBC base64 ciphertext>",
|
| 124 |
+
"address": "<sender>" (optional) }
|
| 125 |
+
Returns same shape as /predict but with encryption_used: true.
|
| 126 |
+
"""
|
| 127 |
+
if predictor is None:
|
| 128 |
+
return jsonify({"error": "Model not loaded."}), 503
|
| 129 |
+
|
| 130 |
+
data = request.get_json()
|
| 131 |
+
if not data or 'message' not in data:
|
| 132 |
+
return jsonify({"error": "Missing 'message' field"}), 400
|
| 133 |
+
|
| 134 |
+
try:
|
| 135 |
+
plaintext = decrypt_message(data['message'])
|
| 136 |
+
except Exception as e:
|
| 137 |
+
return jsonify({"error": f"Decryption failed: {e}"}), 400
|
| 138 |
+
|
| 139 |
+
result = predictor.predict(plaintext)
|
| 140 |
+
result['message'] = plaintext
|
| 141 |
+
result['encryption_used'] = True
|
| 142 |
+
return jsonify(result)
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
@app.route('/api/encryption-key', methods=['GET'])
|
| 146 |
+
def encryption_key():
|
| 147 |
+
"""
|
| 148 |
+
Returns the AES-256 key for the mobile app.
|
| 149 |
+
Protected by X-App-Token header (set APP_TOKEN in .env).
|
| 150 |
+
"""
|
| 151 |
+
expected = os.getenv('APP_TOKEN', 'scamshield-dev-token')
|
| 152 |
+
if request.headers.get('X-App-Token') != expected:
|
| 153 |
+
return jsonify({'error': 'Unauthorized'}), 401
|
| 154 |
+
return jsonify({'key': get_key_hex(), 'algorithm': 'AES-256-CBC'})
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
@app.route('/explain', methods=['POST'])
|
| 158 |
+
def explain():
|
| 159 |
+
if predictor is None or explainer is None:
|
| 160 |
+
return jsonify({
|
| 161 |
+
"error": "Model checkpoint not found. Run train.py first."
|
| 162 |
+
}), 503
|
| 163 |
+
|
| 164 |
+
data = request.get_json()
|
| 165 |
+
if not data or 'message' not in data:
|
| 166 |
+
return jsonify({"error": "Missing 'message' field"}), 400
|
| 167 |
+
|
| 168 |
+
message = data['message']
|
| 169 |
+
|
| 170 |
+
result = predictor.predict(message)
|
| 171 |
+
|
| 172 |
+
try:
|
| 173 |
+
explanation = explainer.explain_text(message, num_features=10)
|
| 174 |
+
except Exception as e:
|
| 175 |
+
return jsonify({
|
| 176 |
+
"error": f"SHAP explanation failed: {str(e)}"
|
| 177 |
+
}), 500
|
| 178 |
+
|
| 179 |
+
return jsonify({
|
| 180 |
+
"label": result['label'],
|
| 181 |
+
"confidence": result['confidence'],
|
| 182 |
+
"top_spam_words": explanation['top_spam_words'],
|
| 183 |
+
"top_ham_words": explanation['top_ham_words'],
|
| 184 |
+
"feature_importances": explanation['feature_importances']
|
| 185 |
+
})
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
if __name__ == '__main__':
|
| 189 |
+
host = os.getenv('SCAMSHIELD_HOST', '0.0.0.0')
|
| 190 |
+
port = int(os.getenv('SCAMSHIELD_PORT', '5000'))
|
| 191 |
+
debug = os.getenv('SCAMSHIELD_DEBUG', 'true').lower() in ('1', 'true', 'yes')
|
| 192 |
+
app.run(host=host, port=port, debug=debug)
|
smishing_detector/app/streamlit_app.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import sys
|
| 3 |
+
import streamlit as st
|
| 4 |
+
import matplotlib.pyplot as plt
|
| 5 |
+
|
| 6 |
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 7 |
+
|
| 8 |
+
from predictor import SmishingPredictor
|
| 9 |
+
from explainability.shap_explainer import SmishingExplainer
|
| 10 |
+
|
| 11 |
+
CHECKPOINT_PATH = "best_model.pt"
|
| 12 |
+
|
| 13 |
+
st.set_page_config(page_title="Smishing Detector", page_icon="🔍")
|
| 14 |
+
|
| 15 |
+
st.markdown("""
|
| 16 |
+
<style>
|
| 17 |
+
.main-header {
|
| 18 |
+
font-size: 2.5rem;
|
| 19 |
+
font-weight: bold;
|
| 20 |
+
margin-bottom: 0;
|
| 21 |
+
}
|
| 22 |
+
.sub-header {
|
| 23 |
+
font-size: 1rem;
|
| 24 |
+
color: #666;
|
| 25 |
+
margin-top: 0;
|
| 26 |
+
}
|
| 27 |
+
.footer {
|
| 28 |
+
font-size: 0.8rem;
|
| 29 |
+
color: #888;
|
| 30 |
+
text-align: center;
|
| 31 |
+
margin-top: 2rem;
|
| 32 |
+
}
|
| 33 |
+
</style>
|
| 34 |
+
""", unsafe_allow_html=True)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
@st.cache_resource
|
| 38 |
+
def load_predictor():
|
| 39 |
+
if not os.path.exists(CHECKPOINT_PATH):
|
| 40 |
+
return None
|
| 41 |
+
return SmishingPredictor(CHECKPOINT_PATH)
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def load_explainer(_predictor):
|
| 45 |
+
if _predictor is None:
|
| 46 |
+
return None
|
| 47 |
+
return SmishingExplainer(_predictor)
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
predictor = load_predictor()
|
| 51 |
+
explainer = load_explainer(predictor) if predictor else None
|
| 52 |
+
|
| 53 |
+
st.markdown('<p class="main-header">🔍 Smishing Detector</p>', unsafe_allow_html=True)
|
| 54 |
+
st.markdown('<p class="sub-header">Powered by XLM-RoBERTa + URL Feature Fusion · English, Hindi & Hinglish Support</p>', unsafe_allow_html=True)
|
| 55 |
+
|
| 56 |
+
if predictor is None:
|
| 57 |
+
st.warning(f"Model checkpoint not found. Please run `python train.py` first to train the model.")
|
| 58 |
+
st.stop()
|
| 59 |
+
|
| 60 |
+
spam_sample = "URGENT: Your SBI account is SUSPENDED. Verify now at http://bit.ly/sbi-verify or lose access."
|
| 61 |
+
ham_sample = "Hey! Are we still on for dinner tonight at 7? Let me know if plans change."
|
| 62 |
+
|
| 63 |
+
col1, col2 = st.columns([2, 1])
|
| 64 |
+
|
| 65 |
+
with col1:
|
| 66 |
+
def update_sample():
|
| 67 |
+
sample = st.session_state.sample_selector
|
| 68 |
+
if sample == "Spam Sample":
|
| 69 |
+
st.session_state.message_input = spam_sample
|
| 70 |
+
elif sample == "Ham Sample":
|
| 71 |
+
st.session_state.message_input = ham_sample
|
| 72 |
+
|
| 73 |
+
message_input = st.text_area("Enter SMS message to analyse:", key="message_input", height=150)
|
| 74 |
+
btn_col1, btn_col2 = st.columns([1, 1])
|
| 75 |
+
with btn_col1:
|
| 76 |
+
analyse_btn = st.button("Analyse", type="primary", use_container_width=True)
|
| 77 |
+
with btn_col2:
|
| 78 |
+
st.selectbox("Load sample:", ["", "Spam Sample", "Ham Sample"], index=0, key="sample_selector", on_change=update_sample)
|
| 79 |
+
|
| 80 |
+
with col2:
|
| 81 |
+
st.write("**Sample Messages:**")
|
| 82 |
+
st.code(spam_sample[:60] + "...")
|
| 83 |
+
st.code(ham_sample[:60] + "...")
|
| 84 |
+
|
| 85 |
+
if analyse_btn and message_input:
|
| 86 |
+
result = predictor.predict(message_input)
|
| 87 |
+
|
| 88 |
+
st.divider()
|
| 89 |
+
|
| 90 |
+
if result['label'] == 'spam':
|
| 91 |
+
st.markdown("## 🚨 SPAM DETECTED")
|
| 92 |
+
else:
|
| 93 |
+
st.markdown("## ✅ HAM (Legitimate)")
|
| 94 |
+
|
| 95 |
+
m1, m2, m3 = st.columns(3)
|
| 96 |
+
m1.metric("Prediction", result['label'].upper())
|
| 97 |
+
m2.metric("Confidence", f"{result['confidence']*100:.1f}%")
|
| 98 |
+
m3.metric("Risk Level", result['risk_level'].upper())
|
| 99 |
+
|
| 100 |
+
# Language detection display
|
| 101 |
+
from utils.multilingual_loader import detect_language
|
| 102 |
+
|
| 103 |
+
detected_lang = detect_language(message_input)
|
| 104 |
+
lang_display = {
|
| 105 |
+
'english': '🇬🇧 English',
|
| 106 |
+
'hindi': '🇮🇳 Hindi (हिंदी)',
|
| 107 |
+
'hinglish': '🇮🇳 Hinglish'
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
st.info(f"**Detected Language:** {lang_display.get(detected_lang, '🌐 Unknown')}")
|
| 111 |
+
|
| 112 |
+
fig, ax = plt.subplots(figsize=(8, 2))
|
| 113 |
+
prob = result['confidence']
|
| 114 |
+
ax.barh(['Message'], [prob], color='#d62728' if result['label'] == 'spam' else '#2ca02c')
|
| 115 |
+
ax.barh(['Message'], [1-prob], left=[prob], color='#2ca02c' if result['label'] == 'spam' else '#d62728')
|
| 116 |
+
ax.axvline(x=0.5, color='black', linestyle='--', linewidth=1, label='Threshold')
|
| 117 |
+
ax.set_xlim(0, 1)
|
| 118 |
+
ax.set_yticks([])
|
| 119 |
+
ax.set_xlabel('Probability')
|
| 120 |
+
st.pyplot(fig)
|
| 121 |
+
|
| 122 |
+
url_cols, text_cols = st.columns(2)
|
| 123 |
+
|
| 124 |
+
with url_cols:
|
| 125 |
+
st.write("**URL Risk Signals:**")
|
| 126 |
+
url_signals = result['url_signals']
|
| 127 |
+
for key, value in url_signals.items():
|
| 128 |
+
flag = " ⚠️" if value and key in ['has_url', 'suspicious_tld', 'has_ip_url', 'has_shortened_url', 'has_http'] else ""
|
| 129 |
+
st.write(f"`{key}`: **{value}**{flag}")
|
| 130 |
+
|
| 131 |
+
with text_cols:
|
| 132 |
+
st.write("**Text Risk Signals:**")
|
| 133 |
+
text_signals = result['text_signals']
|
| 134 |
+
for key, value in text_signals.items():
|
| 135 |
+
st.write(f"`{key}`: **{value}**")
|
| 136 |
+
|
| 137 |
+
if explainer:
|
| 138 |
+
try:
|
| 139 |
+
shap_fig = explainer.plot_explanation(message_input)
|
| 140 |
+
st.pyplot(shap_fig)
|
| 141 |
+
except Exception as e:
|
| 142 |
+
st.caption(f"SHAP explanation unavailable: {str(e)}")
|
| 143 |
+
|
| 144 |
+
with st.expander("View Raw Analysis"):
|
| 145 |
+
st.json(result)
|
| 146 |
+
|
| 147 |
+
st.markdown('<p class="footer">ScamShield · Model: XLM-RoBERTa + URL Feature Fusion</p>', unsafe_allow_html=True)
|
smishing_detector/app/templates/index.html
ADDED
|
@@ -0,0 +1,933 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en" data-theme="dark">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/>
|
| 5 |
+
<title>ScamShield AI — Threat Scanner</title>
|
| 6 |
+
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:ital,wght@0,300;0,400;0,500;0,600;0,700;0,800;1,400&display=swap" rel="stylesheet"/>
|
| 7 |
+
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" rel="stylesheet"/>
|
| 8 |
+
<style>
|
| 9 |
+
/* ── Tokens ────────────────────────────────────────────────────────────── */
|
| 10 |
+
[data-theme="dark"]{
|
| 11 |
+
--bg:#0d1117;
|
| 12 |
+
--bg2:#161b22;
|
| 13 |
+
--surface:#1c2333;
|
| 14 |
+
--surface2:#21293d;
|
| 15 |
+
--border:#2d3748;
|
| 16 |
+
--border2:#3a4a60;
|
| 17 |
+
--accent:#3b82f6;
|
| 18 |
+
--accent-h:#2563eb;
|
| 19 |
+
--accent-soft:rgba(59,130,246,.12);
|
| 20 |
+
--accent-border:rgba(59,130,246,.35);
|
| 21 |
+
--red:#ef4444;
|
| 22 |
+
--red-soft:rgba(239,68,68,.1);
|
| 23 |
+
--red-border:rgba(239,68,68,.3);
|
| 24 |
+
--green:#22c55e;
|
| 25 |
+
--green-soft:rgba(34,197,94,.1);
|
| 26 |
+
--green-border:rgba(34,197,94,.3);
|
| 27 |
+
--amber:#f59e0b;
|
| 28 |
+
--amber-soft:rgba(245,158,11,.1);
|
| 29 |
+
--amber-border:rgba(245,158,11,.3);
|
| 30 |
+
--text:#f0f6fc;
|
| 31 |
+
--text2:#8b949e;
|
| 32 |
+
--text3:#484f58;
|
| 33 |
+
--shadow:0 0 0 1px rgba(255,255,255,.04),0 4px 24px rgba(0,0,0,.4);
|
| 34 |
+
--shadow2:0 8px 40px rgba(0,0,0,.5);
|
| 35 |
+
--input-bg:#0d1117;
|
| 36 |
+
}
|
| 37 |
+
[data-theme="light"]{
|
| 38 |
+
--bg:#f6f8fa;
|
| 39 |
+
--bg2:#ffffff;
|
| 40 |
+
--surface:#ffffff;
|
| 41 |
+
--surface2:#f6f8fa;
|
| 42 |
+
--border:#d0d7de;
|
| 43 |
+
--border2:#b1bac4;
|
| 44 |
+
--accent:#2563eb;
|
| 45 |
+
--accent-h:#1d4ed8;
|
| 46 |
+
--accent-soft:rgba(37,99,235,.08);
|
| 47 |
+
--accent-border:rgba(37,99,235,.25);
|
| 48 |
+
--red:#dc2626;
|
| 49 |
+
--red-soft:rgba(220,38,38,.07);
|
| 50 |
+
--red-border:rgba(220,38,38,.25);
|
| 51 |
+
--green:#16a34a;
|
| 52 |
+
--green-soft:rgba(22,163,74,.07);
|
| 53 |
+
--green-border:rgba(22,163,74,.25);
|
| 54 |
+
--amber:#d97706;
|
| 55 |
+
--amber-soft:rgba(217,119,6,.07);
|
| 56 |
+
--amber-border:rgba(217,119,6,.25);
|
| 57 |
+
--text:#1f2328;
|
| 58 |
+
--text2:#656d76;
|
| 59 |
+
--text3:#b1bac4;
|
| 60 |
+
--shadow:0 0 0 1px rgba(0,0,0,.06),0 4px 24px rgba(0,0,0,.08);
|
| 61 |
+
--shadow2:0 8px 40px rgba(0,0,0,.12);
|
| 62 |
+
--input-bg:#ffffff;
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
/* ── Reset ─────────────────────────────────────────────────────────────── */
|
| 66 |
+
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
| 67 |
+
html{scroll-behavior:smooth}
|
| 68 |
+
body{font-family:'Plus Jakarta Sans',sans-serif;background:var(--bg);color:var(--text);min-height:100vh;display:flex;flex-direction:column;align-items:center;padding:0 1rem 6rem;transition:background .3s,color .3s}
|
| 69 |
+
|
| 70 |
+
/* ── Topbar ─────────────────────────────────────────────────────────────── */
|
| 71 |
+
.topbar{width:100%;max-width:780px;display:flex;align-items:center;justify-content:space-between;padding:1.4rem 0 0}
|
| 72 |
+
.brand{display:flex;align-items:center;gap:.65rem}
|
| 73 |
+
.brand-icon{width:38px;height:38px;border-radius:9px;background:var(--accent);display:flex;align-items:center;justify-content:center;box-shadow:0 2px 10px rgba(59,130,246,.4)}
|
| 74 |
+
.brand-icon i{color:#fff;font-size:.95rem}
|
| 75 |
+
.brand-name{font-size:1rem;font-weight:800;color:var(--text);letter-spacing:-.2px}
|
| 76 |
+
.brand-name em{font-style:normal;color:var(--accent)}
|
| 77 |
+
.brand-sub{font-size:.68rem;color:var(--text2);margin-top:.05rem}
|
| 78 |
+
.theme-btn{display:flex;align-items:center;gap:.38rem;background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:.4rem .75rem;font-size:.76rem;font-weight:600;color:var(--text2);cursor:pointer;transition:all .2s;box-shadow:var(--shadow);font-family:'Plus Jakarta Sans',sans-serif}
|
| 79 |
+
.theme-btn:hover{border-color:var(--accent);color:var(--accent)}
|
| 80 |
+
|
| 81 |
+
/* ── Hero ───────────────────────────────────────────────────────────────── */
|
| 82 |
+
.wrap{width:100%;max-width:780px}
|
| 83 |
+
.hero{text-align:center;padding:3.5rem 0 2.8rem}
|
| 84 |
+
.hero h1{font-size:2.6rem;font-weight:800;color:var(--text);line-height:1.15;letter-spacing:-.7px}
|
| 85 |
+
.hero h1 span{color:var(--accent)}
|
| 86 |
+
.hero p{color:var(--text2);font-size:.92rem;margin-top:.6rem;line-height:1.6}
|
| 87 |
+
|
| 88 |
+
/* ── Tabs ───────────────────────────────────────────────────────────────── */
|
| 89 |
+
.tabs{display:flex;gap:.4rem;background:var(--surface2);border:1px solid var(--border);border-radius:12px;padding:.28rem;margin-bottom:1.4rem;box-shadow:var(--shadow)}
|
| 90 |
+
.tab{flex:1;padding:.62rem 1rem;border-radius:9px;border:none;background:transparent;font-family:'Plus Jakarta Sans',sans-serif;font-size:.83rem;font-weight:600;color:var(--text2);cursor:pointer;transition:all .22s;display:flex;align-items:center;justify-content:center;gap:.38rem}
|
| 91 |
+
.tab:hover{color:var(--text)}
|
| 92 |
+
.tab.active{background:var(--surface);color:var(--accent);box-shadow:var(--shadow)}
|
| 93 |
+
.tab-panel{display:none}.tab-panel.active{display:block}
|
| 94 |
+
|
| 95 |
+
/* ── Card ───────────────────────────────────────────────────────────────── */
|
| 96 |
+
.card{background:var(--surface);border:1px solid var(--border);border-radius:14px;padding:1.7rem 1.9rem;margin-bottom:1.2rem;box-shadow:var(--shadow);transition:border-color .3s,box-shadow .3s}
|
| 97 |
+
.card.c-danger{border-color:var(--red-border);box-shadow:0 0 0 1px var(--red-border),0 4px 24px rgba(239,68,68,.08)}
|
| 98 |
+
.card.c-warning{border-color:var(--amber-border);box-shadow:0 0 0 1px var(--amber-border),0 4px 24px rgba(245,158,11,.08)}
|
| 99 |
+
.card.c-success{border-color:var(--green-border);box-shadow:0 0 0 1px var(--green-border),0 4px 24px rgba(34,197,94,.08)}
|
| 100 |
+
|
| 101 |
+
/* ── Form elements ──────────────────────────────────────────────────────── */
|
| 102 |
+
.flabel{font-size:.7rem;font-weight:700;color:var(--text3);letter-spacing:.9px;text-transform:uppercase;margin-bottom:.55rem;display:block}
|
| 103 |
+
textarea.field{width:100%;background:var(--input-bg);border:1.5px solid var(--border);border-radius:10px;color:var(--text);font-family:'Plus Jakarta Sans',sans-serif;font-size:.9rem;padding:.9rem 1rem;resize:vertical;outline:none;transition:border-color .2s,box-shadow .2s;line-height:1.65;min-height:160px}
|
| 104 |
+
textarea.field::placeholder{color:var(--text3)}
|
| 105 |
+
textarea.field:focus{border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-soft)}
|
| 106 |
+
|
| 107 |
+
/* ── Buttons ────────────────────────────────────────────────────────────── */
|
| 108 |
+
.btn-row{display:flex;gap:.7rem;margin-top:1rem}
|
| 109 |
+
.btn-p{flex:1;padding:.8rem;border-radius:10px;border:none;background:var(--accent);color:#fff;font-family:'Plus Jakarta Sans',sans-serif;font-weight:700;font-size:.9rem;cursor:pointer;transition:all .2s;display:flex;align-items:center;justify-content:center;gap:.45rem;letter-spacing:.1px;box-shadow:0 2px 10px rgba(59,130,246,.35)}
|
| 110 |
+
.btn-p:hover{background:var(--accent-h);transform:translateY(-1px);box-shadow:0 4px 18px rgba(59,130,246,.45)}
|
| 111 |
+
.btn-p:active{transform:translateY(0)}
|
| 112 |
+
.btn-s{padding:.8rem 1.25rem;border-radius:10px;border:1.5px solid var(--border);background:transparent;color:var(--text2);font-family:'Plus Jakarta Sans',sans-serif;font-weight:600;font-size:.87rem;cursor:pointer;transition:all .2s;white-space:nowrap}
|
| 113 |
+
.btn-s:hover{border-color:var(--border2);color:var(--text);background:var(--surface2)}
|
| 114 |
+
|
| 115 |
+
/* ── Upload ─────────────────────────────────────────────────────────────── */
|
| 116 |
+
.dropzone{border:2px dashed var(--border);border-radius:12px;padding:2.2rem 1rem;text-align:center;cursor:pointer;transition:all .22s;background:var(--surface2)}
|
| 117 |
+
.dropzone:hover,.dropzone.over{border-color:var(--accent);background:var(--accent-soft)}
|
| 118 |
+
.dropzone.has-file{border-color:var(--green);border-style:solid;background:var(--green-soft)}
|
| 119 |
+
.dropzone i{font-size:1.8rem;color:var(--text3);margin-bottom:.55rem;display:block;transition:color .2s}
|
| 120 |
+
.dropzone:hover i,.dropzone.over i{color:var(--accent)}
|
| 121 |
+
.dropzone.has-file i{color:var(--green)}
|
| 122 |
+
.dropzone p{color:var(--text2);font-size:.84rem;line-height:1.55}
|
| 123 |
+
.dropzone strong{color:var(--text)}
|
| 124 |
+
#ain{display:none}
|
| 125 |
+
.fchip{display:none;align-items:center;gap:.35rem;background:var(--green-soft);border:1px solid var(--green-border);border-radius:6px;color:var(--green);font-size:.74rem;padding:.22rem .65rem;margin-top:.65rem;width:fit-content;font-weight:600}
|
| 126 |
+
|
| 127 |
+
/* ── Section label ──────────────────────────────────────────────────────── */
|
| 128 |
+
.div{border:none;border-top:1px solid var(--border);margin:1.2rem 0}
|
| 129 |
+
.slabel{font-size:.68rem;font-weight:700;color:var(--text3);letter-spacing:.9px;text-transform:uppercase;margin-bottom:.48rem;display:flex;align-items:center;gap:.3rem}
|
| 130 |
+
|
| 131 |
+
/* ── Verdict ────────────────────────────────────────────────────────────── */
|
| 132 |
+
.verdict-wrap{display:flex;align-items:center;gap:1.1rem;padding:.6rem 0 .4rem}
|
| 133 |
+
.verdict-badge{width:58px;height:58px;border-radius:13px;display:flex;align-items:center;justify-content:center;flex-shrink:0;animation:pop .45s cubic-bezier(.36,.07,.19,.97) both}
|
| 134 |
+
.vb-red{background:var(--red-soft);border:1.5px solid var(--red-border)}
|
| 135 |
+
.vb-amb{background:var(--amber-soft);border:1.5px solid var(--amber-border)}
|
| 136 |
+
.vb-grn{background:var(--green-soft);border:1.5px solid var(--green-border)}
|
| 137 |
+
.vb-red i{color:var(--red)}.vb-amb i{color:var(--amber)}.vb-grn i{color:var(--green)}
|
| 138 |
+
.verdict-badge i{font-size:1.55rem}
|
| 139 |
+
@keyframes pop{0%{transform:scale(.25) rotate(-12deg);opacity:0}65%{transform:scale(1.1) rotate(2deg)}100%{transform:scale(1) rotate(0);opacity:1}}
|
| 140 |
+
.verdict-text h3{font-size:1.05rem;font-weight:800;margin-bottom:.18rem;color:var(--text)}
|
| 141 |
+
.risk-row{display:flex;align-items:center;gap:.35rem;font-size:.79rem;color:var(--text2);font-weight:500}
|
| 142 |
+
.rdot{width:7px;height:7px;border-radius:50%;flex-shrink:0}
|
| 143 |
+
|
| 144 |
+
/* ── Message box ────────────────────────────────────────────────────────── */
|
| 145 |
+
.mbox{background:var(--input-bg);border:1px solid var(--border);border-left:3px solid var(--accent);border-radius:0 9px 9px 0;padding:.75rem 1rem;color:var(--text2);font-size:.84rem;line-height:1.65;word-break:break-word;max-height:110px;overflow-y:auto}
|
| 146 |
+
|
| 147 |
+
/* ── URL block ──────────────────────────────────────────────────────────── */
|
| 148 |
+
.ublock{border:1px solid var(--border);border-radius:10px;overflow:hidden;margin-top:.48rem}
|
| 149 |
+
.urow{display:flex;align-items:flex-start;gap:.7rem;padding:.7rem .95rem;border-bottom:1px solid var(--border);font-size:.8rem}
|
| 150 |
+
.urow:last-child{border-bottom:none}
|
| 151 |
+
.uvb{font-size:.62rem;font-weight:700;padding:.15rem .48rem;border-radius:20px;white-space:nowrap;flex-shrink:0;margin-top:2px;text-transform:uppercase;letter-spacing:.4px}
|
| 152 |
+
.vbr{background:var(--red-soft);color:var(--red);border:1px solid var(--red-border)}
|
| 153 |
+
.vba{background:var(--amber-soft);color:var(--amber);border:1px solid var(--amber-border)}
|
| 154 |
+
.vbg{background:var(--green-soft);color:var(--green);border:1px solid var(--green-border)}
|
| 155 |
+
.vbc{background:var(--accent-soft);color:var(--accent);border:1px solid var(--accent-border)}
|
| 156 |
+
.utxt{color:var(--text2);word-break:break-all;font-family:monospace;font-size:.76rem;line-height:1.5}
|
| 157 |
+
.ubar{height:3px;border-radius:2px;background:var(--border);margin-top:.35rem;overflow:hidden}
|
| 158 |
+
.ufill{height:100%;border-radius:2px;transition:width 1s ease}
|
| 159 |
+
.uflag{font-size:.71rem;color:var(--text3);display:flex;align-items:center;gap:.25rem;margin-top:.12rem;line-height:1.4}
|
| 160 |
+
|
| 161 |
+
/* ── Tags ───────────────────────────────────────────────────────────────── */
|
| 162 |
+
.tagr{display:inline-flex;align-items:center;gap:.25rem;background:var(--red-soft);border:1px solid var(--red-border);border-radius:20px;color:var(--red);font-size:.73rem;font-weight:600;padding:.2rem .68rem;margin:.1rem}
|
| 163 |
+
.tags{background:var(--green-soft);border-color:var(--green-border);color:var(--green)}
|
| 164 |
+
.kw{display:inline-block;background:var(--accent-soft);border:1px solid var(--accent-border);border-radius:6px;color:var(--accent);font-size:.71rem;font-weight:600;padding:.16rem .48rem;margin:.1rem;letter-spacing:.1px}
|
| 165 |
+
|
| 166 |
+
/* ── SHAP Chart ────────────────────────────────────────────────────────── */
|
| 167 |
+
.shap-chart { display: flex; flex-direction: column; gap: 0.5rem; margin-top: 0.8rem; font-size: 0.75rem; }
|
| 168 |
+
.shap-row { display: flex; align-items: center; gap: 0.5rem; }
|
| 169 |
+
.shap-word { width: 90px; text-align: right; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--text2); }
|
| 170 |
+
.shap-bar-container { flex: 1; position: relative; height: 16px; border-left: none; }
|
| 171 |
+
.shap-bar-container::before { content: ''; position: absolute; left: 50%; top: -2px; bottom: -2px; width: 1px; background: var(--border); z-index: 1; }
|
| 172 |
+
.shap-val { width: 50px; font-size: 0.7rem; color: var(--text3); text-align: left; }
|
| 173 |
+
|
| 174 |
+
/* ── Progress ───────────────────────────────────────────────────────────── */
|
| 175 |
+
.prow{display:flex;justify-content:space-between;align-items:baseline;margin-bottom:.45rem}
|
| 176 |
+
.plbl{font-size:.79rem;font-weight:600;color:var(--text2)}
|
| 177 |
+
.ppct{font-size:1.15rem;font-weight:800;color:var(--text);font-variant-numeric:tabular-nums}
|
| 178 |
+
.ptrack{height:9px;border-radius:5px;background:var(--surface2);border:1px solid var(--border);overflow:hidden}
|
| 179 |
+
.pfill{height:100%;border-radius:5px;width:0%;transition:width 1.5s cubic-bezier(.23,1,.32,1);position:relative;overflow:hidden}
|
| 180 |
+
.pfill::after{content:'';position:absolute;inset:0;background:linear-gradient(90deg,transparent,rgba(255,255,255,.18),transparent);animation:sh 2.2s infinite}
|
| 181 |
+
@keyframes sh{0%{transform:translateX(-100%)}100%{transform:translateX(100%)}}
|
| 182 |
+
.pticks{display:flex;justify-content:space-between;margin-top:.28rem;font-size:.66rem;color:var(--text3)}
|
| 183 |
+
|
| 184 |
+
/* ── Alert box ──────────────────────────────────────────────────────────── */
|
| 185 |
+
.abox{border-radius:10px;padding:.95rem 1.05rem;display:flex;gap:.8rem;align-items:flex-start;margin-top:1.1rem}
|
| 186 |
+
.a-red{background:var(--red-soft);border:1px solid var(--red-border)}
|
| 187 |
+
.a-inf{background:var(--accent-soft);border:1px solid var(--accent-border)}
|
| 188 |
+
.a-ico{font-size:1rem;flex-shrink:0;margin-top:.1rem}
|
| 189 |
+
.a-red .a-ico{color:var(--red)}.a-inf .a-ico{color:var(--accent)}
|
| 190 |
+
.a-title{font-size:.84rem;font-weight:700;margin-bottom:.28rem}
|
| 191 |
+
.a-red .a-title{color:var(--red)}.a-inf .a-title{color:var(--accent)}
|
| 192 |
+
.a-body{font-size:.79rem;line-height:1.7;color:var(--text2)}
|
| 193 |
+
.a-body strong{color:var(--text)}
|
| 194 |
+
.a-body code{background:var(--surface2);border:1px solid var(--border);border-radius:4px;padding:.04rem .32rem;font-family:monospace;font-size:.76rem;color:var(--text)}
|
| 195 |
+
|
| 196 |
+
/* ── Transcript ─────────────────────────────────────────────────────────── */
|
| 197 |
+
.tbox{background:var(--input-bg);border:1px solid var(--border);border-left:3px solid var(--accent);border-radius:0 9px 9px 0;padding:.78rem 1rem;font-size:.83rem;line-height:1.7;color:var(--text2);max-height:140px;overflow-y:auto;word-break:break-word}
|
| 198 |
+
.tchips{display:flex;gap:.4rem;flex-wrap:wrap;margin-top:.55rem}
|
| 199 |
+
.tch{background:var(--accent-soft);border:1px solid var(--accent-border);border-radius:20px;color:var(--accent);font-size:.7rem;padding:.16rem .5rem;font-weight:600;display:inline-flex;align-items:center;gap:.25rem}
|
| 200 |
+
|
| 201 |
+
/* ── Error ──────────────────────────────────────────────────────────────── */
|
| 202 |
+
.err{border-radius:9px;padding:.65rem .95rem;font-size:.8rem;margin-bottom:1rem;display:flex;align-items:center;gap:.5rem;background:var(--red-soft);border:1px solid var(--red-border);color:var(--red);font-weight:500}
|
| 203 |
+
|
| 204 |
+
/* ── Overlay ────────────────────────────────────────────────────────────── */
|
| 205 |
+
#ov{display:none;position:fixed;inset:0;z-index:9999;flex-direction:column;align-items:center;justify-content:center;gap:.9rem;backdrop-filter:blur(6px)}
|
| 206 |
+
[data-theme="dark"] #ov{background:rgba(13,17,23,.88)}
|
| 207 |
+
[data-theme="light"] #ov{background:rgba(246,248,250,.88)}
|
| 208 |
+
#ov.on{display:flex}
|
| 209 |
+
.spin{width:44px;height:44px;border:3px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:spin .75s linear infinite}
|
| 210 |
+
@keyframes spin{to{transform:rotate(360deg)}}
|
| 211 |
+
#ov p{color:var(--text2);font-size:.88rem;font-weight:500}
|
| 212 |
+
|
| 213 |
+
/* ── Animations ─────────────────────────────────────────────────────────── */
|
| 214 |
+
.rc{animation:rise .42s cubic-bezier(.16,1,.3,1) both}
|
| 215 |
+
@keyframes rise{from{opacity:0;transform:translateY(16px)}to{opacity:1;transform:translateY(0)}}
|
| 216 |
+
|
| 217 |
+
/* ── Responsive ─────────────────────────────────────────────────────────── */
|
| 218 |
+
@media(max-width:600px){
|
| 219 |
+
.card{padding:1.3rem 1.1rem}
|
| 220 |
+
.hero h1{font-size:1.9rem}
|
| 221 |
+
.btn-row{flex-direction:column}
|
| 222 |
+
.btn-s{width:100%;text-align:center}
|
| 223 |
+
.verdict-wrap{flex-direction:column;text-align:center}
|
| 224 |
+
}
|
| 225 |
+
</style>
|
| 226 |
+
</head>
|
| 227 |
+
<body>
|
| 228 |
+
|
| 229 |
+
<!-- Loading overlay -->
|
| 230 |
+
<div id="ov"><div class="spin"></div><p id="ovm">Analysing threat…</p></div>
|
| 231 |
+
|
| 232 |
+
<!-- Topbar -->
|
| 233 |
+
<div class="topbar">
|
| 234 |
+
<div class="brand">
|
| 235 |
+
<div class="brand-icon"><i class="fa-solid fa-shield-halved"></i></div>
|
| 236 |
+
<div>
|
| 237 |
+
<div class="brand-name">Scam<em>Shield</em> AI</div>
|
| 238 |
+
<div class="brand-sub">Threat Detection System</div>
|
| 239 |
+
</div>
|
| 240 |
+
</div>
|
| 241 |
+
<button class="theme-btn" onclick="tg()" id="tbtn">
|
| 242 |
+
<span id="ticon">☀️</span><span id="ttext">Light Mode</span>
|
| 243 |
+
</button>
|
| 244 |
+
</div>
|
| 245 |
+
|
| 246 |
+
<div class="wrap">
|
| 247 |
+
|
| 248 |
+
<!-- Hero -->
|
| 249 |
+
<div class="hero">
|
| 250 |
+
<h1>Detect <span>Scams</span><br/>Before They Strike</h1>
|
| 251 |
+
<p>AI-powered analysis of messages, links and phone calls.</p>
|
| 252 |
+
</div>
|
| 253 |
+
|
| 254 |
+
<!-- Tabs -->
|
| 255 |
+
<div class="tabs">
|
| 256 |
+
<button class="tab active" onclick="sw('text')" id="tab-text">
|
| 257 |
+
<i class="fa-solid fa-magnifying-glass" style="font-size:11px"></i> Text & Link Scanner
|
| 258 |
+
</button>
|
| 259 |
+
<button class="tab" onclick="sw('audio')" id="tab-audio">
|
| 260 |
+
<i class="fa-solid fa-microphone-lines" style="font-size:11px"></i> Call Audio Analyser
|
| 261 |
+
</button>
|
| 262 |
+
</div>
|
| 263 |
+
|
| 264 |
+
<!-- ── TEXT TAB ── -->
|
| 265 |
+
<div class="tab-panel active" id="panel-text">
|
| 266 |
+
<div class="card">
|
| 267 |
+
<span class="flabel">Paste message, SMS, email or URL</span>
|
| 268 |
+
<textarea class="field" id="mi" rows="6"
|
| 269 |
+
placeholder="Paste any suspicious content here… Press Ctrl+Enter to scan."></textarea>
|
| 270 |
+
<div class="btn-row">
|
| 271 |
+
<button class="btn-p" onclick="rT()">
|
| 272 |
+
<i class="fa-solid fa-shield-halved" style="font-size:13px"></i> Run Threat Analysis
|
| 273 |
+
</button>
|
| 274 |
+
<button class="btn-s" onclick="cT()">
|
| 275 |
+
<i class="fa-solid fa-xmark" style="font-size:12px"></i> Clear
|
| 276 |
+
</button>
|
| 277 |
+
</div>
|
| 278 |
+
</div>
|
| 279 |
+
|
| 280 |
+
<div id="terr" class="err" style="display:none">
|
| 281 |
+
<i class="fa-solid fa-circle-xmark" style="font-size:12px;flex-shrink:0"></i>
|
| 282 |
+
<span id="terrm"></span>
|
| 283 |
+
</div>
|
| 284 |
+
|
| 285 |
+
<div id="tout" style="display:none" class="rc">
|
| 286 |
+
<div class="card" id="tcard">
|
| 287 |
+
|
| 288 |
+
<!-- Verdict -->
|
| 289 |
+
<div class="verdict-wrap">
|
| 290 |
+
<div class="verdict-badge vb-red" id="tvb">
|
| 291 |
+
<i id="tico" class="fa-solid fa-triangle-exclamation"></i>
|
| 292 |
+
</div>
|
| 293 |
+
<div class="verdict-text">
|
| 294 |
+
<h3 id="tverdict">Threat Detected</h3>
|
| 295 |
+
<div class="risk-row">
|
| 296 |
+
<span class="rdot" id="trdot" style="background:var(--red)"></span>
|
| 297 |
+
<span id="tlvl">High Risk</span>
|
| 298 |
+
</div>
|
| 299 |
+
</div>
|
| 300 |
+
</div>
|
| 301 |
+
|
| 302 |
+
<div class="div"></div>
|
| 303 |
+
|
| 304 |
+
<!-- Scanned message -->
|
| 305 |
+
<div style="margin-bottom:1.1rem">
|
| 306 |
+
<div class="slabel">
|
| 307 |
+
<i class="fa-solid fa-envelope-open" style="font-size:9px"></i> Scanned input
|
| 308 |
+
</div>
|
| 309 |
+
<div class="mbox" id="tmsg"></div>
|
| 310 |
+
</div>
|
| 311 |
+
|
| 312 |
+
<!-- URL results -->
|
| 313 |
+
<div id="turw" style="display:none;margin-bottom:1.1rem">
|
| 314 |
+
<div class="slabel">
|
| 315 |
+
<i class="fa-solid fa-link" style="font-size:9px"></i>
|
| 316 |
+
URL analysis — <span id="turc">0</span> link(s) found
|
| 317 |
+
</div>
|
| 318 |
+
<div class="ublock" id="turl"></div>
|
| 319 |
+
</div>
|
| 320 |
+
|
| 321 |
+
<!-- Threat category -->
|
| 322 |
+
<div style="margin-bottom:1.1rem">
|
| 323 |
+
<div class="slabel">
|
| 324 |
+
<i class="fa-solid fa-tags" style="font-size:9px"></i> Threat category
|
| 325 |
+
</div>
|
| 326 |
+
<div id="ttyp"></div>
|
| 327 |
+
</div>
|
| 328 |
+
|
| 329 |
+
<!-- Keywords -->
|
| 330 |
+
<div id="tkww" style="display:none;margin-bottom:1.1rem">
|
| 331 |
+
<div class="slabel">
|
| 332 |
+
<i class="fa-solid fa-key" style="font-size:9px"></i> Trigger keywords detected
|
| 333 |
+
</div>
|
| 334 |
+
<div id="tkws"></div>
|
| 335 |
+
</div>
|
| 336 |
+
|
| 337 |
+
<!-- SHAP Explainability -->
|
| 338 |
+
<div id="tshap" style="display:none;margin-bottom:1.1rem">
|
| 339 |
+
<div class="slabel"><i class="fa-solid fa-chart-bar" style="font-size:9px"></i> SHAP Explainability (Word Impact)</div>
|
| 340 |
+
<div class="shap-chart" id="shap-container"></div>
|
| 341 |
+
</div>
|
| 342 |
+
|
| 343 |
+
<div class="div"></div>
|
| 344 |
+
|
| 345 |
+
<!-- Confidence bar -->
|
| 346 |
+
<div>
|
| 347 |
+
<div class="prow">
|
| 348 |
+
<span class="plbl">Threat confidence score</span>
|
| 349 |
+
<span class="ppct" id="tpct">0%</span>
|
| 350 |
+
</div>
|
| 351 |
+
<div class="ptrack"><div class="pfill" id="tbar"></div></div>
|
| 352 |
+
<div class="pticks">
|
| 353 |
+
<span>Safe · 0%</span><span>Medium · 50%</span><span>High · 75%+</span>
|
| 354 |
+
</div>
|
| 355 |
+
</div>
|
| 356 |
+
|
| 357 |
+
<!-- Warning -->
|
| 358 |
+
<div id="talert" style="display:none" class="abox a-red">
|
| 359 |
+
<i class="fa-solid fa-bell a-ico"></i>
|
| 360 |
+
<div>
|
| 361 |
+
<div class="a-title">Early Warning — Immediate Action Required</div>
|
| 362 |
+
<div class="a-body">
|
| 363 |
+
Do <strong>NOT</strong> click any links or share your OTP, Aadhaar, or bank details.<br/>
|
| 364 |
+
This matches patterns of <strong>Digital Arrest</strong>, <strong>Phishing</strong> & <strong>Financial Fraud</strong>.<br/><br/>
|
| 365 |
+
▸ Block the sender immediately<br/>
|
| 366 |
+
▸ Report at <code>cybercrime.gov.in</code><br/>
|
| 367 |
+
▸ National Helpline: <code>1930</code>
|
| 368 |
+
</div>
|
| 369 |
+
</div>
|
| 370 |
+
</div>
|
| 371 |
+
|
| 372 |
+
<div style="text-align:center;margin-top:1.3rem">
|
| 373 |
+
<button class="btn-s" onclick="cT()">
|
| 374 |
+
<i class="fa-solid fa-rotate-left" style="font-size:11px"></i> Scan Another
|
| 375 |
+
</button>
|
| 376 |
+
</div>
|
| 377 |
+
</div>
|
| 378 |
+
</div>
|
| 379 |
+
</div>
|
| 380 |
+
|
| 381 |
+
<!-- ── AUDIO TAB ── -->
|
| 382 |
+
<div class="tab-panel" id="panel-audio">
|
| 383 |
+
<div class="card">
|
| 384 |
+
<span class="flabel">Upload call recording</span>
|
| 385 |
+
<div class="dropzone" id="dz" onclick="document.getElementById('ain').click()">
|
| 386 |
+
<i class="fa-solid fa-cloud-arrow-up"></i>
|
| 387 |
+
<p><strong>Click to upload</strong> or drag & drop here</p>
|
| 388 |
+
<p style="margin-top:.28rem;font-size:.74rem;color:var(--text3)">
|
| 389 |
+
WAV · MP3 · OGG · FLAC · M4A — Max 50 MB
|
| 390 |
+
</p>
|
| 391 |
+
</div>
|
| 392 |
+
<div class="fchip" id="fch">
|
| 393 |
+
<i class="fa-solid fa-file-audio" style="font-size:10px"></i>
|
| 394 |
+
<span id="fn"></span>
|
| 395 |
+
</div>
|
| 396 |
+
<input type="file" id="ain" accept=".wav,.mp3,.ogg,.flac,.m4a,.aac"/>
|
| 397 |
+
<div class="btn-row" style="margin-top:.9rem">
|
| 398 |
+
<button class="btn-p" onclick="rA()">
|
| 399 |
+
<i class="fa-solid fa-waveform-lines" style="font-size:13px"></i> Analyse Call Recording
|
| 400 |
+
</button>
|
| 401 |
+
</div>
|
| 402 |
+
<div class="abox a-inf" style="margin-top:1rem">
|
| 403 |
+
<i class="fa-solid fa-circle-info a-ico"></i>
|
| 404 |
+
<div>
|
| 405 |
+
<div class="a-title">How audio analysis works</div>
|
| 406 |
+
<div class="a-body">
|
| 407 |
+
Upload a recorded call → AI transcribes speech to text → Scam model analyses
|
| 408 |
+
the transcript → Risk verdict returned. Connect to Flask backend for real transcription.
|
| 409 |
+
</div>
|
| 410 |
+
</div>
|
| 411 |
+
</div>
|
| 412 |
+
</div>
|
| 413 |
+
|
| 414 |
+
<div id="aerr" class="err" style="display:none">
|
| 415 |
+
<i class="fa-solid fa-circle-xmark" style="font-size:12px;flex-shrink:0"></i>
|
| 416 |
+
<span id="aerrm"></span>
|
| 417 |
+
</div>
|
| 418 |
+
|
| 419 |
+
<div id="aout" style="display:none" class="rc">
|
| 420 |
+
<div class="card">
|
| 421 |
+
|
| 422 |
+
<!-- Transcript -->
|
| 423 |
+
<div style="margin-bottom:1.1rem">
|
| 424 |
+
<div class="slabel">
|
| 425 |
+
<i class="fa-solid fa-waveform-lines" style="font-size:9px;color:var(--accent)"></i> Call transcript
|
| 426 |
+
</div>
|
| 427 |
+
<div class="tbox" id="atx"></div>
|
| 428 |
+
<div class="tchips">
|
| 429 |
+
<span class="tch"><i class="fa-solid fa-clock" style="font-size:9px"></i> <span id="adur"></span></span>
|
| 430 |
+
<span class="tch"><i class="fa-solid fa-align-left" style="font-size:9px"></i> <span id="awrd"></span></span>
|
| 431 |
+
<span class="tch"><i class="fa-solid fa-microchip" style="font-size:9px"></i> Google STT</span>
|
| 432 |
+
</div>
|
| 433 |
+
</div>
|
| 434 |
+
|
| 435 |
+
<div class="div"></div>
|
| 436 |
+
|
| 437 |
+
<!-- Verdict -->
|
| 438 |
+
<div class="verdict-wrap">
|
| 439 |
+
<div class="verdict-badge vb-red" id="avb">
|
| 440 |
+
<i id="aico" class="fa-solid fa-triangle-exclamation"></i>
|
| 441 |
+
</div>
|
| 442 |
+
<div class="verdict-text">
|
| 443 |
+
<h3 id="averdict">Scam Call Detected</h3>
|
| 444 |
+
<div class="risk-row">
|
| 445 |
+
<span class="rdot" id="ardot" style="background:var(--red)"></span>
|
| 446 |
+
<span id="alvl">High Risk</span>
|
| 447 |
+
</div>
|
| 448 |
+
</div>
|
| 449 |
+
</div>
|
| 450 |
+
|
| 451 |
+
<div class="div"></div>
|
| 452 |
+
|
| 453 |
+
<div style="margin-bottom:1.1rem">
|
| 454 |
+
<div class="slabel"><i class="fa-solid fa-tags" style="font-size:9px"></i> Threat category</div>
|
| 455 |
+
<div id="atyp"></div>
|
| 456 |
+
</div>
|
| 457 |
+
|
| 458 |
+
<div id="akww" style="display:none;margin-bottom:1.1rem">
|
| 459 |
+
<div class="slabel"><i class="fa-solid fa-key" style="font-size:9px"></i> Suspicious phrases in call</div>
|
| 460 |
+
<div id="akws"></div>
|
| 461 |
+
</div>
|
| 462 |
+
|
| 463 |
+
<div class="div"></div>
|
| 464 |
+
|
| 465 |
+
<div>
|
| 466 |
+
<div class="prow">
|
| 467 |
+
<span class="plbl">Threat confidence score</span>
|
| 468 |
+
<span class="ppct" id="apct">0%</span>
|
| 469 |
+
</div>
|
| 470 |
+
<div class="ptrack"><div class="pfill" id="abar"></div></div>
|
| 471 |
+
<div class="pticks">
|
| 472 |
+
<span>Safe · 0%</span><span>Medium · 50%</span><span>High · 75%+</span>
|
| 473 |
+
</div>
|
| 474 |
+
</div>
|
| 475 |
+
|
| 476 |
+
<div id="aalert" style="display:none" class="abox a-red">
|
| 477 |
+
<i class="fa-solid fa-phone-slash a-ico"></i>
|
| 478 |
+
<div>
|
| 479 |
+
<div class="a-title">Scam Call — Hang Up Immediately</div>
|
| 480 |
+
<div class="a-body">
|
| 481 |
+
Do <strong>NOT</strong> share personal, financial or identity information.<br/><br/>
|
| 482 |
+
▸ Block the caller number<br/>
|
| 483 |
+
▸ Report at <code>cybercrime.gov.in</code><br/>
|
| 484 |
+
▸ National Helpline: <code>1930</code>
|
| 485 |
+
</div>
|
| 486 |
+
</div>
|
| 487 |
+
</div>
|
| 488 |
+
|
| 489 |
+
<div style="text-align:center;margin-top:1.3rem">
|
| 490 |
+
<button class="btn-s" onclick="cA()">
|
| 491 |
+
<i class="fa-solid fa-rotate-left" style="font-size:11px"></i> Analyse Another
|
| 492 |
+
</button>
|
| 493 |
+
</div>
|
| 494 |
+
</div>
|
| 495 |
+
</div>
|
| 496 |
+
</div>
|
| 497 |
+
|
| 498 |
+
</div><!-- /wrap -->
|
| 499 |
+
|
| 500 |
+
<script>
|
| 501 |
+
/* ── Theme toggle ─────────────────────────────────────────────────────── */
|
| 502 |
+
function tg(){
|
| 503 |
+
const h=document.documentElement;
|
| 504 |
+
const cur=h.getAttribute('data-theme');
|
| 505 |
+
const nxt=cur==='dark'?'light':'dark';
|
| 506 |
+
h.setAttribute('data-theme',nxt);
|
| 507 |
+
document.getElementById('ticon').textContent=nxt==='dark'?'☀️':'🌙';
|
| 508 |
+
document.getElementById('ttext').textContent=nxt==='dark'?'Light Mode':'Dark Mode';
|
| 509 |
+
}
|
| 510 |
+
|
| 511 |
+
/* ── Tab switch ───────────────────────────────────────────────────────── */
|
| 512 |
+
function sw(t){
|
| 513 |
+
document.querySelectorAll('.tab').forEach(e=>e.classList.remove('active'));
|
| 514 |
+
document.querySelectorAll('.tab-panel').forEach(e=>e.classList.remove('active'));
|
| 515 |
+
document.getElementById('tab-'+t).classList.add('active');
|
| 516 |
+
document.getElementById('panel-'+t).classList.add('active');
|
| 517 |
+
}
|
| 518 |
+
|
| 519 |
+
/* ── Detection data ───────────────────────────────────────────────────── */
|
| 520 |
+
const SCAM_TYPES={
|
| 521 |
+
'KYC / Bank Fraud':['kyc','aadhaar','upi','neft','account blocked','account suspended','bank account','share otp','send otp'],
|
| 522 |
+
'Lottery / Prize Scam':['prize','winner','lottery','lucky draw','won','congratulations','cash prize','jackpot','claim prize'],
|
| 523 |
+
'Digital Arrest Scam':['arrest','cbi','police','court','warrant','cybercrime','money laundering','digital arrest','fbi','irs','legal action'],
|
| 524 |
+
'OTP / Phishing':['otp','bit.ly','tinyurl','click here','verify now','password reset','login credentials'],
|
| 525 |
+
'Job / Loan Scam':['job offer','loan approved','registration fee','processing fee','earn daily','work from home'],
|
| 526 |
+
'Government Scheme':['pm scheme','free subsidy','ration card','pension','pm kisan','free laptop'],
|
| 527 |
+
'Tax Authority Scam':['income tax','tax refund','unpaid tax','tax fraud','hmrc','ato','tax warrant'],
|
| 528 |
+
'Social Security Scam':['social security','medicare','ssn suspended','benefits suspended','national insurance'],
|
| 529 |
+
'Crypto / Investment':['bitcoin','cryptocurrency','crypto wallet','guaranteed returns','trading profit'],
|
| 530 |
+
'Advance Fee / Romance':['western union','wire transfer','moneygram','gold transfer','inheritance fund'],
|
| 531 |
+
};
|
| 532 |
+
const ALL_KW=['otp','aadhaar','kyc','upi','neft','arrest','cbi','warrant','prize','winner',
|
| 533 |
+
'lottery','lucky draw','suspended','blocked','immediately','urgent','click here','verify now',
|
| 534 |
+
'share your','bitcoin','cryptocurrency','wire transfer','western union','irs','hmrc',
|
| 535 |
+
'social security','medicare','tax refund','illegal','fraud','cybercrime','bank details',
|
| 536 |
+
'police','court','password','jackpot','claim'];
|
| 537 |
+
const URGENCY=['urgent','immediately','expire','blocked','suspended','arrested','action required','now','today'];
|
| 538 |
+
const THREAT=['arrest','warrant','police','cbi','fbi','irs','court','legal action','cybercrime','illegal','fraud','jail'];
|
| 539 |
+
const PRIZE=['won','winner','prize','lottery','lucky draw','congratulations','jackpot','claim'];
|
| 540 |
+
const FIN=['otp','pin','bank account','upi','neft','wire transfer','bitcoin','kyc','aadhaar','password'];
|
| 541 |
+
const SHORT=['bit.ly','tinyurl.com','goo.gl','ow.ly','t.co','tiny.cc','is.gd','buff.ly','adf.ly'];
|
| 542 |
+
const WL=['sbi.co.in','hdfcbank.com','icicibank.com','axisbank.com','paytm.com','phonepe.com',
|
| 543 |
+
'airtel.in','i.airtel.in','myairtel.in','jio.com','vi.in','bsnl.co.in','gov.in','nic.in',
|
| 544 |
+
'uidai.gov.in','incometax.gov.in','rbi.org.in','irctc.co.in','amazon.in','amazon.com',
|
| 545 |
+
'flipkart.com','zomato.com','swiggy.com','google.com','youtube.com','apple.com',
|
| 546 |
+
'microsoft.com','netflix.com','paypal.com','chase.com','bankofamerica.com','barclays.co.uk',
|
| 547 |
+
'hsbc.com','natwest.com','hmrc.gov.uk','ato.gov.au','hotstar.com','primevideo.com'];
|
| 548 |
+
const BRANDS=['sbi','hdfc','icici','axis','paytm','phonepe','amazon','flipkart','google',
|
| 549 |
+
'paypal','apple','microsoft','netflix','whatsapp','airtel','jio','bank','chase',
|
| 550 |
+
'wellsfargo','citibank','barclays','irs','hmrc'];
|
| 551 |
+
const STLDS=new Set(['xyz','top','club','online','site','website','space','tk','ml','ga',
|
| 552 |
+
'cf','gq','pw','cc','icu','buzz','fun','loan','win','bid','biz']);
|
| 553 |
+
const DEMOS=[
|
| 554 |
+
'This is officer Johnson from the IRS. Your social security number has been suspended due to suspicious activity. You owe 3200 dollars in back taxes. Call us immediately or you will be arrested today.',
|
| 555 |
+
'Hello this is Amazon customer service. Your account has been compromised. Please share your OTP and credit card details with our agent now to secure it.',
|
| 556 |
+
'Congratulations you have been selected as lucky draw winner. You won one lakh rupees. Share your bank account number and Aadhaar number immediately to claim your prize.',
|
| 557 |
+
'This is a CBI officer. Your Aadhaar is linked to 12 illegal bank accounts. You are under digital arrest. Do not tell anyone or the police will come to your home within one hour.',
|
| 558 |
+
'I am calling from Paytm KYC department. Your KYC is still pending. If not done within 2 hours your UPI account will be permanently blocked. Please share your OTP now to verify.',
|
| 559 |
+
];
|
| 560 |
+
|
| 561 |
+
/* ── URL extractor & analyser ─────────────────────────────────────────── */
|
| 562 |
+
function xU(t){
|
| 563 |
+
const re=/(?:https?:\/\/|www\.)[^\s\)\]\,\"\'<>]+|[a-zA-Z0-9\-]+\.(?:com|in|org|net|co\.in|gov\.in|edu|xyz|top|club|online|site|tk|ml|ga|cf|biz|info|pw|cc|icu|buzz|fun|loan)[^\s]*/gi;
|
| 564 |
+
return[...new Set((t.match(re)||[]).map(u=>u.replace(/[.,;:!?"\')}\]]+$/,'')))];
|
| 565 |
+
}
|
| 566 |
+
|
| 567 |
+
async function checkDomainLegitimacy(domain) {
|
| 568 |
+
try {
|
| 569 |
+
const response = await fetch('/check-domain', {
|
| 570 |
+
method: 'POST',
|
| 571 |
+
headers: { 'Content-Type': 'application/json' },
|
| 572 |
+
body: JSON.stringify({ domain: domain })
|
| 573 |
+
});
|
| 574 |
+
const data = await response.json();
|
| 575 |
+
return data.is_legitimate || false;
|
| 576 |
+
} catch (e) {
|
| 577 |
+
console.error('Domain check failed:', e);
|
| 578 |
+
return false;
|
| 579 |
+
}
|
| 580 |
+
}
|
| 581 |
+
|
| 582 |
+
async function aU(url){
|
| 583 |
+
const n=url.startsWith('http')?url:'http://'+url;
|
| 584 |
+
let r=0; const f=[];
|
| 585 |
+
const hm=n.match(/\/\/([^\/\?#]+)/);
|
| 586 |
+
const h=(hm?hm[1]:'').toLowerCase();
|
| 587 |
+
const pts=h.split('.');
|
| 588 |
+
const tld=pts[pts.length-1];
|
| 589 |
+
|
| 590 |
+
// CHECK GOOGLE SAFE BROWSING FIRST - This is the primary classifier
|
| 591 |
+
const isLegit = await checkDomainLegitimacy(h);
|
| 592 |
+
|
| 593 |
+
if(isLegit) {
|
| 594 |
+
// Domain is verified safe by Google
|
| 595 |
+
return{url,r:0,v:'Verified Safe',vc:'g',f:['✓ Verified by Google Safe Browsing API']};
|
| 596 |
+
}
|
| 597 |
+
|
| 598 |
+
// Domain is NOT verified safe - could be malicious or unknown
|
| 599 |
+
// Check if it's a known shortener or IP address (high risk indicators)
|
| 600 |
+
if(SHORT.some(s=>h===s||h.endsWith('.'+s))){
|
| 601 |
+
f.push('URL shortener — hides real destination');r+=60;
|
| 602 |
+
}
|
| 603 |
+
if(/^(\d{1,3}\.){3}\d{1,3}$/.test(h)){
|
| 604 |
+
f.push('IP address used instead of domain name');r+=70;
|
| 605 |
+
}
|
| 606 |
+
|
| 607 |
+
// If shortener or IP, mark as high threat
|
| 608 |
+
if(r>=60) {
|
| 609 |
+
r=Math.min(r,100);
|
| 610 |
+
const vc='r';
|
| 611 |
+
const v='High Threat';
|
| 612 |
+
return{url,r,v,vc,f};
|
| 613 |
+
}
|
| 614 |
+
|
| 615 |
+
// For other unknown domains, apply additional heuristics
|
| 616 |
+
if(STLDS.has(tld)){f.push('Suspicious TLD: .'+tld);r+=40;}
|
| 617 |
+
const sc=pts.length-2;
|
| 618 |
+
if(sc>=3){f.push('Excessive subdomains ('+sc+')');r+=25;}
|
| 619 |
+
else if(sc===2){f.push('Multiple subdomains detected');r+=12;}
|
| 620 |
+
|
| 621 |
+
for(const b of BRANDS){
|
| 622 |
+
if(h.includes(b)){
|
| 623 |
+
f.push('Brand impersonation: "'+b+'" in domain');r+=60;break;
|
| 624 |
+
}
|
| 625 |
+
}
|
| 626 |
+
|
| 627 |
+
const pk=['login','verify','secure','confirm','kyc','otp','aadhaar','prize','winner',
|
| 628 |
+
'claim','suspend','block','expire','urgent','click','update','account'];
|
| 629 |
+
const hits=pk.filter(k=>n.toLowerCase().includes(k));
|
| 630 |
+
if(hits.length>=3){f.push('Phishing keywords: '+hits.slice(0,3).join(', '));r+=40;}
|
| 631 |
+
else if(hits.length>0){f.push('Phishing keyword: '+hits[0]);r+=20;}
|
| 632 |
+
|
| 633 |
+
if(!n.startsWith('https')){f.push('No HTTPS — unencrypted connection');r+=15;}
|
| 634 |
+
if(url.length>120){f.push('Unusually long URL');r+=10;}
|
| 635 |
+
|
| 636 |
+
r=Math.min(r,100);
|
| 637 |
+
|
| 638 |
+
// If no red flags found, treat as suspicious (unknown domain)
|
| 639 |
+
if(!f.length){
|
| 640 |
+
f.push('Unknown domain — not verified by Google Safe Browsing');
|
| 641 |
+
r=Math.max(r, 50); // At least medium risk for unknown
|
| 642 |
+
}
|
| 643 |
+
|
| 644 |
+
const vc=r>=70?'r':r>=40?'a':r>=1?'c':'g';
|
| 645 |
+
const v=r>=70?'High Threat':r>=40?'Suspicious':r>=1?'Low Risk':'Safe';
|
| 646 |
+
return{url,r,v,vc,f};
|
| 647 |
+
}
|
| 648 |
+
|
| 649 |
+
/* ── Scoring & detection ──────────────────────────────────────────────── */
|
| 650 |
+
function dTypes(t){
|
| 651 |
+
const tl=t.toLowerCase();
|
| 652 |
+
return Object.entries(SCAM_TYPES)
|
| 653 |
+
.filter(([,kws])=>kws.filter(k=>tl.includes(k)).length>=2)
|
| 654 |
+
.map(([ty])=>ty).slice(0,4);
|
| 655 |
+
}
|
| 656 |
+
function dKws(t){
|
| 657 |
+
const tl=t.toLowerCase();
|
| 658 |
+
return ALL_KW.filter(k=>tl.includes(k)).slice(0,10);
|
| 659 |
+
}
|
| 660 |
+
function score(t){
|
| 661 |
+
const tl=t.toLowerCase(); const w=t.split(/\s+/).length; let s=0;
|
| 662 |
+
s+=Math.min(URGENCY.filter(k=>tl.includes(k)).length*8,28);
|
| 663 |
+
s+=Math.min(THREAT.filter(k=>tl.includes(k)).length*12,40);
|
| 664 |
+
s+=Math.min(PRIZE.filter(k=>tl.includes(k)).length*10,28);
|
| 665 |
+
s+=Math.min(FIN.filter(k=>tl.includes(k)).length*9,30);
|
| 666 |
+
if(/https?:|www\.|bit\.ly|tinyurl/.test(tl))s+=12;
|
| 667 |
+
if(/\botp\b/.test(tl))s+=14;
|
| 668 |
+
if(/\barrest\b|\bwarrant\b/.test(tl))s+=18;
|
| 669 |
+
if(t.split('!').length>2)s+=4;
|
| 670 |
+
if(w<5)s=Math.max(s-16,0);
|
| 671 |
+
return Math.min(Math.round(s),99);
|
| 672 |
+
}
|
| 673 |
+
function getRisk(p){
|
| 674 |
+
if(p>75)return{lv:'High Risk',ii:'fa-solid fa-triangle-exclamation',ic:'var(--red)',vbc:'vb-red',bc:'var(--red)',dot:'var(--red)',cls:'c-danger',w:true};
|
| 675 |
+
if(p>50)return{lv:'Medium Risk',ii:'fa-solid fa-circle-exclamation',ic:'var(--amber)',vbc:'vb-amb',bc:'var(--amber)',dot:'var(--amber)',cls:'c-warning',w:false};
|
| 676 |
+
return{lv:'Low Risk',ii:'fa-solid fa-circle-check',ic:'var(--green)',vbc:'vb-grn',bc:'var(--green)',dot:'var(--green)',cls:'c-success',w:false};
|
| 677 |
+
}
|
| 678 |
+
|
| 679 |
+
/* ── Render helpers ───────────────────────────────────────────────────── */
|
| 680 |
+
function rTypes(types,id){
|
| 681 |
+
const el=document.getElementById(id);
|
| 682 |
+
if(!types.length){el.innerHTML='<span class="tagr tags"><i class="fa-solid fa-circle-check" style="font-size:9px;margin-right:.2rem"></i>No known threat category</span>';return;}
|
| 683 |
+
el.innerHTML=types.map(t=>'<span class="tagr"><i class="fa-solid fa-triangle-exclamation" style="font-size:9px;margin-right:.22rem"></i>'+t+'</span>').join('');
|
| 684 |
+
}
|
| 685 |
+
function rKws(kws,wid,lid){
|
| 686 |
+
const w=document.getElementById(wid),l=document.getElementById(lid);
|
| 687 |
+
if(!kws.length){w.style.display='none';return;}
|
| 688 |
+
w.style.display='block';
|
| 689 |
+
l.innerHTML=kws.map(k=>'<span class="kw">'+k+'</span>').join('');
|
| 690 |
+
}
|
| 691 |
+
function rBar(id,p,c){
|
| 692 |
+
const b=document.getElementById(id);
|
| 693 |
+
b.style.background=c; b.style.width='0%';
|
| 694 |
+
setTimeout(()=>b.style.width=p+'%',200);
|
| 695 |
+
}
|
| 696 |
+
function rUrls(us,wid,cid,lid){
|
| 697 |
+
const w=document.getElementById(wid);
|
| 698 |
+
if(!us.length){w.style.display='none';return;}
|
| 699 |
+
w.style.display='block';
|
| 700 |
+
document.getElementById(cid).textContent=us.length;
|
| 701 |
+
const l=document.getElementById(lid);
|
| 702 |
+
const cm={'r':'vbr','a':'vba','g':'vbg','c':'vbc'};
|
| 703 |
+
const col={'r':'var(--red)','a':'var(--amber)','g':'var(--green)','c':'var(--accent)'};
|
| 704 |
+
l.innerHTML=us.map((r,i)=>`
|
| 705 |
+
<div class="urow">
|
| 706 |
+
<span class="uvb ${cm[r.vc]}">${r.v}</span>
|
| 707 |
+
<div style="flex:1;min-width:0">
|
| 708 |
+
<div class="utxt">${r.url}</div>
|
| 709 |
+
<div class="ubar"><div class="ufill" id="uf${i}" style="width:0%;background:${col[r.vc]}"></div></div>
|
| 710 |
+
<div>${r.f.map(x=>'<div class="uflag"><i class="fa-solid fa-caret-right" style="font-size:7px;color:var(--text3)"></i> '+x+'</div>').join('')}</div>
|
| 711 |
+
</div>
|
| 712 |
+
</div>`).join('');
|
| 713 |
+
setTimeout(()=>us.forEach((r,i)=>{const el=document.getElementById('uf'+i);if(el)el.style.width=r.r+'%';}),380);
|
| 714 |
+
}
|
| 715 |
+
|
| 716 |
+
function renderShap(words) {
|
| 717 |
+
const container = document.getElementById('shap-container');
|
| 718 |
+
if (!words || !words.length) {
|
| 719 |
+
container.innerHTML = '<i>No explanation available</i>';
|
| 720 |
+
return;
|
| 721 |
+
}
|
| 722 |
+
|
| 723 |
+
words.sort((a, b) => Math.abs(b[1]) - Math.abs(a[1]));
|
| 724 |
+
const maxVal = Math.max(...words.map(w => Math.abs(w[1])));
|
| 725 |
+
|
| 726 |
+
let html = '';
|
| 727 |
+
words.slice(0, 10).forEach(w => {
|
| 728 |
+
const word = w[0];
|
| 729 |
+
const val = w[1];
|
| 730 |
+
const pct = (Math.abs(val) / maxVal) * 45;
|
| 731 |
+
const isSpam = val > 0;
|
| 732 |
+
|
| 733 |
+
let barStyle = '';
|
| 734 |
+
if (isSpam) {
|
| 735 |
+
barStyle = `left: 50%; width: ${pct}%; background: var(--red);`;
|
| 736 |
+
} else {
|
| 737 |
+
barStyle = `right: 50%; width: ${pct}%; background: var(--green);`;
|
| 738 |
+
}
|
| 739 |
+
|
| 740 |
+
html += `
|
| 741 |
+
<div class="shap-row">
|
| 742 |
+
<div class="shap-word">${word}</div>
|
| 743 |
+
<div class="shap-bar-container">
|
| 744 |
+
<div style="position: absolute; height: 10px; border-radius: 2px; top: 3px; ${barStyle}"></div>
|
| 745 |
+
</div>
|
| 746 |
+
<div class="shap-val">${val > 0 ? '+' : ''}${val.toFixed(3)}</div>
|
| 747 |
+
</div>
|
| 748 |
+
`;
|
| 749 |
+
});
|
| 750 |
+
|
| 751 |
+
container.innerHTML = html;
|
| 752 |
+
document.getElementById('tshap').style.display = 'block';
|
| 753 |
+
}
|
| 754 |
+
|
| 755 |
+
/* ── Text scan ────────────────────────────────────────────────────────── */
|
| 756 |
+
function rT(){
|
| 757 |
+
const msg=document.getElementById('mi').value.trim();
|
| 758 |
+
document.getElementById('terr').style.display='none';
|
| 759 |
+
if(!msg){document.getElementById('terr').style.display='flex';document.getElementById('terrm').textContent='Please enter a message or URL to analyse.';return;}
|
| 760 |
+
const ov=document.getElementById('ov');
|
| 761 |
+
document.getElementById('ovm').textContent='Running threat analysis…';
|
| 762 |
+
ov.classList.add('on');
|
| 763 |
+
document.getElementById('tshap').style.display = 'none';
|
| 764 |
+
|
| 765 |
+
fetch('/predict', {
|
| 766 |
+
method: 'POST',
|
| 767 |
+
headers: { 'Content-Type': 'application/json' },
|
| 768 |
+
body: JSON.stringify({ message: msg })
|
| 769 |
+
})
|
| 770 |
+
.then(res => res.json())
|
| 771 |
+
.then(data => {
|
| 772 |
+
if(data.error) {
|
| 773 |
+
ov.classList.remove('on');
|
| 774 |
+
document.getElementById('terr').style.display='flex';
|
| 775 |
+
document.getElementById('terrm').textContent=data.error;
|
| 776 |
+
return;
|
| 777 |
+
}
|
| 778 |
+
|
| 779 |
+
let p = Math.round(data.confidence * 100);
|
| 780 |
+
p = Math.min(Math.max(p, 0), 99);
|
| 781 |
+
const rk = getRisk(p);
|
| 782 |
+
|
| 783 |
+
// Update UI with text and model results first
|
| 784 |
+
const tvb=document.getElementById('tvb');
|
| 785 |
+
tvb.className='verdict-badge '+rk.vbc;
|
| 786 |
+
document.getElementById('tico').className=rk.ii;
|
| 787 |
+
document.getElementById('tverdict').textContent=data.label === 'spam' ? 'Threat Detected' : 'Appears Legitimate';
|
| 788 |
+
document.getElementById('tlvl').textContent=rk.lv;
|
| 789 |
+
document.getElementById('trdot').style.background=rk.dot;
|
| 790 |
+
document.getElementById('tmsg').textContent=msg;
|
| 791 |
+
document.getElementById('tpct').textContent=p+'%';
|
| 792 |
+
rBar('tbar',p,rk.bc);
|
| 793 |
+
document.getElementById('talert').style.display=rk.w?'flex':'none';
|
| 794 |
+
document.getElementById('tcard').className='card '+rk.cls;
|
| 795 |
+
document.getElementById('tout').style.display='block';
|
| 796 |
+
document.getElementById('tout').scrollIntoView({behavior:'smooth',block:'start'});
|
| 797 |
+
|
| 798 |
+
// Now analyze URLs asynchronously
|
| 799 |
+
const urlList = xU(msg);
|
| 800 |
+
const ty = dTypes(msg);
|
| 801 |
+
const kw = dKws(msg);
|
| 802 |
+
|
| 803 |
+
rTypes(ty,'ttyp');
|
| 804 |
+
rKws(kw,'tkww','tkws');
|
| 805 |
+
|
| 806 |
+
// Analyze each URL with Safe Browsing API
|
| 807 |
+
Promise.all(urlList.map(aU))
|
| 808 |
+
.then(us => {
|
| 809 |
+
rUrls(us,'turw','turc','turl');
|
| 810 |
+
|
| 811 |
+
// Now fetch explainability
|
| 812 |
+
document.getElementById('ovm').textContent='Generating explainability…';
|
| 813 |
+
fetch('/explain', {
|
| 814 |
+
method: 'POST',
|
| 815 |
+
headers: { 'Content-Type': 'application/json' },
|
| 816 |
+
body: JSON.stringify({ message: msg })
|
| 817 |
+
})
|
| 818 |
+
.then(r => r.json())
|
| 819 |
+
.then(exData => {
|
| 820 |
+
ov.classList.remove('on');
|
| 821 |
+
if(!exData.error) {
|
| 822 |
+
renderShap([...(exData.top_spam_words||[]), ...(exData.top_ham_words||[])]);
|
| 823 |
+
}
|
| 824 |
+
}).catch(e => {
|
| 825 |
+
console.error("SHAP error:", e);
|
| 826 |
+
ov.classList.remove('on');
|
| 827 |
+
});
|
| 828 |
+
})
|
| 829 |
+
.catch(err => {
|
| 830 |
+
console.error("URL analysis error:", err);
|
| 831 |
+
rUrls([],'turw','turc','turl');
|
| 832 |
+
ov.classList.remove('on');
|
| 833 |
+
});
|
| 834 |
+
|
| 835 |
+
})
|
| 836 |
+
.catch(err => {
|
| 837 |
+
ov.classList.remove('on');
|
| 838 |
+
document.getElementById('terr').style.display='flex';
|
| 839 |
+
document.getElementById('terrm').textContent='Failed to connect to backend: ' + err;
|
| 840 |
+
});
|
| 841 |
+
}
|
| 842 |
+
|
| 843 |
+
/* ── Audio scan ───────────────────────────────────────────────────────── */
|
| 844 |
+
function rA(){
|
| 845 |
+
const fi=document.getElementById('ain');
|
| 846 |
+
document.getElementById('aerr').style.display='none';
|
| 847 |
+
if(!fi.files||!fi.files.length){
|
| 848 |
+
document.getElementById('aerr').style.display='flex';
|
| 849 |
+
document.getElementById('aerrm').textContent='Please select a call recording file to analyse.';
|
| 850 |
+
return;
|
| 851 |
+
}
|
| 852 |
+
const f=fi.files[0];
|
| 853 |
+
const ov=document.getElementById('ov');
|
| 854 |
+
document.getElementById('ovm').textContent='Transcribing + analysing call…';
|
| 855 |
+
ov.classList.add('on');
|
| 856 |
+
setTimeout(()=>{
|
| 857 |
+
ov.classList.remove('on');
|
| 858 |
+
const tx=DEMOS[Math.floor(Math.random()*DEMOS.length)];
|
| 859 |
+
const wd=tx.split(' ').length;
|
| 860 |
+
const dr=Math.max(Math.round(f.size/16000),8);
|
| 861 |
+
document.getElementById('atx').textContent=tx;
|
| 862 |
+
document.getElementById('adur').textContent=dr+'s';
|
| 863 |
+
document.getElementById('awrd').textContent=wd+' words';
|
| 864 |
+
let p=score(tx); p=Math.min(p,99);
|
| 865 |
+
const rk=getRisk(p);
|
| 866 |
+
const ty=dTypes(tx);
|
| 867 |
+
const kw=dKws(tx);
|
| 868 |
+
const avb=document.getElementById('avb');
|
| 869 |
+
avb.className='verdict-badge '+rk.vbc;
|
| 870 |
+
document.getElementById('aico').className=rk.ii;
|
| 871 |
+
document.getElementById('averdict').textContent=p>50?'Scam Call Detected':'Call Appears Safe';
|
| 872 |
+
document.getElementById('alvl').textContent=rk.lv;
|
| 873 |
+
document.getElementById('ardot').style.background=rk.dot;
|
| 874 |
+
document.getElementById('apct').textContent=p+'%';
|
| 875 |
+
rBar('abar',p,rk.bc);
|
| 876 |
+
rTypes(ty,'atyp');
|
| 877 |
+
rKws(kw,'akww','akws');
|
| 878 |
+
document.getElementById('aalert').style.display=rk.w?'flex':'none';
|
| 879 |
+
document.getElementById('aout').style.display='block';
|
| 880 |
+
document.getElementById('aout').scrollIntoView({behavior:'smooth',block:'start'});
|
| 881 |
+
},2000);
|
| 882 |
+
}
|
| 883 |
+
|
| 884 |
+
/* ── Clear ────────────────────────────────────────────────────────────── */
|
| 885 |
+
function cT(){
|
| 886 |
+
document.getElementById('mi').value='';
|
| 887 |
+
document.getElementById('tout').style.display='none';
|
| 888 |
+
document.getElementById('terr').style.display='none';
|
| 889 |
+
document.getElementById('tbar').style.width='0%';
|
| 890 |
+
window.scrollTo({top:0,behavior:'smooth'});
|
| 891 |
+
}
|
| 892 |
+
function cA(){
|
| 893 |
+
document.getElementById('ain').value='';
|
| 894 |
+
document.getElementById('fch').style.display='none';
|
| 895 |
+
document.getElementById('dz').classList.remove('has-file');
|
| 896 |
+
document.getElementById('aout').style.display='none';
|
| 897 |
+
document.getElementById('aerr').style.display='none';
|
| 898 |
+
document.getElementById('abar').style.width='0%';
|
| 899 |
+
}
|
| 900 |
+
|
| 901 |
+
/* ── File upload UX ───────────────────────────────────────────────────── */
|
| 902 |
+
document.addEventListener('DOMContentLoaded',()=>{
|
| 903 |
+
const ai=document.getElementById('ain');
|
| 904 |
+
const dz=document.getElementById('dz');
|
| 905 |
+
const fc=document.getElementById('fch');
|
| 906 |
+
const fn=document.getElementById('fn');
|
| 907 |
+
ai.addEventListener('change',()=>{
|
| 908 |
+
if(ai.files.length){
|
| 909 |
+
const f=ai.files[0];
|
| 910 |
+
fn.textContent=f.name+' ('+(f.size/1024/1024).toFixed(2)+' MB)';
|
| 911 |
+
fc.style.display='inline-flex';
|
| 912 |
+
dz.classList.add('has-file');
|
| 913 |
+
}
|
| 914 |
+
});
|
| 915 |
+
dz.addEventListener('dragover',e=>{e.preventDefault();dz.classList.add('over');});
|
| 916 |
+
dz.addEventListener('dragleave',()=>dz.classList.remove('over'));
|
| 917 |
+
dz.addEventListener('drop',e=>{
|
| 918 |
+
e.preventDefault();dz.classList.remove('over');
|
| 919 |
+
if(e.dataTransfer.files.length){
|
| 920 |
+
ai.files=e.dataTransfer.files;
|
| 921 |
+
const f=e.dataTransfer.files[0];
|
| 922 |
+
fn.textContent=f.name+' ('+(f.size/1024/1024).toFixed(2)+' MB)';
|
| 923 |
+
fc.style.display='inline-flex';
|
| 924 |
+
dz.classList.add('has-file');
|
| 925 |
+
}
|
| 926 |
+
});
|
| 927 |
+
document.getElementById('mi').addEventListener('keydown',e=>{
|
| 928 |
+
if(e.key==='Enter'&&e.ctrlKey)rT();
|
| 929 |
+
});
|
| 930 |
+
});
|
| 931 |
+
</script>
|
| 932 |
+
</body>
|
| 933 |
+
</html>
|
smishing_detector/evaluate.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import torch
|
| 2 |
+
import os
|
| 3 |
+
import sys
|
| 4 |
+
from sklearn.metrics import classification_report, confusion_matrix, f1_score, accuracy_score
|
| 5 |
+
|
| 6 |
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
| 7 |
+
|
| 8 |
+
from utils.data_loader import load_dataset, split_dataset
|
| 9 |
+
from predictor import SmishingPredictor
|
| 10 |
+
from adversarial.evasion import run_adversarial_evaluation
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 14 |
+
CHECKPOINT = "best_model.pt"
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def main():
|
| 18 |
+
if not os.path.exists(CHECKPOINT):
|
| 19 |
+
print(f"Error: Model checkpoint '{CHECKPOINT}' not found. Run train.py first.")
|
| 20 |
+
return
|
| 21 |
+
|
| 22 |
+
print("Loading dataset...")
|
| 23 |
+
df = load_dataset()
|
| 24 |
+
|
| 25 |
+
train_df, val_df, test_df = split_dataset(df)
|
| 26 |
+
|
| 27 |
+
print("Loading model...")
|
| 28 |
+
predictor = SmishingPredictor(CHECKPOINT)
|
| 29 |
+
|
| 30 |
+
print("Evaluating on test set...")
|
| 31 |
+
test_messages = test_df['message'].tolist()
|
| 32 |
+
test_labels = test_df['label'].tolist()
|
| 33 |
+
|
| 34 |
+
predictions = []
|
| 35 |
+
for msg in test_messages:
|
| 36 |
+
result = predictor.predict(msg)
|
| 37 |
+
predictions.append(1 if result['label'] == 'spam' else 0)
|
| 38 |
+
|
| 39 |
+
print("\n" + "="*60)
|
| 40 |
+
print("TEST SET RESULTS — SmishingDetector (XLM-RoBERTa + URL Fusion)")
|
| 41 |
+
print("="*60)
|
| 42 |
+
|
| 43 |
+
print("\nClassification Report:")
|
| 44 |
+
print(classification_report(test_labels, predictions, target_names=['ham', 'spam']))
|
| 45 |
+
|
| 46 |
+
cm = confusion_matrix(test_labels, predictions)
|
| 47 |
+
print("Confusion Matrix:")
|
| 48 |
+
print(cm)
|
| 49 |
+
|
| 50 |
+
fpr = cm[0][1] / (cm[0][0] + cm[0][1]) if (cm[0][0] + cm[0][1]) > 0 else 0
|
| 51 |
+
fnr = cm[1][0] / (cm[1][0] + cm[1][1]) if (cm[1][0] + cm[1][1]) > 0 else 0
|
| 52 |
+
|
| 53 |
+
print(f"False Positive Rate: {fpr:.4f} (ham flagged as spam)")
|
| 54 |
+
print(f"False Negative Rate: {fnr:.4f} (spam missed)")
|
| 55 |
+
|
| 56 |
+
test_acc = (sum(1 for p, t in zip(predictions, test_labels) if p == t) / len(test_labels))
|
| 57 |
+
test_f1 = f1_score(test_labels, predictions, zero_division=0)
|
| 58 |
+
|
| 59 |
+
print("\n" + "="*60)
|
| 60 |
+
print("RESULTS SUMMARY")
|
| 61 |
+
print("="*60)
|
| 62 |
+
print(f"{'Metric':<25} {'Value':<20}")
|
| 63 |
+
print("-"*45)
|
| 64 |
+
print(f"{'Accuracy':<25} {f'{test_acc*100:.2f}%':<20}")
|
| 65 |
+
print(f"{'F1 (spam)':<25} {f'{test_f1:.4f}':<20}")
|
| 66 |
+
print(f"{'False Positive Rate':<25} {f'{fpr*100:.2f}%':<20}")
|
| 67 |
+
|
| 68 |
+
print("\n" + "="*60)
|
| 69 |
+
print("ADVERSARIAL ROBUSTNESS")
|
| 70 |
+
print("="*60)
|
| 71 |
+
|
| 72 |
+
adversarial_results = run_adversarial_evaluation(predictor, test_df, spam_only=True)
|
| 73 |
+
|
| 74 |
+
print(f"{'Attack':<15} {'Original F1':<15} {'Attacked F1':<15} {'Drop':<10}")
|
| 75 |
+
print("-"*55)
|
| 76 |
+
for _, row in adversarial_results.iterrows():
|
| 77 |
+
print(f"{row['attack_type']:<15} {row['original_f1']:<15.2f} {row['attacked_f1']:<15.2f} {row['accuracy_drop']:<10.2f}")
|
| 78 |
+
|
| 79 |
+
# ── Multilingual evaluation ───────────────────────────────────────────────
|
| 80 |
+
print("\n" + "="*60)
|
| 81 |
+
print("MULTILINGUAL EVALUATION")
|
| 82 |
+
print("="*60)
|
| 83 |
+
print("Evaluating model on Hindi/Hinglish held-out test data...")
|
| 84 |
+
|
| 85 |
+
try:
|
| 86 |
+
from utils.multilingual_loader import load_all_multilingual_test_data
|
| 87 |
+
|
| 88 |
+
lang_data = load_all_multilingual_test_data()
|
| 89 |
+
|
| 90 |
+
print(f"\n{'Language':<12} {'Accuracy':<12} {'F1':<10} {'FPR':<10}")
|
| 91 |
+
print("-"*45)
|
| 92 |
+
|
| 93 |
+
for lang_name, lang_df in lang_data.items():
|
| 94 |
+
if len(lang_df) == 0:
|
| 95 |
+
continue
|
| 96 |
+
lang_messages = lang_df['message'].tolist()
|
| 97 |
+
lang_labels = lang_df['label'].tolist()
|
| 98 |
+
lang_preds = [1 if predictor.predict(m)['label'] == 'spam' else 0
|
| 99 |
+
for m in lang_messages]
|
| 100 |
+
|
| 101 |
+
lang_acc = accuracy_score(lang_labels, lang_preds)
|
| 102 |
+
lang_f1 = f1_score(lang_labels, lang_preds, zero_division=0)
|
| 103 |
+
lang_cm = confusion_matrix(lang_labels, lang_preds)
|
| 104 |
+
lang_fpr = lang_cm[0][1] / (lang_cm[0][0] + lang_cm[0][1]) \
|
| 105 |
+
if (lang_cm[0][0] + lang_cm[0][1]) > 0 else 0
|
| 106 |
+
|
| 107 |
+
print(f"{lang_name:<12} {lang_acc*100:<12.2f} {lang_f1:<10.4f} {lang_fpr*100:<10.2f}")
|
| 108 |
+
|
| 109 |
+
except Exception as e:
|
| 110 |
+
print(f"Multilingual evaluation failed: {e}")
|
| 111 |
+
|
| 112 |
+
print("\nEvaluation complete!")
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
if __name__ == "__main__":
|
| 116 |
+
main()
|
smishing_detector/evaluate_custom.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Evaluate ScamShield on your own messages.
|
| 3 |
+
|
| 4 |
+
Examples:
|
| 5 |
+
python evaluate_custom.py --input my_sms.csv
|
| 6 |
+
python evaluate_custom.py --input my_sms.csv --output predictions.csv
|
| 7 |
+
|
| 8 |
+
Input can be:
|
| 9 |
+
- CSV/TSV with a "message" column and optional "label" column
|
| 10 |
+
- TXT with one SMS per line
|
| 11 |
+
|
| 12 |
+
Labels, when present, may be: spam/ham, 1/0, scam/safe.
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
import argparse
|
| 16 |
+
import csv
|
| 17 |
+
import os
|
| 18 |
+
import sys
|
| 19 |
+
from pathlib import Path
|
| 20 |
+
|
| 21 |
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
LABEL_MAP = {
|
| 25 |
+
"spam": "spam",
|
| 26 |
+
"scam": "spam",
|
| 27 |
+
"phishing": "spam",
|
| 28 |
+
"smishing": "spam",
|
| 29 |
+
"1": "spam",
|
| 30 |
+
"true": "spam",
|
| 31 |
+
"ham": "ham",
|
| 32 |
+
"safe": "ham",
|
| 33 |
+
"legit": "ham",
|
| 34 |
+
"legitimate": "ham",
|
| 35 |
+
"0": "ham",
|
| 36 |
+
"false": "ham",
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def normalize_label(value):
|
| 41 |
+
if value is None:
|
| 42 |
+
return None
|
| 43 |
+
text = str(value).strip().lower()
|
| 44 |
+
return LABEL_MAP.get(text)
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def sniff_delimiter(path):
|
| 48 |
+
suffix = path.suffix.lower()
|
| 49 |
+
if suffix == ".tsv":
|
| 50 |
+
return "\t"
|
| 51 |
+
return ","
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def load_rows(input_path):
|
| 55 |
+
path = Path(input_path)
|
| 56 |
+
if not path.exists():
|
| 57 |
+
raise FileNotFoundError(f"Input file not found: {path}")
|
| 58 |
+
|
| 59 |
+
if path.suffix.lower() == ".txt":
|
| 60 |
+
rows = []
|
| 61 |
+
with path.open("r", encoding="utf-8-sig") as f:
|
| 62 |
+
for line in f:
|
| 63 |
+
message = line.strip()
|
| 64 |
+
if message:
|
| 65 |
+
rows.append({"message": message, "label": None})
|
| 66 |
+
return rows
|
| 67 |
+
|
| 68 |
+
with path.open("r", encoding="utf-8-sig", newline="") as f:
|
| 69 |
+
reader = csv.DictReader(f, delimiter=sniff_delimiter(path))
|
| 70 |
+
if not reader.fieldnames or "message" not in reader.fieldnames:
|
| 71 |
+
raise ValueError('CSV/TSV input must contain a "message" column.')
|
| 72 |
+
|
| 73 |
+
rows = []
|
| 74 |
+
for row in reader:
|
| 75 |
+
message = (row.get("message") or "").strip()
|
| 76 |
+
if not message:
|
| 77 |
+
continue
|
| 78 |
+
rows.append({
|
| 79 |
+
"message": message,
|
| 80 |
+
"label": normalize_label(row.get("label")),
|
| 81 |
+
})
|
| 82 |
+
return rows
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def evaluate(rows, checkpoint):
|
| 86 |
+
from predictor import SmishingPredictor
|
| 87 |
+
|
| 88 |
+
predictor = SmishingPredictor(checkpoint)
|
| 89 |
+
results = []
|
| 90 |
+
|
| 91 |
+
for row in rows:
|
| 92 |
+
pred = predictor.predict(row["message"])
|
| 93 |
+
expected = row.get("label")
|
| 94 |
+
got = pred["label"]
|
| 95 |
+
correct = None if expected is None else expected == got
|
| 96 |
+
results.append({
|
| 97 |
+
"expected": expected or "",
|
| 98 |
+
"predicted": got,
|
| 99 |
+
"correct": "" if correct is None else str(correct).lower(),
|
| 100 |
+
"confidence": pred["confidence"],
|
| 101 |
+
"risk_level": pred["risk_level"],
|
| 102 |
+
"language": pred.get("language", ""),
|
| 103 |
+
"neural_score": pred.get("_neural_score", ""),
|
| 104 |
+
"rule_score": pred.get("_rule_score", ""),
|
| 105 |
+
"ham_rule_score": pred.get("_ham_rule_score", ""),
|
| 106 |
+
"rule_weight": pred.get("_rule_weight", ""),
|
| 107 |
+
"message": row["message"],
|
| 108 |
+
})
|
| 109 |
+
|
| 110 |
+
return results
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def print_summary(results):
|
| 114 |
+
labelled = [r for r in results if r["expected"]]
|
| 115 |
+
print(f"\nEvaluated {len(results)} messages")
|
| 116 |
+
|
| 117 |
+
if labelled:
|
| 118 |
+
correct = sum(1 for r in labelled if r["correct"] == "true")
|
| 119 |
+
spam_total = sum(1 for r in labelled if r["expected"] == "spam")
|
| 120 |
+
ham_total = sum(1 for r in labelled if r["expected"] == "ham")
|
| 121 |
+
false_pos = sum(1 for r in labelled if r["expected"] == "ham" and r["predicted"] == "spam")
|
| 122 |
+
false_neg = sum(1 for r in labelled if r["expected"] == "spam" and r["predicted"] == "ham")
|
| 123 |
+
|
| 124 |
+
print(f"Labelled accuracy: {correct}/{len(labelled)} ({correct / len(labelled):.1%})")
|
| 125 |
+
print(f"Ham labelled: {ham_total}, spam labelled: {spam_total}")
|
| 126 |
+
print(f"False positives: {false_pos}, false negatives: {false_neg}")
|
| 127 |
+
|
| 128 |
+
print("\nTop misses / borderline cases:")
|
| 129 |
+
misses = [r for r in results if r["correct"] == "false"]
|
| 130 |
+
borderline = sorted(
|
| 131 |
+
results,
|
| 132 |
+
key=lambda r: abs(float(r["confidence"]) - 0.55),
|
| 133 |
+
)[:10]
|
| 134 |
+
for r in (misses[:10] or borderline[:10]):
|
| 135 |
+
expected = r["expected"] or "?"
|
| 136 |
+
print(
|
| 137 |
+
f"- exp={expected:<4} got={r['predicted']:<4} "
|
| 138 |
+
f"conf={float(r['confidence']):.2f} "
|
| 139 |
+
f"neural={r['neural_score']} rule={r['rule_score']} ham_rule={r['ham_rule_score']} "
|
| 140 |
+
f"lang={r['language']} :: {r['message'][:120]}"
|
| 141 |
+
)
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def write_results(results, output_path):
|
| 145 |
+
fields = [
|
| 146 |
+
"expected", "predicted", "correct", "confidence", "risk_level",
|
| 147 |
+
"language", "neural_score", "rule_score", "ham_rule_score", "rule_weight", "message",
|
| 148 |
+
]
|
| 149 |
+
with Path(output_path).open("w", encoding="utf-8", newline="") as f:
|
| 150 |
+
writer = csv.DictWriter(f, fieldnames=fields)
|
| 151 |
+
writer.writeheader()
|
| 152 |
+
writer.writerows(results)
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
def main():
|
| 156 |
+
parser = argparse.ArgumentParser()
|
| 157 |
+
parser.add_argument("--input", required=True, help="CSV/TSV/TXT file of messages")
|
| 158 |
+
parser.add_argument("--output", help="Optional CSV path for detailed predictions")
|
| 159 |
+
parser.add_argument(
|
| 160 |
+
"--checkpoint",
|
| 161 |
+
default=os.path.join(os.path.dirname(os.path.abspath(__file__)), "best_model.pt"),
|
| 162 |
+
help="Path to best_model.pt",
|
| 163 |
+
)
|
| 164 |
+
args = parser.parse_args()
|
| 165 |
+
|
| 166 |
+
rows = load_rows(args.input)
|
| 167 |
+
results = evaluate(rows, args.checkpoint)
|
| 168 |
+
print_summary(results)
|
| 169 |
+
|
| 170 |
+
if args.output:
|
| 171 |
+
write_results(results, args.output)
|
| 172 |
+
print(f"\nSaved detailed predictions to {args.output}")
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
if __name__ == "__main__":
|
| 176 |
+
main()
|
smishing_detector/evaluate_multilingual.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
evaluate_multilingual.py
|
| 3 |
+
------------------------
|
| 4 |
+
Multilingual evaluation script.
|
| 5 |
+
Tests the smishing detector on Hindi and Hinglish messages.
|
| 6 |
+
|
| 7 |
+
Run:
|
| 8 |
+
python evaluate_multilingual.py
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
import torch
|
| 12 |
+
import numpy as np
|
| 13 |
+
from sklearn.metrics import (
|
| 14 |
+
classification_report, confusion_matrix,
|
| 15 |
+
f1_score, accuracy_score
|
| 16 |
+
)
|
| 17 |
+
import sys, os
|
| 18 |
+
|
| 19 |
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
| 20 |
+
|
| 21 |
+
from predictor import SmishingPredictor
|
| 22 |
+
from utils.multilingual_loader import load_all_multilingual_test_data
|
| 23 |
+
|
| 24 |
+
CHECKPOINT = "best_model.pt"
|
| 25 |
+
THRESHOLD = 0.5
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def evaluate_language_subset(predictor, df, language_name: str):
|
| 29 |
+
"""Run evaluation on a single language subset."""
|
| 30 |
+
if len(df) == 0:
|
| 31 |
+
print(f" No {language_name} data available")
|
| 32 |
+
return None
|
| 33 |
+
|
| 34 |
+
messages = df['message'].tolist()
|
| 35 |
+
true_labels = df['label'].tolist()
|
| 36 |
+
|
| 37 |
+
predictions = []
|
| 38 |
+
confidences = []
|
| 39 |
+
|
| 40 |
+
for msg in messages:
|
| 41 |
+
result = predictor.predict(msg)
|
| 42 |
+
predictions.append(1 if result['label'] == 'spam' else 0)
|
| 43 |
+
confidences.append(result['confidence'])
|
| 44 |
+
|
| 45 |
+
acc = accuracy_score(true_labels, predictions)
|
| 46 |
+
f1 = f1_score(true_labels, predictions, zero_division=0)
|
| 47 |
+
cm = confusion_matrix(true_labels, predictions)
|
| 48 |
+
|
| 49 |
+
fpr = 0.0
|
| 50 |
+
fnr = 0.0
|
| 51 |
+
if cm.shape == (2, 2):
|
| 52 |
+
fpr = cm[0][1] / (cm[0][0] + cm[0][1]) if (cm[0][0] + cm[0][1]) > 0 else 0
|
| 53 |
+
fnr = cm[1][0] / (cm[1][0] + cm[1][1]) if (cm[1][0] + cm[1][1]) > 0 else 0
|
| 54 |
+
|
| 55 |
+
return {
|
| 56 |
+
'language': language_name,
|
| 57 |
+
'total': len(df),
|
| 58 |
+
'spam': int(df['label'].sum()),
|
| 59 |
+
'ham': len(df) - int(df['label'].sum()),
|
| 60 |
+
'accuracy': acc,
|
| 61 |
+
'f1': f1,
|
| 62 |
+
'fpr': fpr,
|
| 63 |
+
'fnr': fnr,
|
| 64 |
+
'confusion_matrix': cm,
|
| 65 |
+
'predictions': predictions,
|
| 66 |
+
'true_labels': true_labels
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def main():
|
| 71 |
+
if not os.path.exists(CHECKPOINT):
|
| 72 |
+
print(f"Error: '{CHECKPOINT}' not found. Run train.py first.")
|
| 73 |
+
return
|
| 74 |
+
|
| 75 |
+
print("Loading model...")
|
| 76 |
+
predictor = SmishingPredictor(CHECKPOINT)
|
| 77 |
+
|
| 78 |
+
print("\nLoading multilingual test data...")
|
| 79 |
+
lang_data = load_all_multilingual_test_data()
|
| 80 |
+
|
| 81 |
+
results = {}
|
| 82 |
+
for lang_name, df in lang_data.items():
|
| 83 |
+
results[lang_name] = evaluate_language_subset(predictor, df, lang_name)
|
| 84 |
+
|
| 85 |
+
# ── Print results ──────────────────────────────────────────────────────
|
| 86 |
+
print("\n" + "="*65)
|
| 87 |
+
print(" MULTILINGUAL EVALUATION RESULTS")
|
| 88 |
+
print(" Model tested on: Hindi + Hinglish messages")
|
| 89 |
+
print("="*65)
|
| 90 |
+
|
| 91 |
+
print(f"\n{'Language':<15} {'Messages':<10} {'Accuracy':<12} {'F1':<10} {'FPR':<10} {'FNR':<10}")
|
| 92 |
+
print("-"*65)
|
| 93 |
+
|
| 94 |
+
for lang, result in results.items():
|
| 95 |
+
if result:
|
| 96 |
+
print(f"{result['language']:<15} "
|
| 97 |
+
f"{result['total']:<10} "
|
| 98 |
+
f"{result['accuracy']*100:<12.2f} "
|
| 99 |
+
f"{result['f1']:<10.4f} "
|
| 100 |
+
f"{result['fpr']*100:<10.2f} "
|
| 101 |
+
f"{result['fnr']*100:<10.2f}")
|
| 102 |
+
|
| 103 |
+
print("\n" + "="*65)
|
| 104 |
+
print(" TARGET F1 CHECK")
|
| 105 |
+
print("="*65)
|
| 106 |
+
print(f"{'Language':<15} {'F1 Score':<12} {'Beats 0.85 target?':<20}")
|
| 107 |
+
print("-"*45)
|
| 108 |
+
for lang, result in results.items():
|
| 109 |
+
if result:
|
| 110 |
+
beats = "✓ YES" if result['f1'] >= 0.85 else "✗ NO"
|
| 111 |
+
print(f"{result['language']:<15} {result['f1']:<12.4f} {beats:<20}")
|
| 112 |
+
|
| 113 |
+
print("\n" + "="*65)
|
| 114 |
+
print(" DETAILED CLASSIFICATION REPORTS")
|
| 115 |
+
print("="*65)
|
| 116 |
+
|
| 117 |
+
for lang, result in results.items():
|
| 118 |
+
if result:
|
| 119 |
+
print(f"\n--- {result['language'].upper()} ---")
|
| 120 |
+
print(classification_report(
|
| 121 |
+
result['true_labels'],
|
| 122 |
+
result['predictions'],
|
| 123 |
+
target_names=['ham', 'spam']
|
| 124 |
+
))
|
| 125 |
+
print(f"Confusion Matrix:\n{result['confusion_matrix']}")
|
| 126 |
+
|
| 127 |
+
print("\n" + "="*65)
|
| 128 |
+
print(" F1 VERIFICATION")
|
| 129 |
+
print("="*65)
|
| 130 |
+
combined = results.get('combined')
|
| 131 |
+
if combined:
|
| 132 |
+
print(f"\nCombined Hindi+Hinglish F1 = {combined['f1']:.4f}")
|
| 133 |
+
print(f"Target (>0.85): {'MET ✓' if combined['f1'] >= 0.85 else 'NOT MET ✗'}")
|
| 134 |
+
|
| 135 |
+
print("\nZero-shot evaluation complete!")
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
if __name__ == "__main__":
|
| 139 |
+
main()
|
smishing_detector/explainability/__init__.py
ADDED
|
File without changes
|
smishing_detector/explainability/shap_explainer.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import numpy as np
|
| 2 |
+
import torch
|
| 3 |
+
import shap
|
| 4 |
+
import matplotlib.pyplot as plt
|
| 5 |
+
from matplotlib import colors as mcolors
|
| 6 |
+
import os
|
| 7 |
+
import sys
|
| 8 |
+
|
| 9 |
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 10 |
+
|
| 11 |
+
from predictor import SmishingPredictor
|
| 12 |
+
from utils.data_loader import url_features, text_features, clean_text, get_feature_columns
|
| 13 |
+
from models.model import get_tokenizer, MAX_LEN
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class SmishingExplainer:
|
| 17 |
+
def __init__(self, predictor: SmishingPredictor):
|
| 18 |
+
self.predictor = predictor
|
| 19 |
+
|
| 20 |
+
def _get_prediction_function(self):
|
| 21 |
+
def predict_fn(texts):
|
| 22 |
+
results = []
|
| 23 |
+
for text in texts:
|
| 24 |
+
cleaned = clean_text(text)
|
| 25 |
+
|
| 26 |
+
encoding = self.predictor.tokenizer(
|
| 27 |
+
cleaned,
|
| 28 |
+
max_length=MAX_LEN,
|
| 29 |
+
padding='max_length',
|
| 30 |
+
truncation=True,
|
| 31 |
+
return_tensors='pt'
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
input_ids = encoding['input_ids'].to(self.predictor.device)
|
| 35 |
+
attention_mask = encoding['attention_mask'].to(self.predictor.device)
|
| 36 |
+
|
| 37 |
+
url_feats = url_features(text)
|
| 38 |
+
text_feats = text_features(text)
|
| 39 |
+
|
| 40 |
+
feature_cols = get_feature_columns()
|
| 41 |
+
combined = {**url_feats, **text_feats}
|
| 42 |
+
raw = np.array([[combined[col] for col in feature_cols]], dtype=np.float32)
|
| 43 |
+
norm = (raw - self.predictor.feature_mins) / self.predictor.feature_ranges
|
| 44 |
+
hand_features = torch.from_numpy(norm).to(self.predictor.device)
|
| 45 |
+
|
| 46 |
+
prob = self.predictor.model.predict_proba(input_ids, attention_mask, hand_features).item()
|
| 47 |
+
results.append(prob)
|
| 48 |
+
|
| 49 |
+
return np.array(results)
|
| 50 |
+
|
| 51 |
+
return predict_fn
|
| 52 |
+
|
| 53 |
+
def explain_text(self, message: str, num_features: int = 10) -> dict:
|
| 54 |
+
predict_fn = self._get_prediction_function()
|
| 55 |
+
|
| 56 |
+
tokenizer = self.predictor.tokenizer
|
| 57 |
+
masker = shap.maskers.Text(tokenizer)
|
| 58 |
+
|
| 59 |
+
explainer = shap.Explainer(predict_fn, masker)
|
| 60 |
+
|
| 61 |
+
shap_values = explainer([message])
|
| 62 |
+
|
| 63 |
+
tokens = [str(t) for t in shap_values.data[0]]
|
| 64 |
+
values = shap_values.values[0]
|
| 65 |
+
|
| 66 |
+
token_importance = list(zip(tokens, values))
|
| 67 |
+
token_importance.sort(key=lambda x: x[1], reverse=True)
|
| 68 |
+
|
| 69 |
+
top_spam = [(t, v) for t, v in token_importance if v > 0][:num_features]
|
| 70 |
+
top_ham = [(t, v) for t, v in token_importance if v < 0][-num_features:]
|
| 71 |
+
top_ham.sort(key=lambda x: x[1])
|
| 72 |
+
|
| 73 |
+
feature_cols = get_feature_columns()
|
| 74 |
+
url_feats = url_features(message)
|
| 75 |
+
text_feats = text_features(message)
|
| 76 |
+
combined = {**url_feats, **text_feats}
|
| 77 |
+
|
| 78 |
+
feature_importances = {col: combined[col] for col in feature_cols}
|
| 79 |
+
|
| 80 |
+
return {
|
| 81 |
+
"top_spam_words": top_spam,
|
| 82 |
+
"top_ham_words": top_ham,
|
| 83 |
+
"feature_importances": feature_importances
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
def plot_explanation(self, message: str) -> plt.Figure:
|
| 87 |
+
explanation = self.explain_text(message, num_features=10)
|
| 88 |
+
|
| 89 |
+
fig, ax = plt.subplots(figsize=(10, 6))
|
| 90 |
+
|
| 91 |
+
all_words = explanation["top_spam_words"] + explanation["top_ham_words"]
|
| 92 |
+
all_words.sort(key=lambda x: x[1], reverse=True)
|
| 93 |
+
|
| 94 |
+
words = [w for w, v in all_words[:10]]
|
| 95 |
+
values = [v for w, v in all_words[:10]]
|
| 96 |
+
|
| 97 |
+
colors = ['#d62728' if v > 0 else '#2ca02c' for v in values]
|
| 98 |
+
|
| 99 |
+
ax.barh(words, values, color=colors)
|
| 100 |
+
ax.axvline(x=0, color='black', linestyle='-', linewidth=0.5)
|
| 101 |
+
ax.set_xlabel('SHAP Value')
|
| 102 |
+
ax.set_title('SHAP Explanation for Spam Detection')
|
| 103 |
+
|
| 104 |
+
plt.tight_layout()
|
| 105 |
+
return fig
|
smishing_detector/manual_test.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
manual_test.py — Comprehensive model testing across all categories.
|
| 3 |
+
|
| 4 |
+
Run with:
|
| 5 |
+
cd smishing_detector && python manual_test.py
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import sys, os
|
| 9 |
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
| 10 |
+
from predictor import SmishingPredictor
|
| 11 |
+
|
| 12 |
+
MODEL_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "best_model.pt")
|
| 13 |
+
|
| 14 |
+
# Format: (category, expected_label, message)
|
| 15 |
+
# expected_label: "spam" or "ham"
|
| 16 |
+
TEST_CASES = [
|
| 17 |
+
|
| 18 |
+
# ── English SPAM (phishing) ───────────────────────────────────────────────
|
| 19 |
+
("EN-SPAM", "spam", "URGENT: Your bank account has been suspended. Click http://verify-bank.xyz/login to reactivate immediately."),
|
| 20 |
+
("EN-SPAM", "spam", "Congratulations! You've won a $10,000 Amazon gift card. Claim now: http://prize-claim.top/win?id=8821"),
|
| 21 |
+
("EN-SPAM", "spam", "Your package could not be delivered. Pay $2.99 customs fee at http://dhl-fee.net/track or it will be returned."),
|
| 22 |
+
("EN-SPAM", "spam", "HMRC: You have a tax refund of £842.50 pending. Claim at http://hmrc-refund.co/apply before it expires."),
|
| 23 |
+
("EN-SPAM", "spam", "Your Netflix account will be suspended. Update payment details: http://netflix-billing.info/update"),
|
| 24 |
+
("EN-SPAM", "spam", "Your SBI account is blocked due to KYC mismatch. Verify now: http://sbi-kyc-verify.net/update or lose access."),
|
| 25 |
+
("EN-SPAM", "spam", "CONGRATULATIONS! You have been selected as the lucky winner of our Rs.5,00,000 lottery. Call: 9800012345"),
|
| 26 |
+
("EN-SPAM", "spam", "Your PAN card has been deactivated. Reactivate at http://pan-reactivate.online/verify with your Aadhaar."),
|
| 27 |
+
|
| 28 |
+
# ── English HAM (legit) ───────────────────────────────────────────────────
|
| 29 |
+
("EN-HAM", "ham", "Your Amazon order of Wireless Headphones has been shipped. Expected delivery: Friday, May 31."),
|
| 30 |
+
("EN-HAM", "ham", "HDFC Bank: Your a/c XX4521 credited Rs.25,000 on 30-May. Avl Bal: Rs.1,42,356. -HDFC Bank"),
|
| 31 |
+
("EN-HAM", "ham", "Your Airtel recharge of Rs.239 is successful. Validity: 28 days. Data: 1.5GB/day."),
|
| 32 |
+
("EN-HAM", "ham", "Your OTP for SBI net banking login is 847261. Valid for 10 minutes. Do not share with anyone."),
|
| 33 |
+
("EN-HAM", "ham", "Dear Customer, your IRCTC ticket PNR 4892731 for 31-May (New Delhi to Mumbai) is confirmed."),
|
| 34 |
+
("EN-HAM", "ham", "PhonePe: Rs.500 received from Rahul Kumar on 30-May at 3:45 PM. UPI Ref: 4893721019."),
|
| 35 |
+
("EN-HAM", "ham", "Flipkart: Your order #OD11234 has been delivered. Rate your experience at flipkart.com/orders"),
|
| 36 |
+
("EN-HAM", "ham", "Zepto: Your order of Milk, Eggs, Bread has been dispatched. Arriving in 10 minutes!"),
|
| 37 |
+
|
| 38 |
+
# ── Hindi SPAM ────────────────────────────────────────────────────────────
|
| 39 |
+
("HI-SPAM", "spam", "प्रिय ग्राहक, आपका बिजली कनेक्शन आज रात 9:30 बजे काट दिया जाएगा। अभी कॉल करें: 9876543210"),
|
| 40 |
+
("HI-SPAM", "spam", "आपके खाते में कोई संदिग्ध गतिविधि देखी गई है। KYC अपडेट करें: http://sbi-kyc-update.xyz"),
|
| 41 |
+
("HI-SPAM", "spam", "आयकर विभाग: आपके खिलाफ FIR दर्ज होने वाली है। तत्काल संपर्क करें: 9911000000"),
|
| 42 |
+
|
| 43 |
+
# ── Hinglish SPAM ─────────────────────────────────────────────────────────
|
| 44 |
+
("HL-SPAM", "spam", "Aapka electricity connection aaj raat 9:30 PM ko band ho jayega. Turant call karein: 98765XXXXX"),
|
| 45 |
+
("HL-SPAM", "spam", "Aapka SBI bank account suspend ho gaya hai. Abhi KYC kijiye nahi to permanently band ho jayega: http://sbi-kyc.xyz"),
|
| 46 |
+
("HL-SPAM", "spam", "Congratulations! Aapne 50,000 rupaye ka lucky draw jeeta hai. Claim karne ke liye call karein: 9000012345"),
|
| 47 |
+
("HL-SPAM", "spam", "CBI notice: Aapke naam pe money laundering case darj hua hai. Digital arrest se bachne ke liye turant contact karein officer ko."),
|
| 48 |
+
("HL-SPAM", "spam", "Aapka UPI ID expire ho raha hai. 2 ghante mein renew karein: http://upi-renew.club/verify"),
|
| 49 |
+
("HL-SPAM", "spam", "Income Tax Department: Aapke account mein suspicious transaction mili hai. FIR darj hogi. Abhi call karein: 9912345678"),
|
| 50 |
+
("HL-SPAM", "spam", "Aapka Aadhaar card block ho jayega 24 ghante mein. Update karein: http://uidai-update.xyz/verify"),
|
| 51 |
+
|
| 52 |
+
# ── Legit Indian SMS (false-positive risk) ────────────────────────────────
|
| 53 |
+
("IN-HAM", "ham", "Dear Customer, your HDFC Bank a/c XX9821 credited Rs.12,500.00 on 30-May. Avl bal: Rs.87,432.10."),
|
| 54 |
+
("IN-HAM", "ham", "Airtel: Congratulations! Your mobile number is eligible for a free 5G SIM upgrade. Visit your nearest Airtel store."),
|
| 55 |
+
("IN-HAM", "ham", "IRCTC: OTP for your ticket booking is 739182. Valid for 5 minutes. Do NOT share with anyone."),
|
| 56 |
+
("IN-HAM", "ham", "Bijli Bill: Aapka MSEB bijli bill Rs.1,847 generate ho gaya hai. Due date: 10-Jun-26. Pay at mseb.co.in"),
|
| 57 |
+
("IN-HAM", "ham", "Paytm: Your order #ORD884521 of Rs.1,299 has been placed. Track at paytm.com/orders."),
|
| 58 |
+
("IN-HAM", "ham", "Jio: Your plan of Rs.299 has been renewed. 1.5GB/day for 28 days. Thanks for choosing Jio!"),
|
| 59 |
+
|
| 60 |
+
# ── Adversarial / edge cases (no expected label — note in output only) ────
|
| 61 |
+
("EDGE", None, "Click here http://bit.ly/3xK9Lmn"),
|
| 62 |
+
("EDGE", None, "Call back on this number immediately: 9876543210"),
|
| 63 |
+
("EDGE", None, "Free Recharge! Get Rs.1000 talktime free. Limited offer. Reply YES to claim."),
|
| 64 |
+
("EDGE", None, "Dear Sir, your loan of Rs.5,00,000 at 8.5% p.a. is approved. Contact us to proceed."),
|
| 65 |
+
("EDGE", None, "Your SBI account is safe. No action needed. This is a test message from SBI fraud team."),
|
| 66 |
+
|
| 67 |
+
# ── GREEN CHANNEL tests: promo/ad spam with legit URLs should NOT be "spam" ─
|
| 68 |
+
# These are ads/promos (spammy language) but NOT scams
|
| 69 |
+
("GREEN", "safe", "Congratulations! You're eligible for a special cashback offer on Amazon. Shop now at amazon.in/offers"),
|
| 70 |
+
("GREEN", "safe", "Flipkart Big Billion Sale! Exclusive deals for you. Up to 80% off! Visit flipkart.com/sale"),
|
| 71 |
+
("GREEN", "safe", "WINNER! You've been selected for a special Netflix upgrade. Enjoy 3 months free at netflix.com/upgrade"),
|
| 72 |
+
|
| 73 |
+
# These ARE scams — green channel must NOT clear them
|
| 74 |
+
("GN-SCAM", "spam", "WINNER! You've been selected for Rs.5,00,000 prize. Claim at http://prize-winner.xyz/claim"),
|
| 75 |
+
("GN-SCAM", "spam", "Your Netflix account will be suspended. Update payment details: http://netflix-billing.info/update"),
|
| 76 |
+
("GN-SCAM", "spam", "Amazon: Your account is locked. Verify immediately at http://amaz0n-verify.top/login"),
|
| 77 |
+
]
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def run_tests():
|
| 81 |
+
print(f"Loading model from {MODEL_PATH}...")
|
| 82 |
+
predictor = SmishingPredictor(MODEL_PATH)
|
| 83 |
+
print()
|
| 84 |
+
|
| 85 |
+
stats = {"correct": 0, "total": 0, "by_cat": {}}
|
| 86 |
+
|
| 87 |
+
header = f"{'Category':<10} {'Exp':<5} {'Got':<5} {'Conf':>6} {'Risk':<8} {'Lang':<9} {'Status'}"
|
| 88 |
+
print(header)
|
| 89 |
+
print("─" * len(header))
|
| 90 |
+
|
| 91 |
+
for category, expected, text in TEST_CASES:
|
| 92 |
+
result = predictor.predict(text)
|
| 93 |
+
got_label = result["label"]
|
| 94 |
+
conf = result["confidence"]
|
| 95 |
+
risk = result.get("risk_level", "?")
|
| 96 |
+
lang = result.get("language", "?")
|
| 97 |
+
|
| 98 |
+
if expected is None:
|
| 99 |
+
status = "EDGE"
|
| 100 |
+
else:
|
| 101 |
+
# 'safe' (green channel) counts as correct when expected is 'ham' or 'safe'
|
| 102 |
+
if expected in ("ham", "safe"):
|
| 103 |
+
correct = got_label in ("ham", "safe")
|
| 104 |
+
else:
|
| 105 |
+
correct = got_label == expected
|
| 106 |
+
status = "\u2713" if correct else "\u2717 WRONG"
|
| 107 |
+
cat_key = category.split("-")[0]
|
| 108 |
+
stats["total"] += 1
|
| 109 |
+
stats["correct"] += correct
|
| 110 |
+
stats["by_cat"].setdefault(cat_key, [0, 0])
|
| 111 |
+
stats["by_cat"][cat_key][0] += correct
|
| 112 |
+
stats["by_cat"][cat_key][1] += 1
|
| 113 |
+
|
| 114 |
+
exp_str = (expected or "?").upper()[:4]
|
| 115 |
+
got_str = got_label.upper()[:4]
|
| 116 |
+
gc = result.get("green_channel", False)
|
| 117 |
+
gc_tag = " [GC]" if gc else ""
|
| 118 |
+
print(f"{category:<10} {exp_str:<5} {got_str:<5} {conf:>6.1%} {risk:<8} {lang:<9} {status}{gc_tag}")
|
| 119 |
+
if status == "\u2717 WRONG":
|
| 120 |
+
print(f" \u21b3 {text[:90]}{'...' if len(text)>90 else ''}")
|
| 121 |
+
|
| 122 |
+
print("─" * len(header))
|
| 123 |
+
acc = stats["correct"] / stats["total"] * 100 if stats["total"] else 0
|
| 124 |
+
print(f"\nOverall: {stats['correct']}/{stats['total']} ({acc:.1f}%)\n")
|
| 125 |
+
print("By category:")
|
| 126 |
+
for cat, (ok, total) in sorted(stats["by_cat"].items()):
|
| 127 |
+
pct = ok / total * 100
|
| 128 |
+
bar = "█" * int(pct // 10) + "░" * (10 - int(pct // 10))
|
| 129 |
+
print(f" {cat:<6} [{bar}] {pct:5.1f}% ({ok}/{total})")
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
if __name__ == "__main__":
|
| 133 |
+
run_tests()
|
smishing_detector/models/__init__.py
ADDED
|
File without changes
|
smishing_detector/models/dataset.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import numpy as np
|
| 2 |
+
import pandas as pd
|
| 3 |
+
import torch
|
| 4 |
+
from torch.utils.data import DataLoader, Dataset
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class SMSDataset(Dataset):
|
| 8 |
+
def __init__(
|
| 9 |
+
self,
|
| 10 |
+
df: pd.DataFrame,
|
| 11 |
+
tokenizer,
|
| 12 |
+
max_len: int = 128,
|
| 13 |
+
feature_mins: np.ndarray = None,
|
| 14 |
+
feature_ranges: np.ndarray = None,
|
| 15 |
+
):
|
| 16 |
+
from utils.data_loader import get_feature_columns
|
| 17 |
+
|
| 18 |
+
self.texts = df["message"].astype(str).tolist()
|
| 19 |
+
self.labels = df["label"].values.astype(np.float32)
|
| 20 |
+
self.tokenizer = tokenizer
|
| 21 |
+
self.max_len = max_len
|
| 22 |
+
|
| 23 |
+
feature_cols = get_feature_columns()
|
| 24 |
+
missing = [col for col in feature_cols if col not in df.columns]
|
| 25 |
+
if missing:
|
| 26 |
+
raise ValueError(f"Missing feature columns: {missing}")
|
| 27 |
+
|
| 28 |
+
features = df[feature_cols].values.astype(np.float32)
|
| 29 |
+
features = np.nan_to_num(features, nan=0.0, posinf=0.0, neginf=0.0)
|
| 30 |
+
|
| 31 |
+
if feature_mins is None or feature_ranges is None:
|
| 32 |
+
self.feature_mins = features.min(axis=0)
|
| 33 |
+
ranges = features.max(axis=0) - self.feature_mins
|
| 34 |
+
self.feature_ranges = np.where(ranges == 0, 1.0, ranges).astype(np.float32)
|
| 35 |
+
else:
|
| 36 |
+
self.feature_mins = np.asarray(feature_mins, dtype=np.float32)
|
| 37 |
+
self.feature_ranges = np.asarray(feature_ranges, dtype=np.float32)
|
| 38 |
+
self.feature_ranges = np.where(self.feature_ranges == 0, 1.0, self.feature_ranges)
|
| 39 |
+
|
| 40 |
+
features = (features - self.feature_mins) / self.feature_ranges
|
| 41 |
+
features = np.nan_to_num(features, nan=0.0, posinf=3.0, neginf=-2.0)
|
| 42 |
+
self.features = np.clip(features, -2.0, 3.0).astype(np.float32)
|
| 43 |
+
|
| 44 |
+
def __len__(self):
|
| 45 |
+
return len(self.texts)
|
| 46 |
+
|
| 47 |
+
def __getitem__(self, idx):
|
| 48 |
+
encoding = self.tokenizer(
|
| 49 |
+
self.texts[idx],
|
| 50 |
+
max_length=self.max_len,
|
| 51 |
+
padding="max_length",
|
| 52 |
+
truncation=True,
|
| 53 |
+
return_tensors="pt",
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
return {
|
| 57 |
+
"input_ids": encoding["input_ids"].squeeze(0),
|
| 58 |
+
"attention_mask": encoding["attention_mask"].squeeze(0),
|
| 59 |
+
"hand_features": torch.from_numpy(self.features[idx]),
|
| 60 |
+
"label": torch.tensor(self.labels[idx], dtype=torch.float32),
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def build_dataloaders(train_df, val_df, test_df, tokenizer, batch_size=32):
|
| 65 |
+
train_dataset = SMSDataset(train_df, tokenizer)
|
| 66 |
+
val_dataset = SMSDataset(
|
| 67 |
+
val_df,
|
| 68 |
+
tokenizer,
|
| 69 |
+
feature_mins=train_dataset.feature_mins,
|
| 70 |
+
feature_ranges=train_dataset.feature_ranges,
|
| 71 |
+
)
|
| 72 |
+
test_dataset = SMSDataset(
|
| 73 |
+
test_df,
|
| 74 |
+
tokenizer,
|
| 75 |
+
feature_mins=train_dataset.feature_mins,
|
| 76 |
+
feature_ranges=train_dataset.feature_ranges,
|
| 77 |
+
)
|
| 78 |
+
|
| 79 |
+
loader_kwargs = {
|
| 80 |
+
"batch_size": batch_size,
|
| 81 |
+
"num_workers": 2,
|
| 82 |
+
"pin_memory": torch.cuda.is_available(),
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
train_loader = DataLoader(train_dataset, shuffle=True, **loader_kwargs)
|
| 86 |
+
val_loader = DataLoader(val_dataset, shuffle=False, **loader_kwargs)
|
| 87 |
+
test_loader = DataLoader(test_dataset, shuffle=False, **loader_kwargs)
|
| 88 |
+
|
| 89 |
+
norm_stats = {
|
| 90 |
+
"feature_mins": train_dataset.feature_mins,
|
| 91 |
+
"feature_ranges": train_dataset.feature_ranges,
|
| 92 |
+
}
|
| 93 |
+
return train_loader, val_loader, test_loader, norm_stats
|
smishing_detector/models/model.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import torch
|
| 2 |
+
import torch.nn as nn
|
| 3 |
+
from transformers import XLMRobertaModel, XLMRobertaTokenizerFast
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
MODEL_NAME = "xlm-roberta-base"
|
| 7 |
+
MAX_LEN = 128
|
| 8 |
+
NUM_HANDCRAFTED = 17
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class SmishingDetector(nn.Module):
|
| 12 |
+
def __init__(self, num_handcrafted: int = 17, dropout: float = 0.3, freeze_bert_layers: int = 0):
|
| 13 |
+
super().__init__()
|
| 14 |
+
|
| 15 |
+
# XLM-RoBERTa encoder
|
| 16 |
+
# xlm-roberta-base has 12 transformer layers with 768-d hidden size
|
| 17 |
+
self.bert = XLMRobertaModel.from_pretrained(MODEL_NAME)
|
| 18 |
+
|
| 19 |
+
# Freeze bottom N layers if specified
|
| 20 |
+
for i, layer in enumerate(self.bert.encoder.layer):
|
| 21 |
+
if i < freeze_bert_layers:
|
| 22 |
+
for param in layer.parameters():
|
| 23 |
+
param.requires_grad = False
|
| 24 |
+
|
| 25 |
+
self.feat_proj = nn.Sequential(
|
| 26 |
+
nn.Linear(num_handcrafted, 64),
|
| 27 |
+
nn.ReLU(),
|
| 28 |
+
nn.Dropout(dropout),
|
| 29 |
+
nn.Linear(64, 64),
|
| 30 |
+
nn.ReLU()
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
classifier_input_dim = 768 + 64
|
| 34 |
+
self.classifier = nn.Sequential(
|
| 35 |
+
nn.Linear(classifier_input_dim, 256),
|
| 36 |
+
nn.ReLU(),
|
| 37 |
+
nn.Dropout(dropout),
|
| 38 |
+
nn.Linear(256, 64),
|
| 39 |
+
nn.ReLU(),
|
| 40 |
+
nn.Dropout(dropout),
|
| 41 |
+
nn.Linear(64, 1)
|
| 42 |
+
)
|
| 43 |
+
|
| 44 |
+
def forward(self, input_ids, attention_mask, hand_features):
|
| 45 |
+
# XLM-RoBERTa output format is same as BERT
|
| 46 |
+
outputs = self.bert(input_ids=input_ids, attention_mask=attention_mask)
|
| 47 |
+
cls_emb = outputs.last_hidden_state[:, 0, :] # CLS token → (B, 768)
|
| 48 |
+
feat_emb = self.feat_proj(hand_features) # (B, 64)
|
| 49 |
+
fused = torch.cat([cls_emb, feat_emb], dim=1) # (B, 832)
|
| 50 |
+
logit = self.classifier(fused)
|
| 51 |
+
return logit.squeeze(-1)
|
| 52 |
+
|
| 53 |
+
def predict_proba(self, input_ids, attention_mask, hand_features):
|
| 54 |
+
with torch.no_grad():
|
| 55 |
+
logit = self.forward(input_ids, attention_mask, hand_features)
|
| 56 |
+
return torch.sigmoid(logit)
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def get_tokenizer():
|
| 60 |
+
# XLM-RoBERTa tokenizer handles 100 languages automatically
|
| 61 |
+
return XLMRobertaTokenizerFast.from_pretrained(MODEL_NAME)
|
smishing_detector/predictor.py
ADDED
|
@@ -0,0 +1,390 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Prediction engine for ScamShield.
|
| 3 |
+
|
| 4 |
+
The neural model remains the primary classifier. Rules are used only as:
|
| 5 |
+
- spam floors: raise confidence when explicit phishing evidence is present
|
| 6 |
+
- ham ceilings: lower confidence for clear legitimate transactional SMS
|
| 7 |
+
|
| 8 |
+
This avoids the old failure mode where a weak Hindi/Hinglish rule score could
|
| 9 |
+
average down a strong neural spam prediction.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
import os
|
| 13 |
+
import re
|
| 14 |
+
import sys
|
| 15 |
+
from typing import Dict, Iterable, List, Tuple
|
| 16 |
+
|
| 17 |
+
import numpy as np
|
| 18 |
+
import torch
|
| 19 |
+
|
| 20 |
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
| 21 |
+
|
| 22 |
+
from models.model import MAX_LEN, SmishingDetector, get_tokenizer
|
| 23 |
+
from utils.data_loader import (
|
| 24 |
+
clean_text,
|
| 25 |
+
extract_urls,
|
| 26 |
+
get_feature_columns,
|
| 27 |
+
text_features,
|
| 28 |
+
url_features,
|
| 29 |
+
)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
DEVANAGARI_RE = re.compile(r"[\u0900-\u097F]")
|
| 33 |
+
TOKEN_RE = re.compile(r"[a-z]+|[\u0900-\u097F]+")
|
| 34 |
+
|
| 35 |
+
HINGLISH_MARKERS = {
|
| 36 |
+
"aap", "aapka", "aapke", "apka", "mera", "meri", "tumhara", "hamara",
|
| 37 |
+
"karo", "karein", "karna", "kar", "kiya", "kijiye", "dijiye",
|
| 38 |
+
"hai", "hain", "tha", "thi", "hoga", "hogi", "jayega", "jayegi",
|
| 39 |
+
"nahi", "nahin", "bhi", "aur", "ke", "ki", "ka", "mein", "se",
|
| 40 |
+
"par", "ko", "ne", "tak", "wala", "wali", "turant", "jaldi",
|
| 41 |
+
"abhi", "raat", "subah", "aaj", "kal", "rupaye", "paisa",
|
| 42 |
+
"khata", "bijli", "band",
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
LEGIT_BRANDS = {
|
| 46 |
+
"airtel", "jio", "bsnl", "vi", "vodafone", "idea", "amazon",
|
| 47 |
+
"flipkart", "swiggy", "zomato", "zepto", "blinkit", "meesho",
|
| 48 |
+
"myntra", "irctc", "uidai", "epfo", "hdfc", "icici", "sbi",
|
| 49 |
+
"axis", "kotak", "paytm", "phonepe", "gpay",
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def detect_language(text: str) -> Tuple[str, float]:
|
| 54 |
+
"""Return english, hinglish, or hindi with a simple confidence score."""
|
| 55 |
+
if DEVANAGARI_RE.search(text):
|
| 56 |
+
return "hindi", 0.95
|
| 57 |
+
|
| 58 |
+
tokens = set(re.findall(r"[a-z]+", text.lower()))
|
| 59 |
+
hits = len(tokens & HINGLISH_MARKERS)
|
| 60 |
+
if hits >= 3:
|
| 61 |
+
return "hinglish", min(0.55 + hits * 0.08, 0.95)
|
| 62 |
+
if hits:
|
| 63 |
+
return "hinglish", 0.45
|
| 64 |
+
return "english", 0.90
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
SPAM_PATTERNS: List[Tuple[str, float]] = [
|
| 68 |
+
# Account, card, identity, and service suspension
|
| 69 |
+
(r"account.{0,24}(suspend|block|freez|clos|terminat|deactivat)", 0.45),
|
| 70 |
+
(r"(suspend|block|freez|terminat|deactivat).{0,30}(account|card|upi|sim|pan|aadhaar)", 0.45),
|
| 71 |
+
(r"(pan|aadhaar|aadhar)\s*(card\s*)?.{0,24}(block|suspend|deactivat|reactivat)", 0.45),
|
| 72 |
+
(r"upi\s*(id\s*)?.{0,18}(expir|deactivat|block|suspend)", 0.42),
|
| 73 |
+
(r"kyc.{0,20}(update|verify|complete|pending|expire)", 0.38),
|
| 74 |
+
(r"(verify|update|confirm).{0,30}(account|payment|billing|card|kyc|details)", 0.35),
|
| 75 |
+
# Delivery, fees, refunds, and prizes
|
| 76 |
+
(r"(parcel|package|shipment|delivery).{0,35}(fail|held|unable|fee|customs|returned)", 0.42),
|
| 77 |
+
(r"customs?\s*(fee|charge|duty|clearance|payment)", 0.40),
|
| 78 |
+
(r"(tax|hmrc|irs|income\s*tax).{0,30}(refund|rebate|return|pending|claim)", 0.42),
|
| 79 |
+
(r"(claim|collect|redeem).{0,30}(before|within|it\s*expires|now\s*at\s*http|at\s*http)", 0.38),
|
| 80 |
+
(r"(win|won|winner|selected|chosen|lucky).{0,45}(prize|reward|gift|lottery|cash|rs\.?|inr)", 0.45),
|
| 81 |
+
(r"loan.{0,35}(approved|eligible|sanction|offer).{0,40}(call|contact|whatsapp|apply)", 0.38),
|
| 82 |
+
# Hinglish social engineering
|
| 83 |
+
(r"bijli.{0,25}(cut|band|kataegi|kategi|nahi\s*aayegi)", 0.45),
|
| 84 |
+
(r"(electricity|power|connection).{0,24}(cut|band|disconnect)", 0.42),
|
| 85 |
+
(r"band\s*ho\s*(jayega|jaega|jaayega)", 0.35),
|
| 86 |
+
(r"khata.{0,20}(band|block|suspend|freez)", 0.42),
|
| 87 |
+
(r"(digital\s*arrest|money\s*launder|warrant|fir|giraftari|graftari)", 0.48),
|
| 88 |
+
(r"(cbi|ed|police|income\s*tax).{0,35}(notice|case|action|raid|fir|arrest)", 0.42),
|
| 89 |
+
# Devanagari Hindi equivalents, written as escapes to keep the file stable.
|
| 90 |
+
("\u092c\u093f\u091c\u0932\u0940.{0,24}(\u0915\u091f|\u092c\u0902\u0926)", 0.45),
|
| 91 |
+
("\u0915\u0928\u0947\u0915\u094d\u0936\u0928.{0,20}(\u0915\u091f|\u092c\u0902\u0926)", 0.42),
|
| 92 |
+
("\u0916\u093e\u0924\u093e.{0,20}(\u092c\u0902\u0926|\u092c\u094d\u0932\u0949\u0915|\u092b\u094d\u0930\u0940\u091c)", 0.42),
|
| 93 |
+
("(\u0915\u0947\u0935\u093e\u0908\u0938\u0940|kyc).{0,20}(\u0905\u092a\u0921\u0947\u091f|\u092a\u0942\u0930\u093e|\u0935\u0947\u0930\u093f\u092b)", 0.38),
|
| 94 |
+
("\u0906\u0927\u093e\u0930.{0,20}(\u092c\u094d\u0932\u0949\u0915|\u092c\u0902\u0926|\u0928\u093f\u0932\u0902\u092c\u093f\u0924)", 0.45),
|
| 95 |
+
("\u092a\u0948\u0928.{0,20}(\u092c\u094d\u0932\u0949\u0915|\u092c\u0902\u0926|\u0928\u093f\u0937\u094d\u0915\u094d\u0930\u093f\u092f)", 0.45),
|
| 96 |
+
("\u0917\u093f\u0930\u092b\u094d\u0924\u093e\u0930|\u090f\u092b\u0906\u0908\u0906\u0930|\u0935\u093e\u0930\u0902\u091f|\u0921\u093f\u091c\u093f\u091f\u0932\\s*\u0905\u0930\u0947\u0938\u094d\u091f", 0.48),
|
| 97 |
+
]
|
| 98 |
+
|
| 99 |
+
HAM_PATTERNS: List[Tuple[str, float]] = [
|
| 100 |
+
# Banking and wallet confirmations
|
| 101 |
+
(r"(rs\.?|inr|\u20b9)\s*[\d,]+\.?\d*.{0,35}(credited|debited|received|paid|sent|withdrawn)", 0.58),
|
| 102 |
+
(r"(credited|debited|received|paid|sent|withdrawn).{0,35}(rs\.?|inr|\u20b9)\s*[\d,]+", 0.50),
|
| 103 |
+
(r"(avl|available|current)\s*(bal|balance)", 0.45),
|
| 104 |
+
(r"upi\s*(ref|reference)", 0.35),
|
| 105 |
+
(r"(do\s*not|never)\s*(share|give).{0,25}(otp|pin|code|password)", 0.55),
|
| 106 |
+
(r"(otp|one[\s-]?time|verification\s*code).{0,60}(valid|expires|login|transaction|payment)", 0.45),
|
| 107 |
+
# Recharge, travel, delivery, bills
|
| 108 |
+
(r"recharge.{0,45}(successful|done|activated|renewed)", 0.55),
|
| 109 |
+
(r"(order|parcel|shipment|package).{0,55}(shipped|dispatched|delivered|out\s*for\s*delivery)", 0.50),
|
| 110 |
+
(r"(estimated|expected)\s*(delivery|arrival)", 0.30),
|
| 111 |
+
(r"(ticket|booking|pnr).{0,55}(confirmed|booked|reserved)", 0.50),
|
| 112 |
+
(r"(bill|premium|policy).{0,60}(due|generated|paid|receipt)", 0.35),
|
| 113 |
+
# Telecom — legit promos, upgrades, store visits
|
| 114 |
+
(r"visit.{0,20}(nearest|our).{0,20}(store|branch|centre|outlet)", 0.35),
|
| 115 |
+
(r"eligible\s*(for)?\s*(a\s*)?(free\s*)?(sim|upgrade|service|plan|offer)", 0.30),
|
| 116 |
+
(r"(sim|5g|4g|network|plan)\s*(upgrade|migrat|activat)", 0.30),
|
| 117 |
+
# Hindi/Hinglish legitimate messages
|
| 118 |
+
("\u0913\u091f\u0940\u092a\u0940|otp", 0.20),
|
| 119 |
+
("\u0938\u093e\u091d\u093e|\u0936\u0947\u092f\u0930", 0.15),
|
| 120 |
+
("(rs\\.?|inr|\u20b9)\\s*[\\d,]+.{0,35}(\u091c\u092e\u093e|\u0921\u0947\u092c\u093f\u091f|\u092d\u0941\u0917\u0924\u093e\u0928|\u092a\u094d\u0930\u093e\u092a\u094d\u0924)", 0.45),
|
| 121 |
+
("\u092c\u093f\u0932.{0,45}(\u091c\u0947\u0928\u0930\u0947\u091f|\u0926\u0947\u092f|\u092d\u0941\u0917\u0924\u093e\u0928)", 0.35),
|
| 122 |
+
]
|
| 123 |
+
|
| 124 |
+
URGENCY_TOKENS = {
|
| 125 |
+
"urgent", "immediately", "now", "today", "limited", "last", "final",
|
| 126 |
+
"turant", "jaldi", "abhi", "aaj", "kal", "antim",
|
| 127 |
+
"\u0924\u0941\u0930\u0902\u0924", "\u091c\u0932\u094d\u0926\u0940",
|
| 128 |
+
"\u0905\u092d\u0940", "\u0906\u091c", "\u0915\u0932",
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
PHONE_RE = re.compile(r"(?:\+91[-\s]?)?\d[\d\s\-]{8,}\d")
|
| 132 |
+
DEADLINE_RE = re.compile(
|
| 133 |
+
r"aaj\s*raat|kal\s*subah|\d{1,2}:\d{2}\s*(am|pm|baje)|"
|
| 134 |
+
r"\d+\s*(ghante|minute|mint)\s*(mein|tak|ke\s*andar)|"
|
| 135 |
+
r"\u0906\u091c\s*\u0930\u093e\u0924|\u0915\u0932\s*\u0938\u0941\u092c\u0939|"
|
| 136 |
+
r"\u092c\u091c\u0947\s*\u0924\u0915|\d+\s*(\u0918\u0902\u091f\u0947|\u092e\u093f\u0928\u091f)\s*(\u092e\u0947\u0902|\u0924\u0915)",
|
| 137 |
+
re.IGNORECASE,
|
| 138 |
+
)
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def _tokens(text: str) -> set:
|
| 142 |
+
return set(TOKEN_RE.findall(text.lower()))
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def _sum_patterns(text: str, patterns: Iterable[Tuple[str, float]]) -> float:
|
| 146 |
+
msg = text.lower()
|
| 147 |
+
score = 0.0
|
| 148 |
+
for pattern, weight in patterns:
|
| 149 |
+
if re.search(pattern, msg, re.IGNORECASE):
|
| 150 |
+
score += weight
|
| 151 |
+
return min(score, 1.0)
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
def spam_rule_score(message: str, uf: Dict[str, int]) -> float:
|
| 155 |
+
"""Explicit phishing/scam confidence from transparent rules."""
|
| 156 |
+
score = _sum_patterns(message, SPAM_PATTERNS)
|
| 157 |
+
tokens = _tokens(message)
|
| 158 |
+
|
| 159 |
+
if tokens & URGENCY_TOKENS and PHONE_RE.search(message):
|
| 160 |
+
score += 0.25
|
| 161 |
+
if DEADLINE_RE.search(message):
|
| 162 |
+
score += 0.15
|
| 163 |
+
if uf.get("has_url"):
|
| 164 |
+
score += 0.15 # any URL + spam pattern match is a signal
|
| 165 |
+
if uf.get("suspicious_tld") or uf.get("has_ip_url") or uf.get("has_shortened_url"):
|
| 166 |
+
score += 0.25
|
| 167 |
+
if re.search(r"https?://\S*(verify|login|update|confirm|secure|kyc|account)\S*", message, re.IGNORECASE):
|
| 168 |
+
score += 0.20
|
| 169 |
+
|
| 170 |
+
return min(score, 1.0)
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
def ham_rule_score(message: str, uf: Dict[str, int]) -> float:
|
| 174 |
+
"""Legitimate transactional confidence from transparent rules."""
|
| 175 |
+
score = _sum_patterns(message, HAM_PATTERNS)
|
| 176 |
+
tokens = _tokens(message)
|
| 177 |
+
|
| 178 |
+
if tokens & LEGIT_BRANDS:
|
| 179 |
+
score += 0.18
|
| 180 |
+
if uf.get("has_url") and uf.get("has_legit_domain"):
|
| 181 |
+
score += 0.20
|
| 182 |
+
if uf.get("has_url") and (uf.get("suspicious_tld") or uf.get("has_ip_url") or uf.get("has_shortened_url")):
|
| 183 |
+
score -= 0.35
|
| 184 |
+
|
| 185 |
+
return max(0.0, min(score, 1.0))
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
def combine_scores(neural: float, spam_rule: float, ham_rule: float, has_url: bool, has_legit_domain: bool) -> float:
|
| 189 |
+
"""
|
| 190 |
+
Combine neural and rule scores.
|
| 191 |
+
|
| 192 |
+
Rules never average down spam evidence. They only set floors/ceilings.
|
| 193 |
+
"""
|
| 194 |
+
final = float(neural)
|
| 195 |
+
|
| 196 |
+
if spam_rule >= 0.35:
|
| 197 |
+
final = max(final, spam_rule)
|
| 198 |
+
|
| 199 |
+
clear_legit = ham_rule >= 0.35 and spam_rule < 0.55
|
| 200 |
+
if clear_legit:
|
| 201 |
+
if not has_url:
|
| 202 |
+
cap = 1.0 - ham_rule * 0.82
|
| 203 |
+
elif has_legit_domain:
|
| 204 |
+
cap = 1.0 - ham_rule * 0.58
|
| 205 |
+
else:
|
| 206 |
+
cap = 1.0 - ham_rule * 0.25
|
| 207 |
+
final = min(final, cap)
|
| 208 |
+
|
| 209 |
+
return max(0.0, min(1.0, final))
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
class SmishingPredictor:
|
| 213 |
+
def __init__(self, checkpoint_path: str = "best_model.pt"):
|
| 214 |
+
self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 215 |
+
self.tokenizer = get_tokenizer()
|
| 216 |
+
|
| 217 |
+
checkpoint = torch.load(checkpoint_path, map_location=self.device, weights_only=False)
|
| 218 |
+
self.model = SmishingDetector(
|
| 219 |
+
num_handcrafted=17,
|
| 220 |
+
dropout=0.3,
|
| 221 |
+
freeze_bert_layers=0,
|
| 222 |
+
).to(self.device)
|
| 223 |
+
self.model.load_state_dict(checkpoint["model_state"])
|
| 224 |
+
self.model.eval()
|
| 225 |
+
|
| 226 |
+
self.threshold = float(checkpoint.get("threshold", 0.55))
|
| 227 |
+
self.feature_mins = np.array(checkpoint.get("feature_mins", [0.0] * 17), dtype=np.float32)
|
| 228 |
+
self.feature_ranges = np.array(checkpoint.get("feature_ranges", [1.0] * 17), dtype=np.float32)
|
| 229 |
+
self.feature_ranges = np.where(self.feature_ranges == 0, 1.0, self.feature_ranges)
|
| 230 |
+
|
| 231 |
+
print(
|
| 232 |
+
"SmishingPredictor loaded | "
|
| 233 |
+
f"threshold={self.threshold} | "
|
| 234 |
+
f"val_f1={checkpoint.get('val_f1', 'N/A')} | "
|
| 235 |
+
f"epoch={checkpoint.get('epoch', 'N/A')}"
|
| 236 |
+
)
|
| 237 |
+
|
| 238 |
+
def _handcrafted_features(self, message: str) -> Tuple[Dict[str, int], Dict[str, float], torch.Tensor]:
|
| 239 |
+
uf = url_features(message)
|
| 240 |
+
tf = text_features(message)
|
| 241 |
+
merged = {**uf, **tf}
|
| 242 |
+
raw = np.array([merged[col] for col in get_feature_columns()], dtype=np.float32)
|
| 243 |
+
norm = (raw - self.feature_mins) / self.feature_ranges
|
| 244 |
+
norm = np.nan_to_num(norm, nan=0.0, posinf=3.0, neginf=-2.0)
|
| 245 |
+
norm = np.clip(norm, -2.0, 3.0)
|
| 246 |
+
tensor = torch.from_numpy(norm.reshape(1, -1)).to(self.device)
|
| 247 |
+
return uf, tf, tensor
|
| 248 |
+
|
| 249 |
+
def _neural_score(self, message: str, hand_features: torch.Tensor) -> float:
|
| 250 |
+
cleaned = clean_text(message)
|
| 251 |
+
enc = self.tokenizer(
|
| 252 |
+
cleaned,
|
| 253 |
+
max_length=MAX_LEN,
|
| 254 |
+
padding="max_length",
|
| 255 |
+
truncation=True,
|
| 256 |
+
return_tensors="pt",
|
| 257 |
+
)
|
| 258 |
+
enc = {k: v.to(self.device) for k, v in enc.items()}
|
| 259 |
+
|
| 260 |
+
with torch.no_grad():
|
| 261 |
+
prob = self.model.predict_proba(enc["input_ids"], enc["attention_mask"], hand_features)
|
| 262 |
+
return float(prob.item())
|
| 263 |
+
|
| 264 |
+
def _green_channel(self, message: str, label: str, final_prob: float,
|
| 265 |
+
spam_rule: float, ham_rule: float,
|
| 266 |
+
uf: dict) -> Tuple[str, str, float, bool, str]:
|
| 267 |
+
"""
|
| 268 |
+
Unified URL-aware channel checking.
|
| 269 |
+
|
| 270 |
+
For EVERY message that has URLs, check ALL of them against
|
| 271 |
+
GSB first, then static whitelist:
|
| 272 |
+
|
| 273 |
+
1. ANY URL → known_malicious => SPAM (escalate)
|
| 274 |
+
2. ALL URLs → known_safe => SAFE (green channel)
|
| 275 |
+
3. ANY URL → unverified => keep model verdict (no override)
|
| 276 |
+
4. No URLs + ham patterns => SAFE (promo/ad without links)
|
| 277 |
+
"""
|
| 278 |
+
has_url = bool(uf.get("has_url"))
|
| 279 |
+
risk = "high" if final_prob >= 0.75 else "medium" if final_prob >= self.threshold else "low"
|
| 280 |
+
|
| 281 |
+
# ── URL checking (runs for ALL messages with URLs) ──────────────────
|
| 282 |
+
if has_url:
|
| 283 |
+
try:
|
| 284 |
+
import tldextract
|
| 285 |
+
urls = extract_urls(message)
|
| 286 |
+
if urls:
|
| 287 |
+
# static whitelist
|
| 288 |
+
static_legit = set()
|
| 289 |
+
try:
|
| 290 |
+
from utils.safe_browsing import get_checker
|
| 291 |
+
static_legit |= get_checker().fallback_legit_domains
|
| 292 |
+
except Exception:
|
| 293 |
+
pass
|
| 294 |
+
try:
|
| 295 |
+
from utils.data_loader import LEGIT_DOMAINS
|
| 296 |
+
static_legit |= LEGIT_DOMAINS
|
| 297 |
+
except Exception:
|
| 298 |
+
pass
|
| 299 |
+
|
| 300 |
+
checker = None
|
| 301 |
+
try:
|
| 302 |
+
from utils.safe_browsing import get_checker as _gc
|
| 303 |
+
checker = _gc()
|
| 304 |
+
except Exception:
|
| 305 |
+
pass
|
| 306 |
+
|
| 307 |
+
all_safe = True
|
| 308 |
+
for url in urls:
|
| 309 |
+
ext = tldextract.extract(url)
|
| 310 |
+
full_domain = f"{ext.domain}.{ext.suffix}".lower().strip(".")
|
| 311 |
+
if not full_domain:
|
| 312 |
+
continue
|
| 313 |
+
|
| 314 |
+
# 1. GSB first
|
| 315 |
+
status = "unknown"
|
| 316 |
+
if checker:
|
| 317 |
+
try:
|
| 318 |
+
status = checker.check_domain_status(full_domain)
|
| 319 |
+
except Exception:
|
| 320 |
+
pass
|
| 321 |
+
|
| 322 |
+
if status == "known_malicious":
|
| 323 |
+
return "spam", "high", max(final_prob, 0.90), False, ""
|
| 324 |
+
if status == "known_safe":
|
| 325 |
+
continue
|
| 326 |
+
if full_domain in static_legit:
|
| 327 |
+
continue
|
| 328 |
+
# unverified — don't green-channel
|
| 329 |
+
all_safe = False
|
| 330 |
+
break
|
| 331 |
+
|
| 332 |
+
if all_safe:
|
| 333 |
+
if label == "spam" and spam_rule >= 0.35:
|
| 334 |
+
# message looks scammy but all URLs are legit → cap at medium
|
| 335 |
+
return "spam", "medium", min(final_prob, 0.55), True, \
|
| 336 |
+
"URLs verified legitimate but message content is suspicious"
|
| 337 |
+
return "safe", "low", min(final_prob, 0.40), True, \
|
| 338 |
+
"all URLs verified as legitimate domains"
|
| 339 |
+
except Exception:
|
| 340 |
+
pass
|
| 341 |
+
|
| 342 |
+
# URLs exist but at least one is unverified → keep model verdict
|
| 343 |
+
return label, risk, final_prob, False, ""
|
| 344 |
+
|
| 345 |
+
# ── No URLs: check for harmless promo/ad patterns ───────────────────
|
| 346 |
+
if ham_rule >= 0.35 and spam_rule < 0.35 and label == "spam":
|
| 347 |
+
return "safe", "low", min(final_prob, 0.35), True, \
|
| 348 |
+
"no URLs + legitimate transactional patterns detected"
|
| 349 |
+
|
| 350 |
+
return label, risk, final_prob, False, ""
|
| 351 |
+
|
| 352 |
+
def predict(self, message: str) -> dict:
|
| 353 |
+
lang, lang_conf = detect_language(message)
|
| 354 |
+
uf, tf, hand_features = self._handcrafted_features(message)
|
| 355 |
+
|
| 356 |
+
neural_prob = self._neural_score(message, hand_features)
|
| 357 |
+
spam_s = spam_rule_score(message, uf)
|
| 358 |
+
ham_s = ham_rule_score(message, uf)
|
| 359 |
+
final_prob = combine_scores(
|
| 360 |
+
neural_prob,
|
| 361 |
+
spam_s,
|
| 362 |
+
ham_s,
|
| 363 |
+
bool(uf.get("has_url")),
|
| 364 |
+
bool(uf.get("has_legit_domain")),
|
| 365 |
+
)
|
| 366 |
+
|
| 367 |
+
label = "spam" if final_prob >= self.threshold else "ham"
|
| 368 |
+
risk = "high" if final_prob >= 0.75 else "medium" if final_prob >= self.threshold else "low"
|
| 369 |
+
|
| 370 |
+
# Green channel: distinguish scams from harmless promo spam
|
| 371 |
+
label, risk, final_prob, green_cleared, green_reason = self._green_channel(
|
| 372 |
+
message, label, final_prob, spam_s, ham_s, uf
|
| 373 |
+
)
|
| 374 |
+
|
| 375 |
+
return {
|
| 376 |
+
"label": label,
|
| 377 |
+
"confidence": round(final_prob, 4),
|
| 378 |
+
"risk_level": risk,
|
| 379 |
+
"language": lang,
|
| 380 |
+
"language_confidence": round(lang_conf, 2),
|
| 381 |
+
"green_channel": green_cleared,
|
| 382 |
+
"green_reason": green_reason,
|
| 383 |
+
"url_signals": uf,
|
| 384 |
+
"text_signals": tf,
|
| 385 |
+
"_neural_score": round(neural_prob, 4),
|
| 386 |
+
"_rule_score": round(spam_s, 4),
|
| 387 |
+
"_ham_rule_score": round(ham_s, 4),
|
| 388 |
+
"_threshold": self.threshold,
|
| 389 |
+
}
|
| 390 |
+
|
smishing_detector/requirements.txt
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
torch>=2.0.0
|
| 2 |
+
transformers>=4.35.0
|
| 3 |
+
datasets>=2.14.0
|
| 4 |
+
ucimlrepo>=0.0.3
|
| 5 |
+
scikit-learn>=1.3.0
|
| 6 |
+
pandas>=2.0.0
|
| 7 |
+
numpy>=1.24.0
|
| 8 |
+
tldextract>=3.4.0
|
| 9 |
+
requests>=2.31.0
|
| 10 |
+
python-dotenv>=1.0.0
|
| 11 |
+
shap>=0.43.0
|
| 12 |
+
streamlit>=1.28.0
|
| 13 |
+
flask>=3.0.0
|
| 14 |
+
flask-cors>=4.0.0
|
| 15 |
+
matplotlib>=3.7.0
|
| 16 |
+
seaborn>=0.12.0
|
| 17 |
+
nltk>=3.8.0
|
| 18 |
+
regex>=2023.0.0
|
| 19 |
+
sentencepiece>=0.1.99
|
| 20 |
+
protobuf>=3.20.0
|
| 21 |
+
kagglehub>=0.2.0
|
| 22 |
+
cryptography>=42.0.0
|
smishing_detector/train.py
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import torch
|
| 3 |
+
import torch.nn as nn
|
| 4 |
+
from torch.optim import AdamW
|
| 5 |
+
from transformers import get_linear_schedule_with_warmup
|
| 6 |
+
from sklearn.metrics import accuracy_score, precision_score, recall_score, f1_score, classification_report, confusion_matrix
|
| 7 |
+
import sys
|
| 8 |
+
|
| 9 |
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
| 10 |
+
|
| 11 |
+
from utils.data_loader import load_dataset, split_dataset, get_feature_columns
|
| 12 |
+
from models.model import SmishingDetector, get_tokenizer
|
| 13 |
+
from models.dataset import build_dataloaders
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
EPOCHS = 10
|
| 17 |
+
BATCH_SIZE = 16
|
| 18 |
+
LR = 1e-5
|
| 19 |
+
WEIGHT_DECAY = 0.01
|
| 20 |
+
THRESHOLD = 0.55
|
| 21 |
+
CHECKPOINT = "best_model.pt"
|
| 22 |
+
DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 23 |
+
EARLY_STOPPING_PATIENCE = 4
|
| 24 |
+
FREEZE_BERT_LAYERS = 0 # Unfrozen all layers for deep fine-tuning on diverse data
|
| 25 |
+
LABEL_SMOOTHING = 0.05
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class LabelSmoothingBCELoss(nn.Module):
|
| 29 |
+
"""BCE with logits, class weighting, and mild label smoothing."""
|
| 30 |
+
def __init__(self, pos_weight: torch.Tensor, smoothing: float = 0.05):
|
| 31 |
+
super().__init__()
|
| 32 |
+
self.pos_weight = pos_weight
|
| 33 |
+
self.smoothing = smoothing
|
| 34 |
+
|
| 35 |
+
def forward(self, logits: torch.Tensor, targets: torch.Tensor) -> torch.Tensor:
|
| 36 |
+
targets_smooth = targets * (1 - self.smoothing) + self.smoothing * 0.5
|
| 37 |
+
return nn.functional.binary_cross_entropy_with_logits(
|
| 38 |
+
logits, targets_smooth, pos_weight=self.pos_weight
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def train_epoch(model, loader, criterion, optimizer, scheduler):
|
| 43 |
+
model.train()
|
| 44 |
+
total_loss = 0
|
| 45 |
+
all_preds = []
|
| 46 |
+
all_labels = []
|
| 47 |
+
|
| 48 |
+
for batch in loader:
|
| 49 |
+
input_ids = batch['input_ids'].to(DEVICE)
|
| 50 |
+
attention_mask = batch['attention_mask'].to(DEVICE)
|
| 51 |
+
hand_features = batch['hand_features'].to(DEVICE)
|
| 52 |
+
labels = batch['label'].to(DEVICE)
|
| 53 |
+
|
| 54 |
+
optimizer.zero_grad()
|
| 55 |
+
logits = model(input_ids, attention_mask, hand_features)
|
| 56 |
+
loss = criterion(logits, labels)
|
| 57 |
+
loss.backward()
|
| 58 |
+
|
| 59 |
+
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
|
| 60 |
+
optimizer.step()
|
| 61 |
+
scheduler.step()
|
| 62 |
+
|
| 63 |
+
total_loss += loss.item()
|
| 64 |
+
preds = (torch.sigmoid(logits) > THRESHOLD).long().cpu().tolist()
|
| 65 |
+
all_preds.extend(preds)
|
| 66 |
+
all_labels.extend(labels.long().cpu().tolist())
|
| 67 |
+
|
| 68 |
+
avg_loss = total_loss / len(loader)
|
| 69 |
+
acc = accuracy_score(all_labels, all_preds)
|
| 70 |
+
prec = precision_score(all_labels, all_preds, zero_division=0)
|
| 71 |
+
rec = recall_score(all_labels, all_preds, zero_division=0)
|
| 72 |
+
f1 = f1_score(all_labels, all_preds, zero_division=0)
|
| 73 |
+
|
| 74 |
+
return avg_loss, acc, prec, rec, f1
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def evaluate(model, loader, criterion):
|
| 78 |
+
model.eval()
|
| 79 |
+
total_loss = 0
|
| 80 |
+
all_preds = []
|
| 81 |
+
all_labels = []
|
| 82 |
+
|
| 83 |
+
with torch.no_grad():
|
| 84 |
+
for batch in loader:
|
| 85 |
+
input_ids = batch['input_ids'].to(DEVICE)
|
| 86 |
+
attention_mask = batch['attention_mask'].to(DEVICE)
|
| 87 |
+
hand_features = batch['hand_features'].to(DEVICE)
|
| 88 |
+
labels = batch['label'].to(DEVICE)
|
| 89 |
+
|
| 90 |
+
logits = model(input_ids, attention_mask, hand_features)
|
| 91 |
+
loss = criterion(logits, labels)
|
| 92 |
+
|
| 93 |
+
total_loss += loss.item()
|
| 94 |
+
preds = (torch.sigmoid(logits) > THRESHOLD).long().cpu().tolist()
|
| 95 |
+
all_preds.extend(preds)
|
| 96 |
+
all_labels.extend(labels.long().cpu().tolist())
|
| 97 |
+
|
| 98 |
+
avg_loss = total_loss / len(loader)
|
| 99 |
+
acc = accuracy_score(all_labels, all_preds)
|
| 100 |
+
prec = precision_score(all_labels, all_preds, zero_division=0)
|
| 101 |
+
rec = recall_score(all_labels, all_preds, zero_division=0)
|
| 102 |
+
f1 = f1_score(all_labels, all_preds, zero_division=0)
|
| 103 |
+
|
| 104 |
+
return avg_loss, acc, prec, rec, f1, all_preds, all_labels
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def main():
|
| 108 |
+
print(f"Using device: {DEVICE}")
|
| 109 |
+
|
| 110 |
+
print("Loading dataset...")
|
| 111 |
+
df = load_dataset()
|
| 112 |
+
|
| 113 |
+
train_df, val_df, test_df = split_dataset(df)
|
| 114 |
+
print(f"Train: {len(train_df)}, Val: {len(val_df)}, Test: {len(test_df)}")
|
| 115 |
+
|
| 116 |
+
print("Loading tokenizer and building dataloaders...")
|
| 117 |
+
tokenizer = get_tokenizer()
|
| 118 |
+
train_loader, val_loader, test_loader, norm_stats = build_dataloaders(train_df, val_df, test_df, tokenizer, BATCH_SIZE)
|
| 119 |
+
|
| 120 |
+
print("Initializing model...")
|
| 121 |
+
model = SmishingDetector(num_handcrafted=17, dropout=0.3, freeze_bert_layers=FREEZE_BERT_LAYERS).to(DEVICE)
|
| 122 |
+
|
| 123 |
+
spam_count = train_df['label'].sum()
|
| 124 |
+
ham_count = len(train_df) - spam_count
|
| 125 |
+
pos_weight = torch.tensor([ham_count / spam_count]).to(DEVICE)
|
| 126 |
+
print(f"Class imbalance - pos_weight: {pos_weight.item():.2f}")
|
| 127 |
+
|
| 128 |
+
criterion = LabelSmoothingBCELoss(pos_weight=pos_weight, smoothing=LABEL_SMOOTHING)
|
| 129 |
+
|
| 130 |
+
trainable_params = [p for p in model.parameters() if p.requires_grad]
|
| 131 |
+
optimizer = AdamW(trainable_params, lr=LR, weight_decay=WEIGHT_DECAY)
|
| 132 |
+
|
| 133 |
+
total_steps = len(train_loader) * EPOCHS
|
| 134 |
+
warmup_steps = total_steps // 10
|
| 135 |
+
scheduler = get_linear_schedule_with_warmup(optimizer, warmup_steps, total_steps)
|
| 136 |
+
|
| 137 |
+
best_val_f1 = 0.0
|
| 138 |
+
patience_counter = 0
|
| 139 |
+
|
| 140 |
+
print(f"\nStarting training ({EPOCHS} epochs, early stopping patience={EARLY_STOPPING_PATIENCE})...")
|
| 141 |
+
for epoch in range(EPOCHS):
|
| 142 |
+
train_loss, train_acc, train_prec, train_rec, train_f1 = train_epoch(
|
| 143 |
+
model, train_loader, criterion, optimizer, scheduler
|
| 144 |
+
)
|
| 145 |
+
|
| 146 |
+
val_loss, val_acc, val_prec, val_rec, val_f1, _, _ = evaluate(model, val_loader, criterion)
|
| 147 |
+
|
| 148 |
+
print(f"Epoch {epoch+1}/{EPOCHS}")
|
| 149 |
+
print(f" Train - Loss: {train_loss:.4f}, Acc: {train_acc:.4f}, Prec: {train_prec:.4f}, Rec: {train_rec:.4f}, F1: {train_f1:.4f}")
|
| 150 |
+
print(f" Val - Loss: {val_loss:.4f}, Acc: {val_acc:.4f}, Prec: {val_prec:.4f}, Rec: {val_rec:.4f}, F1: {val_f1:.4f}")
|
| 151 |
+
|
| 152 |
+
if val_f1 > best_val_f1:
|
| 153 |
+
best_val_f1 = val_f1
|
| 154 |
+
patience_counter = 0
|
| 155 |
+
torch.save({
|
| 156 |
+
'epoch': epoch,
|
| 157 |
+
'model_state': model.state_dict(),
|
| 158 |
+
'val_f1': best_val_f1,
|
| 159 |
+
'threshold': THRESHOLD,
|
| 160 |
+
'feature_mins': norm_stats['feature_mins'].tolist(),
|
| 161 |
+
'feature_ranges': norm_stats['feature_ranges'].tolist(),
|
| 162 |
+
}, CHECKPOINT)
|
| 163 |
+
print(f" -> New best model saved (F1: {best_val_f1:.4f})")
|
| 164 |
+
else:
|
| 165 |
+
patience_counter += 1
|
| 166 |
+
print(f" -> No improvement ({patience_counter}/{EARLY_STOPPING_PATIENCE})")
|
| 167 |
+
if patience_counter >= EARLY_STOPPING_PATIENCE:
|
| 168 |
+
print(f"\nEarly stopping triggered after {epoch+1} epochs")
|
| 169 |
+
break
|
| 170 |
+
|
| 171 |
+
print("\nLoading best model for test evaluation...")
|
| 172 |
+
checkpoint = torch.load(CHECKPOINT, map_location=DEVICE, weights_only=False)
|
| 173 |
+
model.load_state_dict(checkpoint['model_state'])
|
| 174 |
+
|
| 175 |
+
test_loss, test_acc, test_prec, test_rec, test_f1, test_preds, test_labels = evaluate(model, test_loader, criterion)
|
| 176 |
+
|
| 177 |
+
print("\n" + "="*60)
|
| 178 |
+
print("TEST SET RESULTS")
|
| 179 |
+
print("="*60)
|
| 180 |
+
print(f"Loss: {test_loss:.4f}, Accuracy: {test_acc:.4f}")
|
| 181 |
+
print(f"Precision: {test_prec:.4f}, Recall: {test_rec:.4f}, F1: {test_f1:.4f}")
|
| 182 |
+
|
| 183 |
+
print("\nClassification Report:")
|
| 184 |
+
print(classification_report(test_labels, test_preds, target_names=['ham', 'spam']))
|
| 185 |
+
|
| 186 |
+
cm = confusion_matrix(test_labels, test_preds)
|
| 187 |
+
print("Confusion Matrix:")
|
| 188 |
+
print(cm)
|
| 189 |
+
|
| 190 |
+
fpr = cm[0][1] / (cm[0][0] + cm[0][1]) if (cm[0][0] + cm[0][1]) > 0 else 0
|
| 191 |
+
fnr = cm[1][0] / (cm[1][0] + cm[1][1]) if (cm[1][0] + cm[1][1]) > 0 else 0
|
| 192 |
+
|
| 193 |
+
print(f"False Positive Rate: {fpr:.4f} (ham flagged as spam)")
|
| 194 |
+
print(f"False Negative Rate: {fnr:.4f} (spam missed)")
|
| 195 |
+
|
| 196 |
+
print("\nTraining complete!")
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
if __name__ == "__main__":
|
| 200 |
+
main()
|
smishing_detector/utils/__init__.py
ADDED
|
File without changes
|
smishing_detector/utils/data_loader.py
ADDED
|
@@ -0,0 +1,403 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Dataset and feature engine for ScamShield.
|
| 3 |
+
|
| 4 |
+
Feature extraction is deterministic and side-effect free. In particular, it does
|
| 5 |
+
not call Google Safe Browsing or any network service. Training, evaluation, and
|
| 6 |
+
inference all import the same functions from this module.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import os
|
| 12 |
+
import re
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
from typing import Iterable, Optional
|
| 15 |
+
|
| 16 |
+
import numpy as np
|
| 17 |
+
import pandas as pd
|
| 18 |
+
import tldextract
|
| 19 |
+
from sklearn.model_selection import train_test_split
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
BASE_DIR = Path(__file__).resolve().parents[1]
|
| 23 |
+
DATA_DIR = BASE_DIR / "data"
|
| 24 |
+
|
| 25 |
+
SUSPICIOUS_TLDS = {
|
| 26 |
+
"tk", "ml", "ga", "cf", "gq", "xyz", "top", "click", "link",
|
| 27 |
+
"work", "loan", "online", "site", "info", "biz", "club",
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
URL_SHORTENERS = {
|
| 31 |
+
"bit.ly", "tinyurl.com", "t.co", "goo.gl", "ow.ly", "is.gd",
|
| 32 |
+
"buff.ly", "rebrand.ly", "cutt.ly", "fkrt.it", "amzn.in",
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
LEGIT_DOMAINS = {
|
| 36 |
+
"amazon.com", "amazon.in", "netflix.com", "spotify.com", "apple.com",
|
| 37 |
+
"google.com", "microsoft.com", "paypal.com", "ebay.com", "walmart.com",
|
| 38 |
+
"fedex.com", "ups.com", "usps.com", "shopify.com", "slack.com",
|
| 39 |
+
"zoom.us", "gmail.com", "outlook.com", "yahoo.com",
|
| 40 |
+
"flipkart.com", "myntra.com", "swiggy.com", "zomato.com",
|
| 41 |
+
"makemytrip.com", "yatra.com", "cleartrip.com", "paytm.com",
|
| 42 |
+
"phonepe.com", "hdfcbank.com", "onlinesbi.sbi", "icicibank.com",
|
| 43 |
+
"axisbank.com", "kotak.com", "airindia.in", "goindigo.in",
|
| 44 |
+
"irctc.co.in", "redbus.in", "olacabs.com", "hotstar.com",
|
| 45 |
+
"sonyliv.com", "zee5.com", "jiocinema.com", "moneycontrol.com",
|
| 46 |
+
"bseindia.com", "nseindia.com", "bookmyshow.com", "policybazaar.com",
|
| 47 |
+
"nykaa.com", "ajio.com", "firstcry.com", "bigbasket.com",
|
| 48 |
+
"blinkit.com", "zepto.com", "upstox.com", "groww.in", "zerodha.com",
|
| 49 |
+
"airtel.in", "jio.com", "myvi.in", "vi.com", "bsnl.co.in",
|
| 50 |
+
"tataplay.com", "dishtv.in", "sbicard.com", "bajajfinserv.in",
|
| 51 |
+
"cred.club", "licindia.in", "epfindia.gov.in", "mseb.co.in",
|
| 52 |
+
"bescom.org", "uidai.gov.in", "cibil.com",
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
TRUSTED_TLDS = {
|
| 56 |
+
"gov", "gov.in", "edu", "sbi",
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
URL_FEATURE_COLS = [
|
| 60 |
+
"has_url", "num_urls", "has_http", "has_https", "suspicious_tld",
|
| 61 |
+
"max_url_len", "has_ip_url", "has_shortened_url", "has_legit_domain",
|
| 62 |
+
]
|
| 63 |
+
|
| 64 |
+
TEXT_FEATURE_COLS = [
|
| 65 |
+
"num_chars", "num_words", "pct_upper", "pct_digits", "num_special",
|
| 66 |
+
"urgency_count", "has_phone", "has_currency",
|
| 67 |
+
]
|
| 68 |
+
|
| 69 |
+
URGENCY_WORDS = {
|
| 70 |
+
"urgent", "winner", "won", "free", "prize", "claim", "cash",
|
| 71 |
+
"congratulations", "selected", "reward", "limited", "click",
|
| 72 |
+
"password", "invoice", "crypto", "bitcoin", "wallet", "suspended",
|
| 73 |
+
"blocked", "deactivated", "illegal", "arrested", "cyber", "fraud",
|
| 74 |
+
"hack", "jaldi", "turant", "abhi", "kijiye", "rupaye", "paisa",
|
| 75 |
+
"khata", "band", "inam", "jeeta", "loot", "kyc", "cashback",
|
| 76 |
+
"lucky", "gift", "redeem", "bijli", "officer", "helpline",
|
| 77 |
+
"fir", "giraftari", "arrest",
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
URGENCY_PHRASES = {
|
| 81 |
+
"act now", "action required", "share your otp", "last chance",
|
| 82 |
+
"court notice", "turant call", "abhi call", "aaj raat",
|
| 83 |
+
"kal subah", "power cut", "connection cut", "band ho jayega",
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
CURRENCY_SYMBOLS = {"$", "\u00a3", "\u20ac", "\u20b9", "btc", "eth", "usdt"}
|
| 87 |
+
|
| 88 |
+
INDIAN_LEGIT_SMS = [
|
| 89 |
+
"HDFC Bank: Rs.25,000 credited to a/c XX4521 on 02-May. Avl bal: Rs.1,42,356. -HDFC Bank",
|
| 90 |
+
"Dear Customer, your ICICI a/c ending 7890 debited Rs.1,500 at AMAZON on 01-May. -ICICI Bank",
|
| 91 |
+
"Your SBI a/c XXXX1234 is credited with INR 5,000.00 on 01-May-25. Bal: INR 12,450.00. -SBI",
|
| 92 |
+
"Your OTP for SBI Net Banking login is 483921. Valid for 10 minutes. Do not share. -SBI",
|
| 93 |
+
"PhonePe OTP: 273948 for payment of Rs.150 to Zomato. Do not share. -PhonePe",
|
| 94 |
+
"Aadhaar OTP: 581234 for e-KYC verification. Valid 30 minutes. -UIDAI",
|
| 95 |
+
"Airtel Thanks! Your recharge of Rs.239 is successful. Validity: 28 days. Data: 1.5GB/day. -Airtel",
|
| 96 |
+
"Jio: Your recharge of Rs.299 is done. Validity 28 days, 2GB/day data. Enjoy! -Jio",
|
| 97 |
+
"Your electricity bill of Rs.1,234 for account 98765 is due on 10-May. Pay via BESCOM app.",
|
| 98 |
+
"Bijli Bill: Aapka MSEB bijli bill Rs.1,847 generate ho gaya hai. Due date: 10-Jun-26. Pay at mseb.co.in",
|
| 99 |
+
"Your Amazon order #402-9876543 is out for delivery today. Track: amzn.in/track -Amazon",
|
| 100 |
+
"Flipkart: Your order for boAt Earphones has been shipped. Tracking: fkrt.it/xyz -Flipkart",
|
| 101 |
+
"Zomato: Your order from Dominos has been picked up. ETA: 25 mins. -Zomato",
|
| 102 |
+
"Your IRCTC ticket PNR 4567891230 is confirmed. Train 12345 on 05-May. Seat: S4/32. -IRCTC",
|
| 103 |
+
"IndiGo: Your flight 6E-456 on 05-May is confirmed. PNR: ABCDEF. Web check-in open. -IndiGo",
|
| 104 |
+
"LIC: Your premium of Rs.5,000 for policy 123456789 is due on 10-May. Pay at licindia.in.",
|
| 105 |
+
"EPFO: Your PF balance as of 01-May-25 is Rs.2,45,678. Check on epfindia.gov.in. -EPFO",
|
| 106 |
+
"You received Rs.500 from Priya via UPI. UPI Ref: 123456789012. -Google Pay",
|
| 107 |
+
"PhonePe: Rs.1,200 sent to Ajay Kumar successfully. UPI Ref: 987654321. -PhonePe",
|
| 108 |
+
"Paytm: Rs.250 added to your wallet from HDFC Bank XX1234. Wallet Bal: Rs.430. -Paytm",
|
| 109 |
+
]
|
| 110 |
+
|
| 111 |
+
URL_RE = re.compile(r"(?:https?://|www\.)[^\s<>'\"]+", re.IGNORECASE)
|
| 112 |
+
BARE_DOMAIN_RE = re.compile(
|
| 113 |
+
r"\b(?:[a-zA-Z0-9-]+\.)+(?:com|org|net|edu|gov|gov\.in|co\.uk|co\.in|in|io|co|sbi|club|xyz|top|click|link|online|site|info|biz)\b",
|
| 114 |
+
re.IGNORECASE,
|
| 115 |
+
)
|
| 116 |
+
PHONE_RE = re.compile(r"(?:\+91[-\s]?)?\d[\d\s-]{8,}\d")
|
| 117 |
+
TOKEN_RE = re.compile(r"[a-z]+|[\u0900-\u097F]+")
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def get_feature_columns() -> list[str]:
|
| 121 |
+
return URL_FEATURE_COLS + TEXT_FEATURE_COLS
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def clean_text(text: str, remove_urls: bool = False) -> str:
|
| 125 |
+
text = "" if text is None else str(text)
|
| 126 |
+
if remove_urls:
|
| 127 |
+
text = URL_RE.sub("", text)
|
| 128 |
+
return re.sub(r"\s+", " ", text).strip()
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def extract_urls(text: str) -> list[str]:
|
| 132 |
+
text = "" if text is None else str(text)
|
| 133 |
+
urls = URL_RE.findall(text)
|
| 134 |
+
urls.extend(BARE_DOMAIN_RE.findall(text))
|
| 135 |
+
cleaned = []
|
| 136 |
+
for url in urls:
|
| 137 |
+
cleaned.append(url.strip(".,;:!?)\"]}'").lower())
|
| 138 |
+
return sorted(set(u for u in cleaned if u))
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def _registered_domain(url: str) -> tuple[str, str]:
|
| 142 |
+
ext = tldextract.extract(url)
|
| 143 |
+
domain = ext.domain.lower()
|
| 144 |
+
suffix = ext.suffix.lower()
|
| 145 |
+
full_domain = f"{domain}.{suffix}".strip(".")
|
| 146 |
+
return full_domain, suffix
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def url_features(text: str) -> dict:
|
| 150 |
+
urls = extract_urls(text)
|
| 151 |
+
|
| 152 |
+
suspicious_tld = 0
|
| 153 |
+
has_ip_url = 0
|
| 154 |
+
has_shortened_url = 0
|
| 155 |
+
has_legit_domain = 0
|
| 156 |
+
max_url_len = 0
|
| 157 |
+
|
| 158 |
+
for url in urls:
|
| 159 |
+
max_url_len = max(max_url_len, len(url))
|
| 160 |
+
full_domain, suffix = _registered_domain(url)
|
| 161 |
+
|
| 162 |
+
if suffix in SUSPICIOUS_TLDS:
|
| 163 |
+
suspicious_tld = 1
|
| 164 |
+
if full_domain in URL_SHORTENERS:
|
| 165 |
+
has_shortened_url = 1
|
| 166 |
+
if full_domain in LEGIT_DOMAINS or suffix in TRUSTED_TLDS:
|
| 167 |
+
has_legit_domain = 1
|
| 168 |
+
if re.search(r"https?://\d{1,3}(?:\.\d{1,3}){3}", url, re.IGNORECASE):
|
| 169 |
+
has_ip_url = 1
|
| 170 |
+
|
| 171 |
+
return {
|
| 172 |
+
"has_url": int(bool(urls)),
|
| 173 |
+
"num_urls": len(urls),
|
| 174 |
+
"has_http": int(any(u.startswith("http://") for u in urls)),
|
| 175 |
+
"has_https": int(any(u.startswith("https://") for u in urls)),
|
| 176 |
+
"suspicious_tld": suspicious_tld,
|
| 177 |
+
"max_url_len": max_url_len,
|
| 178 |
+
"has_ip_url": has_ip_url,
|
| 179 |
+
"has_shortened_url": has_shortened_url,
|
| 180 |
+
"has_legit_domain": has_legit_domain,
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
def text_features(text: str) -> dict:
|
| 185 |
+
text = "" if text is None else str(text)
|
| 186 |
+
lowered = text.lower()
|
| 187 |
+
tokens = TOKEN_RE.findall(lowered)
|
| 188 |
+
|
| 189 |
+
num_chars = len(text)
|
| 190 |
+
upper_count = sum(1 for c in text if c.isupper())
|
| 191 |
+
digit_count = sum(1 for c in text if c.isdigit())
|
| 192 |
+
special_count = sum(1 for c in text if c in "!@#$%^&*()_+-=[]{}|;:,.<>?")
|
| 193 |
+
urgency_count = sum(1 for token in tokens if token in URGENCY_WORDS)
|
| 194 |
+
urgency_count += sum(1 for phrase in URGENCY_PHRASES if phrase in lowered)
|
| 195 |
+
|
| 196 |
+
return {
|
| 197 |
+
"num_chars": num_chars,
|
| 198 |
+
"num_words": len(tokens),
|
| 199 |
+
"pct_upper": upper_count / num_chars if num_chars else 0.0,
|
| 200 |
+
"pct_digits": digit_count / num_chars if num_chars else 0.0,
|
| 201 |
+
"num_special": special_count,
|
| 202 |
+
"urgency_count": urgency_count,
|
| 203 |
+
"has_phone": int(bool(PHONE_RE.search(text))),
|
| 204 |
+
"has_currency": int(any(symbol in lowered for symbol in CURRENCY_SYMBOLS)),
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
def _standardize_label(value) -> Optional[int]:
|
| 209 |
+
if value is None or (isinstance(value, float) and np.isnan(value)):
|
| 210 |
+
return None
|
| 211 |
+
text = str(value).strip().lower()
|
| 212 |
+
mapping = {
|
| 213 |
+
"spam": 1, "scam": 1, "phishing": 1, "smishing": 1, "1": 1, "true": 1,
|
| 214 |
+
"ham": 0, "safe": 0, "legit": 0, "legitimate": 0, "not_spam": 0,
|
| 215 |
+
"0": 0, "false": 0,
|
| 216 |
+
}
|
| 217 |
+
return mapping.get(text)
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
def _standardize_frame(df: pd.DataFrame, message_cols: Iterable[str], label_cols: Iterable[str]) -> pd.DataFrame:
|
| 221 |
+
lower_map = {c.lower().strip(): c for c in df.columns}
|
| 222 |
+
msg_col = next((lower_map[c] for c in message_cols if c in lower_map), None)
|
| 223 |
+
lbl_col = next((lower_map[c] for c in label_cols if c in lower_map), None)
|
| 224 |
+
|
| 225 |
+
if msg_col is None or lbl_col is None:
|
| 226 |
+
return pd.DataFrame(columns=["message", "label"])
|
| 227 |
+
|
| 228 |
+
result = pd.DataFrame({
|
| 229 |
+
"message": df[msg_col].astype(str),
|
| 230 |
+
"label": df[lbl_col].apply(_standardize_label),
|
| 231 |
+
})
|
| 232 |
+
result = result.dropna(subset=["message", "label"])
|
| 233 |
+
result["label"] = result["label"].astype(int)
|
| 234 |
+
result = result[result["message"].str.strip() != ""]
|
| 235 |
+
return result[["message", "label"]].reset_index(drop=True)
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
def _load_local_spam_csv() -> pd.DataFrame:
|
| 239 |
+
path = DATA_DIR / "spam.csv"
|
| 240 |
+
if not path.exists():
|
| 241 |
+
return pd.DataFrame(columns=["message", "label"])
|
| 242 |
+
try:
|
| 243 |
+
df = pd.read_csv(path, encoding="latin-1")
|
| 244 |
+
result = _standardize_frame(df, ["v2", "message", "text", "sms"], ["v1", "label", "labels", "category"])
|
| 245 |
+
print(f" Local UCI spam.csv: {len(result)} messages loaded")
|
| 246 |
+
return result
|
| 247 |
+
except Exception as exc:
|
| 248 |
+
print(f" Warning: failed to load local spam.csv: {exc}")
|
| 249 |
+
return pd.DataFrame(columns=["message", "label"])
|
| 250 |
+
|
| 251 |
+
|
| 252 |
+
def _load_local_parquet() -> pd.DataFrame:
|
| 253 |
+
frames = []
|
| 254 |
+
for path in sorted(DATA_DIR.glob("*.parquet")):
|
| 255 |
+
try:
|
| 256 |
+
raw = pd.read_parquet(path)
|
| 257 |
+
result = _standardize_frame(raw, ["message", "text", "sms", "email"], ["label", "labels", "category", "class"])
|
| 258 |
+
if len(result):
|
| 259 |
+
print(f" Local {path.name}: {len(result)} messages loaded")
|
| 260 |
+
frames.append(result)
|
| 261 |
+
except Exception as exc:
|
| 262 |
+
print(f" Warning: failed to load {path.name}: {exc}")
|
| 263 |
+
if not frames:
|
| 264 |
+
return pd.DataFrame(columns=["message", "label"])
|
| 265 |
+
return pd.concat(frames, ignore_index=True)
|
| 266 |
+
|
| 267 |
+
|
| 268 |
+
def _load_synthetic_indian_legit() -> pd.DataFrame:
|
| 269 |
+
print(f" Synthetic Indian legit SMS: {len(INDIAN_LEGIT_SMS)} messages loaded")
|
| 270 |
+
return pd.DataFrame({"message": INDIAN_LEGIT_SMS, "label": 0})
|
| 271 |
+
|
| 272 |
+
|
| 273 |
+
def _load_huggingface_dataset(name: str) -> pd.DataFrame:
|
| 274 |
+
try:
|
| 275 |
+
from datasets import load_dataset as hf_load
|
| 276 |
+
|
| 277 |
+
ds = hf_load(name)
|
| 278 |
+
frames = []
|
| 279 |
+
for split_name in ds.keys():
|
| 280 |
+
raw = ds[split_name].to_pandas()
|
| 281 |
+
frames.append(_standardize_frame(raw, ["message", "text", "sms", "email"], ["label", "labels", "category", "class"]))
|
| 282 |
+
result = pd.concat(frames, ignore_index=True)
|
| 283 |
+
print(f" HuggingFace {name}: {len(result)} messages loaded")
|
| 284 |
+
return result
|
| 285 |
+
except Exception as exc:
|
| 286 |
+
print(f" Warning: failed to load HuggingFace {name}: {exc}")
|
| 287 |
+
return pd.DataFrame(columns=["message", "label"])
|
| 288 |
+
|
| 289 |
+
|
| 290 |
+
def _load_multilingual_hf(name: str) -> pd.DataFrame:
|
| 291 |
+
try:
|
| 292 |
+
from datasets import load_dataset as hf_load
|
| 293 |
+
|
| 294 |
+
ds = hf_load(name)
|
| 295 |
+
raw = pd.concat([ds[s].to_pandas() for s in ds.keys()], ignore_index=True)
|
| 296 |
+
lower_map = {c.lower().strip(): c for c in raw.columns}
|
| 297 |
+
label_col = next((lower_map[c] for c in ["label", "labels", "v1", "category", "class"] if c in lower_map), None)
|
| 298 |
+
if label_col is None:
|
| 299 |
+
return pd.DataFrame(columns=["message", "label"])
|
| 300 |
+
|
| 301 |
+
message_cols = []
|
| 302 |
+
for col in raw.columns:
|
| 303 |
+
lower = col.lower().strip()
|
| 304 |
+
if lower in {"text", "message", "sms", "v2", "text_en", "en", "text_hi", "hi", "hindi"} or lower.endswith("_hi"):
|
| 305 |
+
message_cols.append(col)
|
| 306 |
+
|
| 307 |
+
frames = []
|
| 308 |
+
for col in message_cols:
|
| 309 |
+
frame = pd.DataFrame({"message": raw[col].astype(str), "label": raw[label_col].apply(_standardize_label)})
|
| 310 |
+
frame = frame.dropna(subset=["message", "label"])
|
| 311 |
+
frame["label"] = frame["label"].astype(int)
|
| 312 |
+
frame = frame[frame["message"].str.strip() != ""]
|
| 313 |
+
frames.append(frame[["message", "label"]])
|
| 314 |
+
|
| 315 |
+
result = pd.concat(frames, ignore_index=True) if frames else pd.DataFrame(columns=["message", "label"])
|
| 316 |
+
print(f" Multilingual {name}: {len(result)} messages loaded")
|
| 317 |
+
return result
|
| 318 |
+
except Exception as exc:
|
| 319 |
+
print(f" Warning: failed to load multilingual {name}: {exc}")
|
| 320 |
+
return pd.DataFrame(columns=["message", "label"])
|
| 321 |
+
|
| 322 |
+
|
| 323 |
+
def _deduplicate(df: pd.DataFrame) -> pd.DataFrame:
|
| 324 |
+
df = df.copy()
|
| 325 |
+
df["_norm"] = df["message"].astype(str).str.lower().str.strip().str.replace(r"\s+", " ", regex=True)
|
| 326 |
+
df = df.drop_duplicates(subset="_norm", keep="first").drop(columns=["_norm"])
|
| 327 |
+
return df.reset_index(drop=True)
|
| 328 |
+
|
| 329 |
+
|
| 330 |
+
def _add_features(df: pd.DataFrame) -> pd.DataFrame:
|
| 331 |
+
url_rows = []
|
| 332 |
+
text_rows = []
|
| 333 |
+
cleaned = []
|
| 334 |
+
|
| 335 |
+
for message in df["message"]:
|
| 336 |
+
message = str(message)
|
| 337 |
+
url_rows.append(url_features(message))
|
| 338 |
+
text_rows.append(text_features(message))
|
| 339 |
+
cleaned.append(clean_text(message))
|
| 340 |
+
|
| 341 |
+
result = pd.concat(
|
| 342 |
+
[df.reset_index(drop=True), pd.DataFrame(url_rows), pd.DataFrame(text_rows)],
|
| 343 |
+
axis=1,
|
| 344 |
+
)
|
| 345 |
+
result["message"] = cleaned
|
| 346 |
+
return result
|
| 347 |
+
|
| 348 |
+
|
| 349 |
+
def load_dataset(use_remote: Optional[bool] = None) -> pd.DataFrame:
|
| 350 |
+
"""
|
| 351 |
+
Load SMS datasets and return message, label, and feature columns.
|
| 352 |
+
|
| 353 |
+
Remote loaders are enabled by default. Set SCAMSHIELD_USE_REMOTE_DATA=0 or
|
| 354 |
+
pass use_remote=False to train only on local files plus synthetic examples.
|
| 355 |
+
"""
|
| 356 |
+
if use_remote is None:
|
| 357 |
+
use_remote = os.getenv("SCAMSHIELD_USE_REMOTE_DATA", "1").lower() not in {"0", "false", "no"}
|
| 358 |
+
|
| 359 |
+
print("Loading datasets...")
|
| 360 |
+
frames = [
|
| 361 |
+
_load_local_spam_csv(),
|
| 362 |
+
_load_local_parquet(),
|
| 363 |
+
_load_synthetic_indian_legit(),
|
| 364 |
+
]
|
| 365 |
+
|
| 366 |
+
if use_remote:
|
| 367 |
+
frames.extend([
|
| 368 |
+
_load_huggingface_dataset("Deysi/spam-detection-dataset"),
|
| 369 |
+
_load_huggingface_dataset("Ngadou/Spam_SMS"),
|
| 370 |
+
_load_multilingual_hf("dbarbedillo/SMS_Spam_Multilingual_Collection_Dataset"),
|
| 371 |
+
])
|
| 372 |
+
|
| 373 |
+
frames = [frame for frame in frames if len(frame) > 0]
|
| 374 |
+
if not frames:
|
| 375 |
+
raise RuntimeError("No datasets loaded. Check local data files or enable remote datasets.")
|
| 376 |
+
|
| 377 |
+
df = pd.concat(frames, ignore_index=True)
|
| 378 |
+
before = len(df)
|
| 379 |
+
df = _deduplicate(df)
|
| 380 |
+
if before != len(df):
|
| 381 |
+
print(f" Removed {before - len(df)} duplicate messages")
|
| 382 |
+
|
| 383 |
+
df = _add_features(df)
|
| 384 |
+
spam_count = int(df["label"].sum())
|
| 385 |
+
ham_count = len(df) - spam_count
|
| 386 |
+
print(f"\nDataset loaded: {len(df)} messages ({spam_count} spam, {ham_count} ham)")
|
| 387 |
+
return df
|
| 388 |
+
|
| 389 |
+
|
| 390 |
+
def split_dataset(df: pd.DataFrame, seed: int = 42) -> tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame]:
|
| 391 |
+
train_df, temp_df = train_test_split(
|
| 392 |
+
df,
|
| 393 |
+
test_size=0.30,
|
| 394 |
+
stratify=df["label"],
|
| 395 |
+
random_state=seed,
|
| 396 |
+
)
|
| 397 |
+
val_df, test_df = train_test_split(
|
| 398 |
+
temp_df,
|
| 399 |
+
test_size=0.50,
|
| 400 |
+
stratify=temp_df["label"],
|
| 401 |
+
random_state=seed,
|
| 402 |
+
)
|
| 403 |
+
return train_df.reset_index(drop=True), val_df.reset_index(drop=True), test_df.reset_index(drop=True)
|
smishing_detector/utils/encryption.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
utils/encryption.py — AES-256-CBC encrypt/decrypt for secure API communication.
|
| 3 |
+
|
| 4 |
+
Shared key is stored in .env as SMS_ENCRYPTION_KEY (64-char hex = 32 bytes).
|
| 5 |
+
Mobile encrypts SMS body with the same key before sending to /predict_secure.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import os
|
| 9 |
+
import base64
|
| 10 |
+
import secrets
|
| 11 |
+
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
| 12 |
+
from cryptography.hazmat.backends import default_backend
|
| 13 |
+
from dotenv import load_dotenv
|
| 14 |
+
|
| 15 |
+
load_dotenv()
|
| 16 |
+
|
| 17 |
+
# 32-byte (256-bit) key from env — generate once with:
|
| 18 |
+
# python -c "import os; print(os.urandom(32).hex())"
|
| 19 |
+
def _load_key_hex() -> str:
|
| 20 |
+
key_hex = os.getenv("SMS_ENCRYPTION_KEY")
|
| 21 |
+
if not key_hex:
|
| 22 |
+
# Fallback to known default so mobile and server stay in sync
|
| 23 |
+
# when .env is not yet configured. Set SMS_ENCRYPTION_KEY in .env
|
| 24 |
+
# before deploying to production.
|
| 25 |
+
key_hex = "5fc5555dfd4b23ecfbfbfda273cb82eb5fdfb8b25c5b1357a2568d6afa1f472e"
|
| 26 |
+
print("Warning: SMS_ENCRYPTION_KEY not set in .env; using built-in dev key.")
|
| 27 |
+
if len(key_hex) != 64:
|
| 28 |
+
raise ValueError("SMS_ENCRYPTION_KEY must be a 64-character hex string.")
|
| 29 |
+
bytes.fromhex(key_hex) # validate it's valid hex
|
| 30 |
+
return key_hex
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
_KEY_HEX = _load_key_hex()
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def get_key() -> bytes:
|
| 37 |
+
return bytes.fromhex(_KEY_HEX)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def decrypt_message(encrypted_b64: str) -> str:
|
| 41 |
+
"""
|
| 42 |
+
Decrypt a base64-encoded AES-256-CBC ciphertext.
|
| 43 |
+
Format: base64(IV[16] + ciphertext)
|
| 44 |
+
Matching encrypt_message() in mobile src/utils/encryption.js
|
| 45 |
+
"""
|
| 46 |
+
data = base64.b64decode(encrypted_b64)
|
| 47 |
+
iv = data[:16]
|
| 48 |
+
ciphertext = data[16:]
|
| 49 |
+
key = get_key()
|
| 50 |
+
|
| 51 |
+
cipher = Cipher(algorithms.AES(key), modes.CBC(iv), backend=default_backend())
|
| 52 |
+
decryptor = cipher.decryptor()
|
| 53 |
+
padded = decryptor.update(ciphertext) + decryptor.finalize()
|
| 54 |
+
|
| 55 |
+
# Remove PKCS7 padding
|
| 56 |
+
pad_len = padded[-1]
|
| 57 |
+
return padded[:-pad_len].decode("utf-8")
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def encrypt_message(plaintext: str) -> str:
|
| 61 |
+
"""
|
| 62 |
+
Encrypt a string with AES-256-CBC (for testing / server-to-client responses).
|
| 63 |
+
Returns base64(IV[16] + ciphertext).
|
| 64 |
+
"""
|
| 65 |
+
key = get_key()
|
| 66 |
+
iv = secrets.token_bytes(16)
|
| 67 |
+
|
| 68 |
+
# PKCS7 padding
|
| 69 |
+
raw = plaintext.encode("utf-8")
|
| 70 |
+
pad = 16 - (len(raw) % 16)
|
| 71 |
+
raw += bytes([pad] * pad)
|
| 72 |
+
|
| 73 |
+
cipher = Cipher(algorithms.AES(key), modes.CBC(iv), backend=default_backend())
|
| 74 |
+
encryptor = cipher.encryptor()
|
| 75 |
+
ciphertext = encryptor.update(raw) + encryptor.finalize()
|
| 76 |
+
|
| 77 |
+
return base64.b64encode(iv + ciphertext).decode("utf-8")
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def get_key_hex() -> str:
|
| 81 |
+
"""Return current key as hex — sent to client on /api/encryption-key."""
|
| 82 |
+
return _KEY_HEX
|
smishing_detector/utils/multilingual_loader.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
utils/multilingual_loader.py
|
| 3 |
+
-----------------------------
|
| 4 |
+
Loads Hindi and Hinglish test datasets for multilingual evaluation.
|
| 5 |
+
Used ONLY for evaluation — not added to training data.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import pandas as pd
|
| 9 |
+
import numpy as np
|
| 10 |
+
import re
|
| 11 |
+
from datasets import load_dataset as hf_load
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def detect_language(text: str) -> str:
|
| 15 |
+
"""
|
| 16 |
+
Detect if a message is:
|
| 17 |
+
- 'hindi' : contains Devanagari Unicode characters (pure Hindi)
|
| 18 |
+
- 'hinglish': Roman script with Hindi words mixed with English
|
| 19 |
+
- 'english' : standard English
|
| 20 |
+
"""
|
| 21 |
+
# Check for Devanagari Unicode range (0900-097F)
|
| 22 |
+
devanagari_pattern = re.compile(r'[\u0900-\u097F]')
|
| 23 |
+
if devanagari_pattern.search(text):
|
| 24 |
+
return 'hindi'
|
| 25 |
+
|
| 26 |
+
# Common Hinglish words that appear in Roman script
|
| 27 |
+
hinglish_markers = {
|
| 28 |
+
'aapka', 'aapke', 'abhi', 'karo', 'kare', 'kiya', 'nahi', 'nai',
|
| 29 |
+
'hai', 'hain', 'ho', 'ka', 'ki', 'ke', 'ko', 'se', 'mein', 'par',
|
| 30 |
+
'aur', 'bhi', 'yeh', 'woh', 'ap', 'apka', 'jana', 'karna', 'dena',
|
| 31 |
+
'lena', 'milega', 'milegi', 'paisa', 'rupaye', 'khata', 'band',
|
| 32 |
+
'verify', 'kijiye', 'dijiye', 'lijiye', 'phir', 'agar', 'toh',
|
| 33 |
+
'jaldi', 'turant', 'abhi', 'sirf', 'bahut', 'accha', 'theek'
|
| 34 |
+
}
|
| 35 |
+
words = set(text.lower().split())
|
| 36 |
+
if len(words.intersection(hinglish_markers)) >= 2:
|
| 37 |
+
return 'hinglish'
|
| 38 |
+
|
| 39 |
+
return 'english'
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def load_hindi_test_data() -> pd.DataFrame:
|
| 43 |
+
"""
|
| 44 |
+
Load Hindi (Devanagari) test data from HuggingFace multilingual dataset.
|
| 45 |
+
Returns DataFrame with columns: message, label, language
|
| 46 |
+
"""
|
| 47 |
+
print("Loading Hindi test data from HuggingFace...")
|
| 48 |
+
try:
|
| 49 |
+
ds = hf_load("dbarbedillo/SMS_Spam_Multilingual_Collection_Dataset")
|
| 50 |
+
|
| 51 |
+
dfs = []
|
| 52 |
+
for split_name in ds.keys():
|
| 53 |
+
split_df = ds[split_name].to_pandas()
|
| 54 |
+
dfs.append(split_df)
|
| 55 |
+
df = pd.concat(dfs, ignore_index=True)
|
| 56 |
+
|
| 57 |
+
# This dataset has columns for each language
|
| 58 |
+
# Find the Hindi column
|
| 59 |
+
hindi_col = None
|
| 60 |
+
for col in df.columns:
|
| 61 |
+
if 'hindi' in col.lower() or 'hi' == col.lower():
|
| 62 |
+
hindi_col = col
|
| 63 |
+
break
|
| 64 |
+
|
| 65 |
+
if hindi_col is None:
|
| 66 |
+
# Try text_hi column (actual name in this dataset)
|
| 67 |
+
if 'text_hi' in df.columns:
|
| 68 |
+
hindi_col = 'text_hi'
|
| 69 |
+
else:
|
| 70 |
+
# Auto-detect by Devanagari content
|
| 71 |
+
skip = {'label', 'labels', 'v1', 'v2'}
|
| 72 |
+
for col in df.columns:
|
| 73 |
+
if col in skip:
|
| 74 |
+
continue
|
| 75 |
+
sample = df[col].dropna().head(10).astype(str)
|
| 76 |
+
devanagari = re.compile(r'[\u0900-\u097F]')
|
| 77 |
+
if any(devanagari.search(s) for s in sample):
|
| 78 |
+
hindi_col = col
|
| 79 |
+
break
|
| 80 |
+
|
| 81 |
+
if hindi_col is None:
|
| 82 |
+
print(" Warning: Could not find Hindi column")
|
| 83 |
+
return pd.DataFrame(columns=['message', 'label', 'language'])
|
| 84 |
+
|
| 85 |
+
# Get label column — could be 'v1', 'label', or 'labels' (case-insensitive)
|
| 86 |
+
col_lower_map = {c.lower().strip(): c for c in df.columns}
|
| 87 |
+
label_col = next((col_lower_map[c] for c in ['v1', 'label', 'labels'] if c in col_lower_map), None)
|
| 88 |
+
if label_col is None:
|
| 89 |
+
print(f" Warning: No label column found. Got: {df.columns.tolist()}")
|
| 90 |
+
return pd.DataFrame(columns=['message', 'label', 'language'])
|
| 91 |
+
|
| 92 |
+
result_df = pd.DataFrame({
|
| 93 |
+
'message': df[hindi_col].astype(str),
|
| 94 |
+
'label': df[label_col]
|
| 95 |
+
})
|
| 96 |
+
|
| 97 |
+
# Standardise labels
|
| 98 |
+
label_map = {'ham': 0, 'spam': 1, 0: 0, 1: 1, '0': 0, '1': 1}
|
| 99 |
+
result_df['label'] = result_df['label'].map(label_map)
|
| 100 |
+
result_df = result_df.dropna(subset=['message', 'label'])
|
| 101 |
+
result_df['label'] = result_df['label'].astype(int)
|
| 102 |
+
result_df['language'] = 'hindi'
|
| 103 |
+
|
| 104 |
+
print(f" Hindi test data: {len(result_df)} messages "
|
| 105 |
+
f"({result_df['label'].sum()} spam, "
|
| 106 |
+
f"{(result_df['label']==0).sum()} ham)")
|
| 107 |
+
return result_df.reset_index(drop=True)
|
| 108 |
+
|
| 109 |
+
except Exception as e:
|
| 110 |
+
print(f" Warning: Failed to load Hindi data: {e}")
|
| 111 |
+
return pd.DataFrame(columns=['message', 'label', 'language'])
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def load_hinglish_test_data() -> pd.DataFrame:
|
| 115 |
+
"""
|
| 116 |
+
Load Hinglish test data from Kaggle multilingual spam dataset.
|
| 117 |
+
Returns DataFrame with columns: message, label, language
|
| 118 |
+
"""
|
| 119 |
+
print("Loading Hinglish test data from Kaggle...")
|
| 120 |
+
try:
|
| 121 |
+
import kagglehub
|
| 122 |
+
path = kagglehub.dataset_download("rajnathpatel/multilingual-spam-data")
|
| 123 |
+
|
| 124 |
+
import os
|
| 125 |
+
csv_files = [f for f in os.listdir(path) if f.endswith('.csv')]
|
| 126 |
+
if not csv_files:
|
| 127 |
+
print(" Warning: No CSV files in Kaggle multilingual dataset")
|
| 128 |
+
return pd.DataFrame(columns=['message', 'label', 'language'])
|
| 129 |
+
|
| 130 |
+
dfs = []
|
| 131 |
+
for csv_file in csv_files:
|
| 132 |
+
try:
|
| 133 |
+
df = pd.read_csv(f"{path}/{csv_file}", encoding='latin-1')
|
| 134 |
+
dfs.append(df)
|
| 135 |
+
except Exception:
|
| 136 |
+
continue
|
| 137 |
+
|
| 138 |
+
if not dfs:
|
| 139 |
+
return pd.DataFrame(columns=['message', 'label', 'language'])
|
| 140 |
+
|
| 141 |
+
df = pd.concat(dfs, ignore_index=True)
|
| 142 |
+
|
| 143 |
+
# Standardise column names
|
| 144 |
+
col_lower = {c: c.lower().strip() for c in df.columns}
|
| 145 |
+
df = df.rename(columns=col_lower)
|
| 146 |
+
|
| 147 |
+
# Find message and label columns
|
| 148 |
+
msg_col = next((c for c in df.columns if c in ['text', 'message', 'sms', 'v2']), None)
|
| 149 |
+
lbl_col = next((c for c in ['label', 'labels', 'v1', 'category', 'class'] if c in df.columns), None)
|
| 150 |
+
|
| 151 |
+
if not msg_col or not lbl_col:
|
| 152 |
+
print(f" Warning: Could not identify columns. Found: {df.columns.tolist()}")
|
| 153 |
+
return pd.DataFrame(columns=['message', 'label', 'language'])
|
| 154 |
+
|
| 155 |
+
result_df = pd.DataFrame({
|
| 156 |
+
'message': df[msg_col].astype(str),
|
| 157 |
+
'label': df[lbl_col]
|
| 158 |
+
})
|
| 159 |
+
|
| 160 |
+
label_map = {'ham': 0, 'spam': 1, 0: 0, 1: 1, '0': 0, '1': 1}
|
| 161 |
+
result_df['label'] = result_df['label'].map(label_map)
|
| 162 |
+
result_df = result_df.dropna(subset=['message', 'label'])
|
| 163 |
+
result_df['label'] = result_df['label'].astype(int)
|
| 164 |
+
|
| 165 |
+
# Detect language per message and keep only Hindi/Hinglish
|
| 166 |
+
result_df['language'] = result_df['message'].apply(detect_language)
|
| 167 |
+
result_df = result_df[result_df['language'].isin(['hindi', 'hinglish'])]
|
| 168 |
+
|
| 169 |
+
print(f" Hinglish/Hindi test data: {len(result_df)} messages "
|
| 170 |
+
f"({result_df['label'].sum()} spam, "
|
| 171 |
+
f"{(result_df['label']==0).sum()} ham)")
|
| 172 |
+
return result_df.reset_index(drop=True)
|
| 173 |
+
|
| 174 |
+
except Exception as e:
|
| 175 |
+
print(f" Warning: Failed to load Hinglish data: {e}")
|
| 176 |
+
return pd.DataFrame(columns=['message', 'label', 'language'])
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
def load_all_multilingual_test_data() -> dict[str, pd.DataFrame]:
|
| 180 |
+
"""
|
| 181 |
+
Loads all multilingual test data.
|
| 182 |
+
Returns dict with keys: 'hindi', 'hinglish', 'combined'
|
| 183 |
+
"""
|
| 184 |
+
hindi_df = load_hindi_test_data()
|
| 185 |
+
hinglish_df = load_hinglish_test_data()
|
| 186 |
+
|
| 187 |
+
# Separate hinglish from hindi in the combined df
|
| 188 |
+
pure_hindi = pd.concat([
|
| 189 |
+
hindi_df,
|
| 190 |
+
hinglish_df[hinglish_df['language'] == 'hindi']
|
| 191 |
+
], ignore_index=True)
|
| 192 |
+
|
| 193 |
+
pure_hinglish = hinglish_df[hinglish_df['language'] == 'hinglish'].reset_index(drop=True)
|
| 194 |
+
|
| 195 |
+
combined = pd.concat([pure_hindi, pure_hinglish], ignore_index=True)
|
| 196 |
+
|
| 197 |
+
print(f"\nTotal multilingual test data:")
|
| 198 |
+
print(f" Pure Hindi: {len(pure_hindi)} messages")
|
| 199 |
+
print(f" Hinglish: {len(pure_hinglish)} messages")
|
| 200 |
+
print(f" Combined: {len(combined)} messages")
|
| 201 |
+
|
| 202 |
+
return {
|
| 203 |
+
'hindi': pure_hindi,
|
| 204 |
+
'hinglish': pure_hinglish,
|
| 205 |
+
'combined': combined
|
| 206 |
+
}
|
smishing_detector/utils/safe_browsing.py
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Google Safe Browsing API integration for domain legitimacy checking."""
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
import time
|
| 5 |
+
import requests
|
| 6 |
+
from typing import Optional, Dict
|
| 7 |
+
from dotenv import load_dotenv
|
| 8 |
+
|
| 9 |
+
# Load .env file from project root
|
| 10 |
+
load_dotenv()
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
# Google Safe Browsing API v4 endpoint
|
| 14 |
+
SAFE_BROWSING_API_URL = "https://safebrowsing.googleapis.com/v4/threatMatches:find"
|
| 15 |
+
|
| 16 |
+
# Threat types to check for
|
| 17 |
+
THREAT_TYPES = ["MALWARE", "SOCIAL_ENGINEERING", "UNWANTED_SOFTWARE", "POTENTIALLY_HARMFUL_APPLICATION"]
|
| 18 |
+
|
| 19 |
+
# Platform types - use ANY_PLATFORM to check all platforms
|
| 20 |
+
PLATFORM_TYPES = ["ANY_PLATFORM"]
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class SafeBrowsingChecker:
|
| 24 |
+
"""Wrapper for Google Safe Browsing API with caching and fallback logic."""
|
| 25 |
+
|
| 26 |
+
def __init__(self):
|
| 27 |
+
"""Initialize the Safe Browsing checker."""
|
| 28 |
+
self.api_key = os.getenv("GOOGLE_SAFE_BROWSING_API_KEY")
|
| 29 |
+
self.cache: Dict[str, str] = {} # domain -> known_safe | known_malicious | unknown
|
| 30 |
+
self.cache_timestamps: Dict[str, float] = {} # domain -> timestamp
|
| 31 |
+
self.cache_ttl = 86400 # 24 hours in seconds
|
| 32 |
+
self.api_available = True
|
| 33 |
+
self.last_api_check = time.time()
|
| 34 |
+
self.fallback_legit_domains = {
|
| 35 |
+
'amazon.com', 'amazon.co.uk', 'amazon.in', 'netflix.com', 'spotify.com', 'apple.com',
|
| 36 |
+
'google.com', 'microsoft.com', 'paypal.com', 'ebay.com', 'walmart.com',
|
| 37 |
+
'target.com', 'bestbuy.com', 'costco.com', 'fedex.com', 'ups.com', 'usps.com',
|
| 38 |
+
'airlines.com', 'ba.com', 'united.com', 'delta.com', 'jetblue.com',
|
| 39 |
+
'library.org', 'cvs.com', 'walgreens.com', 'riteaid.com', 'shopify.com',
|
| 40 |
+
'slack.com', 'zoom.us', 'gmail.com', 'outlook.com', 'yahoo.com',
|
| 41 |
+
'flipkart.com', 'myntra.com', 'swiggy.com', 'zomato.com', 'makemytrip.com', 'yatra.com',
|
| 42 |
+
'cleartrip.com', 'paytm.com', 'phonepe.com', 'hdfcbank.com', 'onlinesbi.sbi', 'icicibank.com',
|
| 43 |
+
'axisbank.com', 'kotak.com', 'airindia.in', 'goindigo.in', 'irctc.co.in', 'redbus.in',
|
| 44 |
+
'olacabs.com', 'hotstar.com', 'sonyliv.com', 'zee5.com', 'jiocinema.com',
|
| 45 |
+
'timesofindia.indiatimes.com', 'hindustantimes.com', 'thehindu.com', 'ndtv.com', 'moneycontrol.com',
|
| 46 |
+
'bseindia.com', 'nseindia.com', 'naukri.com', 'shaadi.com', 'bharatmatrimony.com',
|
| 47 |
+
'bookmyshow.com', 'policybazaar.com', 'nykaa.com', 'ajio.com', 'firstcry.com',
|
| 48 |
+
'bigbasket.com', 'blinkit.com', 'zepto.com', 'upstox.com', 'groww.in', 'zerodha.com',
|
| 49 |
+
'airtel.in', 'jio.com', 'myvi.in', 'vi.com', 'bsnl.co.in', 'tataplay.com', 'dishtv.in',
|
| 50 |
+
'sbicard.com', 'bajajfinserv.in', 'cred.club',
|
| 51 |
+
# Tech/Development domains
|
| 52 |
+
'github.com', 'gitlab.com', 'bitbucket.org', 'stackoverflow.com', 'developer.mozilla.org',
|
| 53 |
+
'npm.com', 'pypi.org', 'docker.com', 'heroku.com', 'aws.amazon.com',
|
| 54 |
+
'azure.microsoft.com', 'gcp.google.com', 'atlassian.com', 'jira.com', 'confluence.com'
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
def _is_cache_valid(self, domain: str) -> bool:
|
| 58 |
+
"""Check if cached result is still valid."""
|
| 59 |
+
if domain not in self.cache_timestamps:
|
| 60 |
+
return False
|
| 61 |
+
return (time.time() - self.cache_timestamps[domain]) < self.cache_ttl
|
| 62 |
+
|
| 63 |
+
def _check_safe_browsing_api(self, domain: str) -> Optional[str]:
|
| 64 |
+
"""
|
| 65 |
+
Query Google Safe Browsing API for known threats.
|
| 66 |
+
Returns known_malicious if threats are found, unknown if no match exists,
|
| 67 |
+
and None if the API could not be queried.
|
| 68 |
+
"""
|
| 69 |
+
if not self.api_key:
|
| 70 |
+
return None
|
| 71 |
+
|
| 72 |
+
try:
|
| 73 |
+
payload = {
|
| 74 |
+
"client": {
|
| 75 |
+
"clientId": "smishing-detector",
|
| 76 |
+
"clientVersion": "1.0"
|
| 77 |
+
},
|
| 78 |
+
"threatInfo": {
|
| 79 |
+
"threatTypes": THREAT_TYPES,
|
| 80 |
+
"platformTypes": PLATFORM_TYPES,
|
| 81 |
+
"threatEntries": [
|
| 82 |
+
{"url": f"http://{domain}"},
|
| 83 |
+
{"url": f"https://{domain}"}
|
| 84 |
+
]
|
| 85 |
+
}
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
response = requests.post(
|
| 89 |
+
f"{SAFE_BROWSING_API_URL}?key={self.api_key}",
|
| 90 |
+
json=payload,
|
| 91 |
+
timeout=5
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
+
if response.status_code == 200:
|
| 95 |
+
data = response.json()
|
| 96 |
+
if data.get("matches"):
|
| 97 |
+
return "known_malicious"
|
| 98 |
+
# Safe Browsing is a blocklist. No match means "not known bad",
|
| 99 |
+
# not proof that a new phishing domain is legitimate.
|
| 100 |
+
return "unknown"
|
| 101 |
+
else:
|
| 102 |
+
# Log the error response for debugging
|
| 103 |
+
error_msg = f"Safe Browsing API returned {response.status_code} for {domain}"
|
| 104 |
+
try:
|
| 105 |
+
error_details = response.json()
|
| 106 |
+
error_msg += f": {error_details}"
|
| 107 |
+
except:
|
| 108 |
+
error_msg += f": {response.text}"
|
| 109 |
+
print(error_msg)
|
| 110 |
+
|
| 111 |
+
# API error - mark API as unavailable for a while
|
| 112 |
+
self.api_available = False
|
| 113 |
+
self.last_api_check = time.time()
|
| 114 |
+
return None
|
| 115 |
+
|
| 116 |
+
except requests.exceptions.Timeout:
|
| 117 |
+
print(f"Safe Browsing API timeout for {domain}")
|
| 118 |
+
self.api_available = False
|
| 119 |
+
self.last_api_check = time.time()
|
| 120 |
+
return None
|
| 121 |
+
except Exception as e:
|
| 122 |
+
print(f"Safe Browsing API error for {domain}: {e}")
|
| 123 |
+
return None
|
| 124 |
+
|
| 125 |
+
def is_domain_legitimate(self, domain: str) -> bool:
|
| 126 |
+
"""
|
| 127 |
+
Check if a domain is legitimate using Google Safe Browsing API.
|
| 128 |
+
Falls back to cached hardcoded domains if API is unavailable.
|
| 129 |
+
|
| 130 |
+
Args:
|
| 131 |
+
domain: Domain name to check (e.g., 'google.com')
|
| 132 |
+
|
| 133 |
+
Returns:
|
| 134 |
+
True if domain appears safe, False otherwise
|
| 135 |
+
"""
|
| 136 |
+
# Normalize domain
|
| 137 |
+
domain = domain.lower().strip()
|
| 138 |
+
|
| 139 |
+
# Check cache first
|
| 140 |
+
if domain in self.cache and self._is_cache_valid(domain):
|
| 141 |
+
return self.cache[domain] == "known_safe"
|
| 142 |
+
|
| 143 |
+
# Check hardcoded fallback list (always trust these)
|
| 144 |
+
if domain in self.fallback_legit_domains:
|
| 145 |
+
self.cache[domain] = "known_safe"
|
| 146 |
+
self.cache_timestamps[domain] = time.time()
|
| 147 |
+
return True
|
| 148 |
+
|
| 149 |
+
# Try API if available - will detect known malicious domains
|
| 150 |
+
if self.api_available or (time.time() - self.last_api_check) > 300: # Retry every 5 minutes
|
| 151 |
+
result = self._check_safe_browsing_api(domain)
|
| 152 |
+
if result is not None:
|
| 153 |
+
# result=True means GSB found NO threats → domain is safe
|
| 154 |
+
# result=False means GSB found threats → domain is dangerous
|
| 155 |
+
self.cache[domain] = result
|
| 156 |
+
self.cache_timestamps[domain] = time.time()
|
| 157 |
+
return self.cache[domain] == "known_safe"
|
| 158 |
+
|
| 159 |
+
# API unavailable — conservatively treat unknown domains as not legitimate
|
| 160 |
+
self.cache[domain] = "unknown"
|
| 161 |
+
self.cache_timestamps[domain] = time.time()
|
| 162 |
+
return False
|
| 163 |
+
|
| 164 |
+
def check_domain_status(self, domain: str) -> str:
|
| 165 |
+
"""Return known_safe, known_malicious, or unknown for a domain."""
|
| 166 |
+
domain = domain.lower().strip()
|
| 167 |
+
self.is_domain_legitimate(domain)
|
| 168 |
+
return self.cache.get(domain, "unknown")
|
| 169 |
+
|
| 170 |
+
def clear_cache(self):
|
| 171 |
+
"""Clear the domain cache."""
|
| 172 |
+
self.cache.clear()
|
| 173 |
+
self.cache_timestamps.clear()
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
# Global instance
|
| 177 |
+
_checker = None
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
def get_checker() -> SafeBrowsingChecker:
|
| 181 |
+
"""Get the global Safe Browsing checker instance."""
|
| 182 |
+
global _checker
|
| 183 |
+
if _checker is None:
|
| 184 |
+
_checker = SafeBrowsingChecker()
|
| 185 |
+
return _checker
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
def is_domain_legitimate(domain: str) -> bool:
|
| 189 |
+
"""
|
| 190 |
+
Check if a domain is legitimate using Google Safe Browsing API.
|
| 191 |
+
|
| 192 |
+
Args:
|
| 193 |
+
domain: Domain name to check (e.g., 'google.com')
|
| 194 |
+
|
| 195 |
+
Returns:
|
| 196 |
+
True if domain appears safe, False otherwise
|
| 197 |
+
"""
|
| 198 |
+
return get_checker().is_domain_legitimate(domain)
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
def check_domain_status(domain: str) -> str:
|
| 202 |
+
"""Return known_safe, known_malicious, or unknown for a domain."""
|
| 203 |
+
return get_checker().check_domain_status(domain)
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
def clear_domain_cache():
|
| 207 |
+
"""Clear the domain legitimacy cache."""
|
| 208 |
+
get_checker().clear_cache()
|