Scam-Detector / ml_utils.py
Sanjam19's picture
Update ml_utils.py
8d89619 verified
Raw
History Blame Contribute Delete
31.5 kB
# ml_utils.py, adding ensemble for URL scam detection
import re
import pickle
import logging
import numpy as np
from typing import Dict, List, Tuple
from urllib.parse import urlparse
from pathlib import Path
import pandas as pd
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.linear_model import LogisticRegression
from sklearn.ensemble import RandomForestClassifier
from sklearn.pipeline import Pipeline
from sklearn.model_selection import cross_val_score
from xgboost import XGBClassifier
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# ── URL feature engineering ───────────────────────────────────────────────────
def extract_url_features(url: str) -> Dict:
"""Extract structured numerical features from a URL string."""
try:
raw = url
if not url.startswith(('http://', 'https://')):
url = 'http://' + url
p = urlparse(url)
domain = p.netloc.lower()
path = p.path.lower()
full = url.lower()
SECURITY_KW = ['login', 'verify', 'update', 'confirm', 'secure',
'account', 'kyc', 'banking', 'signin', 'validation']
BRAND_KW = ['hdfc', 'sbi', 'icici', 'paytm', 'amazon', 'google',
'microsoft', 'paypal', 'netflix', 'airtel', 'jio',
'phonepe', 'razorpay', 'flipkart', 'swiggy', 'zomato',
'axis', 'kotak', 'uidai', 'npci', 'irctc']
SUSP_TLDS = ['.tk', '.ml', '.ga', '.cf', '.gq', '.xyz', '.top',
'.pw', '.click', '.site', '.co', '.in']
FREE_TLDS = ['.tk', '.ml', '.ga', '.cf', '.gq']
return {
'length': int(len(raw)),
'dot_count': int(raw.count('.')),
'hyphen_count': int(raw.count('-')),
'slash_count': int(raw.count('/')),
'digit_count': int(sum(c.isdigit() for c in domain)),
'is_https': int(url.startswith('https://')),
'is_ip': int(bool(re.search(r'\d+\.\d+\.\d+\.\d+', domain))),
'subdomain_depth': int(max(len(domain.split('.')) - 2, 0)),
'has_security_kw': int(any(w in full for w in SECURITY_KW)),
'has_brand_kw': int(any(w in full for w in BRAND_KW)),
'suspicious_tld': int(any(domain.endswith(t) for t in SUSP_TLDS)),
'has_free_tld': int(any(domain.endswith(t) for t in FREE_TLDS)),
'path_length': int(len(p.path)),
'has_numbers_in_domain': int(bool(re.search(r'\d', domain.split('.')[0]))),
'hyphen_in_domain': int('-' in domain),
'multi_hyphens': int(domain.count('-') >= 2),
'at_sign': int('@' in url),
'double_slash_redirect': int('//' in p.path),
'query_length': int(len(p.query)),
'brand_plus_hyphen': int(any(w in domain and '-' in domain for w in BRAND_KW)),
'security_kw_in_path': int(any(w in path for w in SECURITY_KW)),
}
except Exception:
return {k: 0 for k in [
'length', 'dot_count', 'hyphen_count', 'slash_count', 'digit_count',
'is_https', 'is_ip', 'subdomain_depth', 'has_security_kw', 'has_brand_kw',
'suspicious_tld', 'has_free_tld', 'path_length', 'has_numbers_in_domain',
'hyphen_in_domain', 'multi_hyphens', 'at_sign', 'double_slash_redirect',
'query_length', 'brand_plus_hyphen', 'security_kw_in_path',
]}
class ScamDetectionService:
def __init__(self):
logger.info("Loading models...")
text_path = Path("spam_model.pkl")
url_path = Path("url_ensemble.pkl")
if text_path.exists():
with open(text_path, 'rb') as f:
self.text_model = pickle.load(f)
logger.info("Text model loaded.")
else:
logger.info("Training text model...")
self.train_text_model()
if url_path.exists():
with open(url_path, 'rb') as f:
self.url_ensemble = pickle.load(f)
logger.info("URL ensemble loaded.")
else:
logger.info("Training URL ensemble...")
self.train_url_ensemble()
self.suspicious_shorteners = [
'bit.ly', 'tinyurl.com', 'short.link', 'tiny.cc', 'ow.ly',
'goo.gl', 't.co', 'rb.gy', 'is.gd', 'v.gd', 'cutt.ly'
]
# (regex, risk_score, short_label, detailed_explanation)
self.social_engineering_patterns: List[Tuple[str, float, str, str]] = [
# OTP / credential harvesting
(r'\bshare.{0,20}otp\b', 0.90, "OTP request",
"Asks you to share an OTP -- no legitimate org ever does this."),
(r'\bconfirm.{0,20}(otp|pin|password)\b', 0.85, "Credential request",
"Requests you confirm an OTP, PIN, or password -- classic phishing."),
(r'\b(last\s*4|last\s*four).{0,20}(digit|card)\b', 0.85, "Card digit request",
"Asks for last 4 digits of your card -- used to build to full card theft."),
(r'\b(card\s*number|cvv|expiry)\b', 0.85, "Card detail request",
"Requests card number, CVV, or expiry -- your bank will never ask over SMS."),
(r'\botp\b', 0.45, "OTP mention",
"Mentions OTP -- context suggests a transaction or verification prompt."),
# Bank / govt impersonation
(r'\b(bank|rbi|sbi|hdfc|icici|axis|kotak|pnb|boi).{0,30}(suspend|block|close|deactivat|restrict)\b',
0.88, "Bank account threat",
"Claims your bank account is being suspended -- banks use official mail, not SMS links."),
(r'\b(fraud\s*prevention|fraud\s*team|fraud\s*department|fraud\s*monitoring)\b',
0.75, "Fraud team impersonation",
"Impersonates a bank fraud team to create panic and urgency."),
(r'\b(kyc|know\s*your\s*customer).{0,20}(update|expire|pending|complet|verif)\b',
0.88, "KYC scam",
"KYC update requests via SMS are almost always fraudulent."),
(r'\baadhaar.{0,30}(link|update|verify|expire|deactivat|biometric)\b',
0.85, "Aadhaar scam",
"UIDAI does not send Aadhaar deactivation or verification requests via SMS."),
(r'\b(pan\s*card|pan).{0,30}(flag|block|verify|link|update)\b',
0.82, "PAN card scam",
"PAN verification is done only through the income tax portal -- not SMS links."),
(r'\b(rbi|reserve\s*bank).{0,30}(notice|compliance|regulat|review|audit)\b',
0.85, "RBI impersonation",
"The RBI does not contact individuals directly via SMS for compliance or audits."),
(r'\b(income\s*tax|it\s*department|tds).{0,30}(refund|notice|verif|confirm|scrutin)\b',
0.85, "Tax dept scam",
"Income tax refunds and notices come through e-Filing portal, not SMS."),
(r'\b(gst|epfo|uan|pf\s*deposit).{0,30}(verif|update|link|suspend|block)\b',
0.82, "Govt portal impersonation",
"Legitimate EPFO/GST communications don't ask for verification via SMS links."),
# Telecom
(r'\b(sim|mobile).{0,30}(deactivat|block|suspend|port).{0,20}(kyc|verif|update)\b',
0.85, "SIM KYC scam",
"TRAI and telecom operators don't deactivate SIMs via SMS verification links."),
(r'\b(airtel|jio|vi|vodafone|bsnl).{0,30}(block|suspend|deactivat|kyc|port)\b',
0.82, "Telecom impersonation",
"Telecom providers handle SIM issues at stores or official apps -- not SMS links."),
# Digital arrest / legal threat
(r'\b(cbi|cybercrime|enforcement\s*directorate|ed|police|court).{0,40}(case|notice|filed|investigation|arrest|prosecution)\b',
0.90, "Law enforcement impersonation",
"CBI/ED/Police do not initiate legal proceedings via SMS. This is a 'digital arrest' scam."),
(r'\b(section\s*420|fema|pmla|ipc|money\s*laundering).{0,40}(invest|notice|case|compli)\b',
0.88, "Legal threat scam",
"Citing specific legal sections over SMS to create fear is a known fraud tactic."),
(r'\b(legal\s*notice|warrant|fir)\b',
0.80, "Legal threat",
"Legitimate legal notices arrive through official postal or court channels, not SMS."),
# Account suspension / urgency
(r'\b(account|service|upi|wallet).{0,20}(suspend|block|terminat|deactivat|restrict)\b',
0.75, "Account suspension threat",
"Suspension threats via SMS are pressure tactics to make you act without thinking."),
(r'\b(immediate|immediately|urgent|urgently).{0,30}(action|call|contact|verify|confirm)\b',
0.70, "Urgency pressure",
"Manufactured urgency is the #1 social engineering tactic -- bypasses rational thinking."),
(r'\bwithin\s*(1|2|24|30|48|72)\s*hours?\b',
0.65, "Time pressure",
"Artificial deadlines pressure you into acting before you can verify."),
# Fake helpline
(r'\b(call|contact).{0,20}(helpline|support|team|officer|number).{0,30}\d{8,12}\b',
0.75, "Fake helpline",
"Scammers publish fake helpline numbers -- always call from the official website."),
# Prize / lottery
(r'\b(won|win|winner|winning).{0,30}(prize|lottery|lucky|reward|cash|gift|iphone|samsung)\b',
0.90, "Lottery/prize scam",
"You cannot win a lottery you didn't enter. Designed to get your personal details."),
(r'\bcongratulations.{0,50}(won|selected|chosen|winner|shortlist)\b',
0.90, "Prize scam",
"Unsolicited congratulations messages are almost universally fraudulent."),
(r'\bclaim.{0,20}(prize|reward|cash|gift|money|amount)\b',
0.85, "Claim prize prompt",
"Asking you to 'claim' a prize you weren't expecting is a classic advance fee setup."),
# Job fraud
(r'\b(work\s*from\s*home|earn.{0,10}per\s*day|daily\s*earning|part\s*time\s*job).{0,40}(register|fee|pay|deposit)\b',
0.85, "Job fee scam",
"Legitimate jobs don't ask you to pay a registration fee upfront."),
(r'\b(shortlisted|selected).{0,30}(job|position|role|data\s*entry).{0,30}(register|fee|limited)\b',
0.85, "Fake job shortlisting",
"Unsolicited job shortlisting with urgency or a fee is a recruitment scam."),
# Investment fraud
(r'\b(invest.{0,20}(return|profit|earning)).{0,30}(guaranteed|assured|fixed)\b',
0.88, "Investment scam",
"Guaranteed returns don't exist -- hallmark of financial fraud."),
(r'\bdouble.{0,15}(money|investment|amount|profit)\b',
0.90, "Investment doubling scam",
"No legitimate scheme doubles your money. This is a Ponzi/pyramid scam pattern."),
(r'\b(crypto|trading|forex).{0,30}(group|signal|profit|return|earn).{0,30}(percent|%|lakh|crore)\b',
0.88, "Crypto trading scam",
"Fake trading groups with guaranteed returns -- the 'pig butchering' scam pattern."),
# Phishing links
(r'\bclick.{0,20}(link|here|below).{0,20}(verify|confirm|update|claim|secure)\b',
0.80, "Phishing link",
"Being directed to click a link to verify or claim something is a phishing setup."),
(r'\b(update|confirm).{0,20}(personal|bank|card|account)\s*(detail|info|data)\b',
0.85, "Data harvesting",
"Requests to 'update' personal or financial details via a link are data theft attempts."),
# Customs/Courier fraud
(r'\b(customs|clearance).{0,30}(charge|fee|pay|pending|release)\b',
0.85, "Customs fee scam",
"Customs clearance fees via SMS are fake -- official notices come through couriers."),
(r'\b(parcel|package|shipment|courier).{0,30}(stuck|hold|pending|failed).{0,30}(pay|fee|charge|verify)\b',
0.80, "Courier fraud",
"Delivery failure messages asking you to pay or verify details are typically fraudulent."),
# Tech support
(r'\b(microsoft|apple|google).{0,30}(security|malicious|virus|malware|traffic).{0,30}(install|call|contact)\b',
0.88, "Tech support scam",
"Microsoft/Apple/Google don't contact you about malware via SMS."),
]
logger.info("All models ready.")
# ── Text model training ───────────────────────────────────────────────────
def train_text_model(self):
texts, labels = [], []
try:
sms_df = pd.read_csv("spam.csv", encoding='latin-1')[['v1', 'v2']]
sms_df.columns = ['label_raw', 'text']
sms_df['label'] = sms_df['label_raw'].map({'ham': 0, 'spam': 1})
texts += list(sms_df['text'])
labels += list(sms_df['label'])
logger.info(f"Loaded {len(sms_df)} SMS spam samples.")
except FileNotFoundError:
logger.warning("spam.csv not found.")
try:
new_df = pd.read_csv("scam_messages_complete_500.csv", encoding='latin-1')
new_df.columns = [c.lower().strip() for c in new_df.columns]
label_map = {
'SCAM': 1,
'LOOKS_GOOD_BUT_SUSPICIOUS': 1,
'SUSPICIOUS': 1,
'FISHY_BUT_LEGITIMATE': 0,
'LEGITIMATE': 0,
}
new_df['label_int'] = new_df['label'].map(label_map)
new_df = new_df.dropna(subset=['label_int'])
texts += list(new_df['message_text']) * 5
labels += list(new_df['label_int'].astype(int)) * 5
logger.info(f"Loaded {len(new_df)} India-specific scam samples (5x upweighted).")
except FileNotFoundError:
logger.warning("scam_messages_complete_500.csv not found.")
if not texts:
raise RuntimeError("No training data found.")
self.text_model = Pipeline([
('tfidf', TfidfVectorizer(
max_features=5000,
ngram_range=(1, 2),
stop_words='english',
min_df=1,
sublinear_tf=True,
)),
('clf', LogisticRegression(
max_iter=1000,
C=1.0,
class_weight='balanced',
))
])
logger.info(f"Training text model on {len(texts)} samples...")
self.text_model.fit(texts, labels)
with open("spam_model.pkl", 'wb') as f:
pickle.dump(self.text_model, f)
logger.info("Text model saved -> spam_model.pkl")
# ── URL ensemble training ─────────────────────────────────────────────────
def train_url_ensemble(self):
"""
Three-model soft-voting ensemble on 250-row labeled URL dataset.
Model A: char n-gram TF-IDF on URL string -> Logistic Regression
Learns character-level patterns (e.g. 'hdfc-', '-kyc', '.xyz').
Model B: 21 engineered numerical features -> Random Forest
Learns structural signals: hyphen count, TLD type, subdomain depth.
Model C: word TF-IDF on (url + red_flags + domain_pattern) -> XGBoost
Learns combined text+signal keyword interactions.
Final score: average of three probability vectors.
Override: if any single model has >= 0.85 confidence on MALICIOUS, use it.
"""
try:
df = pd.read_csv("scam_urls_training_250.csv", encoding='latin-1')
except FileNotFoundError:
logger.warning("scam_urls_training_250.csv not found. URL ensemble disabled.")
self.url_ensemble = None
return
label_map = {'LEGITIMATE': 0, 'SUSPICIOUS': 1, 'MALICIOUS': 2}
df['label_int'] = df['label'].map(label_map)
df = df.dropna(subset=['label_int'])
df['label_int'] = df['label_int'].astype(int)
urls = df['url'].tolist()
labels = df['label_int'].tolist()
# Model A: char n-gram TF-IDF + LR
model_a = Pipeline([
('tfidf', TfidfVectorizer(
analyzer='char_wb',
ngram_range=(3, 5),
max_features=3000,
sublinear_tf=True,
)),
('clf', LogisticRegression(
max_iter=1000,
C=1.0,
class_weight='balanced',
))
])
# Model B: engineered features + RF
feat_matrix = np.array([
list(extract_url_features(u).values()) for u in urls
], dtype=float)
model_b = RandomForestClassifier(
n_estimators=200,
max_depth=8,
class_weight='balanced',
random_state=42,
)
# Model C: combined text + XGBoost
combined_text = [
f"{row['url']} {row.get('red_flags', '')} {row.get('domain_pattern', '')}"
for _, row in df.iterrows()
]
tfidf_c = TfidfVectorizer(
analyzer='word',
ngram_range=(1, 2),
max_features=2000,
sublinear_tf=True,
)
feat_c = tfidf_c.fit_transform(combined_text)
model_c = XGBClassifier(
n_estimators=150,
max_depth=4,
learning_rate=0.1,
eval_metric='mlogloss',
random_state=42,
verbosity=0,
)
logger.info("Training Model A (char TF-IDF + LR)...")
model_a.fit(urls, labels)
cv_a = cross_val_score(model_a, urls, labels, cv=3, scoring='balanced_accuracy').mean()
logger.info(f" Model A 3-fold balanced accuracy: {cv_a:.3f}")
logger.info("Training Model B (engineered features + RF)...")
model_b.fit(feat_matrix, labels)
cv_b = cross_val_score(model_b, feat_matrix, labels, cv=3, scoring='balanced_accuracy').mean()
logger.info(f" Model B 3-fold balanced accuracy: {cv_b:.3f}")
logger.info("Training Model C (combined text + XGBoost)...")
model_c.fit(feat_c, labels)
cv_c = cross_val_score(model_c, feat_c, labels, cv=3, scoring='balanced_accuracy').mean()
logger.info(f" Model C 3-fold balanced accuracy: {cv_c:.3f}")
self.url_ensemble = {
'model_a': model_a,
'model_b': model_b,
'model_b_feat_names': list(extract_url_features(urls[0]).keys()),
'model_c_tfidf': tfidf_c,
'model_c': model_c,
'label_map_inv': {0: 'LEGITIMATE', 1: 'SUSPICIOUS', 2: 'MALICIOUS'},
'cv_scores': {'model_a': cv_a, 'model_b': cv_b, 'model_c': cv_c},
}
with open("url_ensemble.pkl", 'wb') as f:
pickle.dump(self.url_ensemble, f)
logger.info(
f"URL ensemble saved -> url_ensemble.pkl "
f"(A={cv_a:.3f}, B={cv_b:.3f}, C={cv_c:.3f})"
)
# ── Helpers ───────────────────────────────────────────────────────────────
def _check_social_engineering(self, text: str) -> Tuple[float, List[str], List[str]]:
text_lower = text.lower()
short_reasons, detailed_reasons = [], []
max_score = 0.0
for pattern, score, short, detail in self.social_engineering_patterns:
if re.search(pattern, text_lower):
if short not in short_reasons:
short_reasons.append(short)
detailed_reasons.append(detail)
max_score = max(max_score, score)
return max_score, short_reasons, detailed_reasons
def detect_language(self, text: str) -> str:
if re.search(r'[\u0900-\u097F]', text):
return 'hi'
elif re.search(r'[\u0980-\u09FF]', text):
return 'or'
return 'en'
def _ensemble_url_predict(self, url: str, red_flags_hint: str = "") -> Tuple[float, float, float]:
"""Returns (p_legitimate, p_suspicious, p_malicious). Falls back to safe if ensemble missing."""
if not self.url_ensemble:
return 1.0, 0.0, 0.0
e = self.url_ensemble
combined = f"{url} {red_flags_hint}"
pa = e['model_a'].predict_proba([url])[0]
feats = np.array([list(extract_url_features(url).values())], dtype=float)
pb = e['model_b'].predict_proba(feats)[0]
feat_c = e['model_c_tfidf'].transform([combined])
pc = e['model_c'].predict_proba(feat_c)[0]
avg = (pa + pb + pc) / 3.0
# Single-model override if very confident on MALICIOUS
for probs in [pa, pb, pc]:
if probs[2] >= 0.85:
avg = probs
break
return float(avg[0]), float(avg[1]), float(avg[2])
# ── Text analysis ─────────────────────────────────────────────────────────
def analyze_text_scam(self, text: str, language: str = None) -> Dict:
if not text or not text.strip():
return {
"risk_level": "Safe", "confidence": 0.0,
"reasoning": "Empty text.", "user_message": "Nothing to analyze.",
"detected_language": "unknown",
}
detected_language = language or self.detect_language(text)
try:
proba = self.text_model.predict_proba([text])[0]
spam_prob = proba[1]
se_score, se_short, se_detailed = self._check_social_engineering(text)
effective = max(spam_prob, se_score)
if effective >= 0.55:
risk_level, confidence = "Scam", effective
elif effective >= 0.35:
risk_level, confidence = "Suspicious", effective
else:
risk_level, confidence = "Safe", 1 - effective
reasoning = f"Spam probability: {round(spam_prob * 100, 1)}%"
if se_short:
reasoning += f" | Flags: {', '.join(se_short[:3])}"
if risk_level == "Scam":
if se_detailed:
extra = f" Also flagged: {', '.join(se_short[1:3])}." if len(se_short) > 1 else ""
user_message = f"{se_detailed[0]}{extra} Do not share any details or click any links."
else:
user_message = f"Model confidence {round(spam_prob*100)}%. Multiple scam signals detected. Do not share details or click links."
elif risk_level == "Suspicious":
if se_detailed:
user_message = f"Flagged for: {', '.join(se_short[:3])}. {se_detailed[0]} Verify independently before responding."
else:
user_message = f"Some characteristics match spam patterns (score: {round(spam_prob*100)}%). Worth a second look."
else:
user_message = "No scam signals detected. Still be cautious -- if something feels off, verify through official channels."
return {
"risk_level": risk_level,
"confidence": round(confidence, 4),
"reasoning": reasoning,
"user_message": user_message,
"detected_language": detected_language,
}
except Exception as e:
logger.error(f"Text classification failed: {e}")
return {
"risk_level": "Suspicious", "confidence": 0.5,
"reasoning": f"Model error: {e}",
"user_message": "Could not analyze this message. Treat with caution.",
"detected_language": detected_language,
}
# ── URL analysis (ensemble + rules blended) ───────────────────────────────
def analyze_url_scam(self, url: str, context: str = "") -> Dict:
if not url:
return {
"risk_level": "Safe", "confidence": 0.0,
"reasoning": "No URL provided.", "user_message": "Nothing to analyze.",
"domain": "", "url_status": "invalid",
}
try:
if not url.startswith(('http://', 'https://')):
url = 'http://' + url
domain = urlparse(url).netloc.lower()
# Rule layer
rule_risk = 0.0
rule_flags = []
if url.startswith('http://') and not url.startswith('https://'):
rule_risk += 0.30
rule_flags.append(("HTTP not HTTPS",
"Connection is unencrypted. Any data you enter can be intercepted."))
scam_domain_patterns = [
"faceb00k", "paypa1", "amaz0n", "micros0ft", "g00gle",
"appleid", "login-secure", "claim-your", "verify-account",
"lottery", "techsupport", "quickloan", "account-update",
"hdfc-", "sbi-", "icici-", "paytm-", "netflix-payment",
"bluedart-track", "india-post",
]
matched = [p for p in scam_domain_patterns if p in domain]
if matched:
rule_risk += 0.85
rule_flags.append(("Brand spoofing",
f"Domain impersonates a trusted brand ({matched[0]}). Use the official domain."))
shortener = next((s for s in self.suspicious_shorteners if s in domain), None)
if shortener:
rule_risk += 0.55
rule_flags.append(("URL shortener",
f"Uses {shortener} -- hides the real destination."))
if re.search(r'\d+\.\d+\.\d+\.\d+', domain):
rule_risk += 0.90
rule_flags.append(("Raw IP address",
"Legitimate services never use raw IP addresses."))
suspicious_tlds = ['.tk', '.ml', '.ga', '.cf', '.gq', '.xyz',
'.top', '.pw', '.click', '.info', '.site']
matched_tld = next((t for t in suspicious_tlds if domain.endswith(t)), None)
if matched_tld:
rule_risk += 0.65
rule_flags.append(("Suspicious TLD",
f"'{matched_tld}' is commonly used in phishing campaigns."))
if len(url) > 100:
rule_risk += 0.25
rule_flags.append(("Abnormally long URL",
"Very long URLs with many parameters are a common obfuscation tactic."))
security_words = ['login', 'verify', 'update', 'confirm',
'secure', 'account', 'signin', 'banking']
matched_kw = [w for w in security_words if w in url.lower()]
if matched_kw:
rule_risk += 0.35
rule_flags.append(("Security keywords in URL",
f"Contains '{matched_kw[0]}' -- phishing pages use these to appear legitimate."))
# Ensemble prediction
p_legit, p_susp, p_mal = self._ensemble_url_predict(url)
ensemble_risk = p_mal * 1.0 + p_susp * 0.5
# 50/50 blend
final_score = min(0.50 * min(rule_risk, 1.0) + 0.50 * ensemble_risk, 1.0)
# Context boost from message analysis
context_result = None
if context:
context_result = self.analyze_text_scam(context)
if context_result['risk_level'] == 'Scam':
final_score = min(final_score + 0.15, 1.0)
elif context_result['risk_level'] == 'Suspicious':
final_score = min(final_score + 0.07, 1.0)
if final_score >= 0.55:
risk_level = "Scam"
elif final_score >= 0.25:
risk_level = "Suspicious"
else:
risk_level = "Safe"
flag_labels = [f[0] for f in rule_flags]
reasoning = "; ".join(flag_labels) if flag_labels else "No rule flags"
reasoning += (f" | Ensemble: {round(p_mal*100)}% malicious, "
f"{round(p_susp*100)}% suspicious")
if context_result:
reasoning += f" | Message context: {context_result['risk_level']}"
if risk_level == "Scam":
primary = rule_flags[0][1] if rule_flags else "High-risk URL detected by ensemble classifier."
extras = [f[0] for f in rule_flags[1:3]]
extra_s = f" Also: {', '.join(extras)}." if extras else ""
user_message = f"{primary}{extra_s} Do not open this link."
elif risk_level == "Suspicious":
primary = rule_flags[0][1] if rule_flags else "URL has unusual structural characteristics."
user_message = f"{primary} Verify this is from an official source before clicking."
else:
user_message = "No major red flags detected. Still, only click links from sources you initiated contact with."
return {
"risk_level": risk_level,
"confidence": round(final_score, 4),
"reasoning": reasoning,
"user_message": user_message,
"domain": domain,
"url_status": "analyzed",
"ensemble_scores": {
"p_legitimate": round(p_legit, 3),
"p_suspicious": round(p_susp, 3),
"p_malicious": round(p_mal, 3),
},
}
except Exception as e:
logger.error(f"URL analysis error: {e}")
return {
"risk_level": "Suspicious", "confidence": 0.5,
"reasoning": f"URL analysis error: {e}",
"user_message": "Could not fully analyze this URL. Treat with caution.",
"domain": "unknown", "url_status": "error",
}
def generate_user_response(self, risk_level: str) -> str:
responses = {
"Safe": "This message appears safe.",
"Suspicious": "Be cautious -- this message has suspicious elements.",
"Scam": "WARNING: This appears to be a scam! Do not click links or share personal info.",
}
return responses.get(risk_level, "Unable to analyze.")