ExistedYear commited on
Commit
77c5607
·
1 Parent(s): f642077

scamshield slim - code only, weights via model repo

Browse files
.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 &amp; 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 &amp; 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…&#10;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> &amp; <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 &amp; drop here</p>
388
+ <p style="margin-top:.28rem;font-size:.74rem;color:var(--text3)">
389
+ WAV &middot; MP3 &middot; OGG &middot; FLAC &middot; M4A &mdash; 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()