ritesh19180 commited on
Commit
82cae4f
·
verified ·
1 Parent(s): ca9652c

Upload folder using huggingface_hub

Browse files
.gitignore CHANGED
@@ -4,7 +4,10 @@ __pycache__/
4
  *$py.class
5
  venv/
6
  env/
7
- .env
 
 
 
8
 
9
  # Models (Keep them if they are small, but here they are large.
10
  # We should probably only keep the ones we actually use if possible,
 
4
  *$py.class
5
  venv/
6
  env/
7
+ # Ignore all env files anywhere in the repo
8
+ **/.env
9
+ **/.env.*
10
+
11
 
12
  # Models (Keep them if they are small, but here they are large.
13
  # We should probably only keep the ones we actually use if possible,
Frontend/package.json CHANGED
@@ -6,7 +6,7 @@
6
  "scripts": {
7
  "dev": "vite",
8
  "build": "vite build",
9
- "lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0",
10
  "check:ai-secrets": "node ../scripts/check-frontend-secrets.mjs",
11
  "preview": "vite preview",
12
  "prepare": "husky install",
 
6
  "scripts": {
7
  "dev": "vite",
8
  "build": "vite build",
9
+ "lint": "eslint . --report-unused-disable-directives --max-warnings 0",
10
  "check:ai-secrets": "node ../scripts/check-frontend-secrets.mjs",
11
  "preview": "vite preview",
12
  "prepare": "husky install",
Frontend/src/admin/pages/AdminDashboard.jsx CHANGED
@@ -115,7 +115,6 @@ const AdminDashboard = () => {
115
  const handleWsMessage = useTicketStore((s) => s.handleWsMessage);
116
  const setWsConnected = useTicketStore((s) => s.setWsConnected);
117
  const upsertTicket = useTicketStore((s) => s.upsertTicket);
118
- const removeTicket = useTicketStore((s) => s.removeTicket);
119
 
120
  // Sync WebSocket connection status to store
121
  React.useEffect(() => {
 
115
  const handleWsMessage = useTicketStore((s) => s.handleWsMessage);
116
  const setWsConnected = useTicketStore((s) => s.setWsConnected);
117
  const upsertTicket = useTicketStore((s) => s.upsertTicket);
 
118
 
119
  // Sync WebSocket connection status to store
120
  React.useEffect(() => {
Frontend/src/admin/pages/AdminTicketDetail.jsx CHANGED
@@ -4,7 +4,7 @@ import {
4
  CheckCircle2, Clock, AlertCircle, User,
5
  Activity, ShieldCheck, Briefcase, Globe, BarChart3,
6
  ImageIcon, CornerUpLeft, CheckSquare, XCircle,
7
- Cpu, Eye, MessageSquare, MoveRight, Loader2, Star, Eraser
8
  } from 'lucide-react';
9
  import { supabase } from "../../lib/supabaseClient";
10
  import useAuthStore from "../../store/authStore";
@@ -307,6 +307,67 @@ const AdminTicketDetail = () => {
307
  </div>
308
  </div>
309
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
310
  <div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
311
  {/* Primary Column */}
312
  <div className="lg:col-span-8 space-y-8">
 
4
  CheckCircle2, Clock, AlertCircle, User,
5
  Activity, ShieldCheck, Briefcase, Globe, BarChart3,
6
  ImageIcon, CornerUpLeft, CheckSquare, XCircle,
7
+ Cpu, Eye, MessageSquare, MoveRight, Loader2, Star, Eraser, ShieldAlert
8
  } from 'lucide-react';
9
  import { supabase } from "../../lib/supabaseClient";
10
  import useAuthStore from "../../store/authStore";
 
307
  </div>
308
  </div>
309
 
310
+ {ticket.metadata?.spam_analysis?.is_spam && (
311
+ <div style={{
312
+ background: ticket.metadata.spam_analysis.risk_level === 'high' ? '#fef2f2' : '#fffbeb',
313
+ border: `1.5px solid ${ticket.metadata.spam_analysis.risk_level === 'high' ? '#fca5a5' : '#fde047'}`,
314
+ borderRadius: '16px',
315
+ padding: '16px 24px',
316
+ color: ticket.metadata.spam_analysis.risk_level === 'high' ? '#991b1b' : '#92400e',
317
+ display: 'flex',
318
+ alignItems: 'flex-start',
319
+ gap: '16px',
320
+ boxShadow: '0 4px 12px rgba(0,0,0,0.02)'
321
+ }}>
322
+ <ShieldAlert size={24} style={{ flexShrink: 0, marginTop: '2px', color: ticket.metadata.spam_analysis.risk_level === 'high' ? '#dc2626' : '#d97706' }} />
323
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
324
+ <h4 style={{ margin: 0, fontWeight: 800, fontSize: '13px', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
325
+ SECURITY PROTOCOL ALERT // POTENTIAL {ticket.metadata.spam_analysis.risk_level.toUpperCase()} RISK SPAM/PHISHING
326
+ </h4>
327
+ <p style={{ margin: 0, fontSize: '12px', fontWeight: 600, lineHeight: 1.5 }}>
328
+ This incident contains potential phishing patterns or malicious URLs. To protect system infrastructure, support agents must not click or copy any untrusted hyperlinks below.
329
+ </p>
330
+ <div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', marginTop: '8px' }}>
331
+ {ticket.metadata.spam_analysis.reasons.map((reason, i) => (
332
+ <span key={i} style={{
333
+ fontSize: '9px',
334
+ fontWeight: 700,
335
+ textTransform: 'uppercase',
336
+ background: ticket.metadata.spam_analysis.risk_level === 'high' ? '#fee2e2' : '#fef3c7',
337
+ color: ticket.metadata.spam_analysis.risk_level === 'high' ? '#991b1b' : '#92400e',
338
+ padding: '2px 8px',
339
+ borderRadius: '100px',
340
+ border: `1px solid ${ticket.metadata.spam_analysis.risk_level === 'high' ? '#fca5a5' : '#fde047'}`
341
+ }}>
342
+ {reason}
343
+ </span>
344
+ ))}
345
+ </div>
346
+ {ticket.metadata.spam_analysis.suspicious_urls?.length > 0 && (
347
+ <div style={{ marginTop: '12px', display: 'flex', flexDirection: 'column', gap: '6px' }}>
348
+ <span style={{ fontSize: '10px', fontWeight: 800, textTransform: 'uppercase', color: ticket.metadata.spam_analysis.risk_level === 'high' ? '#991b1b' : '#92400e' }}>Suspicious links locked:</span>
349
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
350
+ {ticket.metadata.spam_analysis.suspicious_urls.map((url, idx) => (
351
+ <code key={idx} style={{
352
+ fontSize: '11px',
353
+ fontFamily: 'monospace',
354
+ padding: '4px 8px',
355
+ background: ticket.metadata.spam_analysis.risk_level === 'high' ? '#fee2e240' : '#fef3c740',
356
+ color: '#dc2626',
357
+ borderRadius: '6px',
358
+ border: '1.5px dashed #fca5a5',
359
+ wordBreak: 'break-all'
360
+ }}>
361
+ [LOCKED] {url}
362
+ </code>
363
+ ))}
364
+ </div>
365
+ </div>
366
+ )}
367
+ </div>
368
+ </div>
369
+ )}
370
+
371
  <div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
372
  {/* Primary Column */}
373
  <div className="lg:col-span-8 space-y-8">
Frontend/src/admin/pages/AdminTickets.jsx CHANGED
@@ -168,7 +168,7 @@ const AdminTickets = () => {
168
 
169
  const categories = ['All', 'Network', 'Hardware', 'Software', 'Access', 'Account'];
170
  const priorities = ['All', 'Low', 'Medium', 'High'];
171
- const statuses = ['All', 'Open', 'In Progress', 'Resolved', 'Closed'];
172
  const teams = ['All', 'Software Team', 'Hardware Support', 'Network Ops', 'Security Unit', 'General Support'];
173
 
174
  const filteredTickets = useMemo(() => {
@@ -333,9 +333,21 @@ const AdminTickets = () => {
333
  {/* Subject */}
334
  <td className="px-6 py-6">
335
  <div className="flex flex-col">
336
- <span className="text-xs font-bold text-slate-700 truncate max-w-[200px]" title={ticket.summary || ticket.subject}>
337
- {ticket.summary || ticket.subject}
338
- </span>
 
 
 
 
 
 
 
 
 
 
 
 
339
  <span className="text-[10px] font-black text-slate-400 uppercase tracking-widest flex items-center gap-2">
340
  {ticket.category}
341
  <span className="text-[9px] font-medium text-slate-300">• {formatTimelineDate(ticket.created_at)}</span>
@@ -420,7 +432,13 @@ const AdminTickets = () => {
420
  {/* Status (Editable) */}
421
  <td className="px-6 py-6">
422
  <div className="flex items-center gap-2">
423
- <div className={`w-1.5 h-1.5 rounded-full ${ticket.status?.toLowerCase() === 'resolved' || ticket.status?.toLowerCase() === 'closed' ? 'bg-emerald-400' : 'bg-amber-500 animate-pulse'}`}></div>
 
 
 
 
 
 
424
  <Select
425
  value={String(ticket.status || 'open').toLowerCase()}
426
  onChange={(e) => handleUpdateTicket(ticket.id, { status: e.target.value })}
 
168
 
169
  const categories = ['All', 'Network', 'Hardware', 'Software', 'Access', 'Account'];
170
  const priorities = ['All', 'Low', 'Medium', 'High'];
171
+ const statuses = ['All', 'Open', 'In Progress', 'Resolved', 'Closed', 'Spam'];
172
  const teams = ['All', 'Software Team', 'Hardware Support', 'Network Ops', 'Security Unit', 'General Support'];
173
 
174
  const filteredTickets = useMemo(() => {
 
333
  {/* Subject */}
334
  <td className="px-6 py-6">
335
  <div className="flex flex-col">
336
+ <div className="flex items-center gap-2">
337
+ <span className="text-xs font-bold text-slate-700 truncate max-w-[200px]" title={ticket.summary || ticket.subject}>
338
+ {ticket.summary || ticket.subject}
339
+ </span>
340
+ {ticket.metadata?.spam_analysis?.is_spam && (
341
+ <span className={`px-2 py-0.5 rounded text-[8px] font-black uppercase tracking-wider flex items-center gap-1 ${
342
+ ticket.metadata.spam_analysis.risk_level === 'high'
343
+ ? 'bg-red-100 text-red-700 border border-red-200'
344
+ : 'bg-amber-100 text-amber-700 border border-amber-200'
345
+ }`}>
346
+ <ShieldAlert size={10} />
347
+ {ticket.metadata.spam_analysis.risk_level} Risk
348
+ </span>
349
+ )}
350
+ </div>
351
  <span className="text-[10px] font-black text-slate-400 uppercase tracking-widest flex items-center gap-2">
352
  {ticket.category}
353
  <span className="text-[9px] font-medium text-slate-300">• {formatTimelineDate(ticket.created_at)}</span>
 
432
  {/* Status (Editable) */}
433
  <td className="px-6 py-6">
434
  <div className="flex items-center gap-2">
435
+ <div className={`w-1.5 h-1.5 rounded-full ${
436
+ ticket.status?.toLowerCase() === 'resolved' || ticket.status?.toLowerCase() === 'closed'
437
+ ? 'bg-emerald-400'
438
+ : ticket.status?.toLowerCase() === 'spam'
439
+ ? 'bg-slate-400 border border-slate-500'
440
+ : 'bg-amber-500 animate-pulse'
441
+ }`}></div>
442
  <Select
443
  value={String(ticket.status || 'open').toLowerCase()}
444
  onChange={(e) => handleUpdateTicket(ticket.id, { status: e.target.value })}
Frontend/src/hooks/useWebSocket.js CHANGED
@@ -47,7 +47,6 @@ export default function useWebSocket(companyId) {
47
  const companyIdRef = useRef(companyId);
48
 
49
  // Keep a ref to latest companyId so the effect closure always has it
50
- companyIdRef.current = companyId;
51
 
52
  // ---- Cleanup helpers ---------------------------------------------------
53
 
@@ -89,7 +88,26 @@ export default function useWebSocket(companyId) {
89
  }, PING_INTERVAL_MS);
90
  }, []);
91
 
92
- // ---- WebSocket lifecycle -----------------------------------------------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93
 
94
  const connect = useCallback(() => {
95
  cleanup();
@@ -153,25 +171,10 @@ export default function useWebSocket(companyId) {
153
  socket.onerror = () => {
154
  // onclose fires immediately after onerror, so reconnect is handled there
155
  };
156
- }, [cleanup, clearTimers, startHeartbeat]);
157
 
158
- // ---- Reconnection with exponential backoff -----------------------------
159
-
160
- const scheduleReconnect = useCallback(() => {
161
- if (!mountedRef.current || !companyIdRef.current) return;
162
-
163
- const attempt = reconnectAttemptRef.current;
164
- const delay = Math.min(
165
- INITIAL_RECONNECT_DELAY_MS * Math.pow(2, attempt),
166
- MAX_RECONNECT_DELAY_MS
167
- );
168
- reconnectAttemptRef.current = attempt + 1;
169
-
170
- setConnectionError(`Reconnecting in ${Math.round(delay / 1000)}s...`);
171
-
172
- reconnectTimerRef.current = setTimeout(() => {
173
- if (mountedRef.current) connect();
174
- }, delay);
175
  }, [connect]);
176
 
177
  // ---- Send helper -------------------------------------------------------
 
47
  const companyIdRef = useRef(companyId);
48
 
49
  // Keep a ref to latest companyId so the effect closure always has it
 
50
 
51
  // ---- Cleanup helpers ---------------------------------------------------
52
 
 
88
  }, PING_INTERVAL_MS);
89
  }, []);
90
 
91
+ // ---- WebSocket lifecycle & Reconnection ---------------------------------
92
+
93
+ const connectRef = useRef(null);
94
+
95
+ const scheduleReconnect = useCallback(() => {
96
+ if (!mountedRef.current || !companyIdRef.current) return;
97
+
98
+ const attempt = reconnectAttemptRef.current;
99
+ const delay = Math.min(
100
+ INITIAL_RECONNECT_DELAY_MS * Math.pow(2, attempt),
101
+ MAX_RECONNECT_DELAY_MS
102
+ );
103
+ reconnectAttemptRef.current = attempt + 1;
104
+
105
+ setConnectionError(`Reconnecting in ${Math.round(delay / 1000)}s...`);
106
+
107
+ reconnectTimerRef.current = setTimeout(() => {
108
+ if (mountedRef.current && connectRef.current) connectRef.current();
109
+ }, delay);
110
+ }, []);
111
 
112
  const connect = useCallback(() => {
113
  cleanup();
 
171
  socket.onerror = () => {
172
  // onclose fires immediately after onerror, so reconnect is handled there
173
  };
174
+ }, [cleanup, clearTimers, startHeartbeat, scheduleReconnect]);
175
 
176
+ useEffect(() => {
177
+ connectRef.current = connect;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
178
  }, [connect]);
179
 
180
  // ---- Send helper -------------------------------------------------------
backend/main.py CHANGED
@@ -212,6 +212,26 @@ def calculate_sla_response_at(priority: str) -> datetime.datetime:
212
  return datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(hours=hours)
213
 
214
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
215
  # ---------------------------------------------------------------------------
216
  # Request / Response models
217
  # ---------------------------------------------------------------------------
 
212
  return datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(hours=hours)
213
 
214
 
215
+ def classify_sla_status(sla_breach_at: str | None) -> str:
216
+ """Return 'BREACHED', 'WARNING', or 'ACTIVE' based on the breach time."""
217
+ if not sla_breach_at:
218
+ return "ACTIVE"
219
+ try:
220
+ clean_val = str(sla_breach_at).replace("Z", "+00:00")
221
+ deadline = datetime.datetime.fromisoformat(clean_val)
222
+ if deadline.tzinfo is None:
223
+ deadline = deadline.replace(tzinfo=datetime.timezone.utc)
224
+ except Exception:
225
+ return "ACTIVE"
226
+
227
+ now = datetime.datetime.now(datetime.timezone.utc)
228
+ if deadline <= now:
229
+ return "BREACHED"
230
+ if deadline - now <= datetime.timedelta(hours=1):
231
+ return "WARNING"
232
+ return "ACTIVE"
233
+
234
+
235
  # ---------------------------------------------------------------------------
236
  # Request / Response models
237
  # ---------------------------------------------------------------------------
backend/services/gemini_service.py CHANGED
@@ -3,11 +3,19 @@ import base64
3
  import io
4
  import re
5
  import json
6
- from PIL import Image
7
- from google import genai
8
  from dotenv import load_dotenv
9
  from pathlib import Path
10
 
 
 
 
 
 
 
 
 
 
 
11
  # Load environment variables from backend/.env
12
  env_path = Path(__file__).parent.parent / '.env'
13
  load_dotenv(dotenv_path=env_path)
@@ -18,7 +26,7 @@ class GeminiService:
18
  self._initialized = False
19
  self.model_name = 'gemini-2.5-flash'
20
 
21
- if self.api_key:
22
  try:
23
  self.client = genai.Client(api_key=self.api_key)
24
  self._initialized = True
@@ -26,15 +34,18 @@ class GeminiService:
26
  except Exception as e:
27
  print(f"[GeminiService] Initialization Error: {e}")
28
  else:
29
- print("[GeminiService] WARNING: GEMINI_API_KEY not found in environment.")
 
 
 
30
 
31
  def analyze_image(self, image_base64: str, context_text: str = None) -> dict:
32
  """
33
  Perform OCR and image analysis using Gemini logic.
34
  """
35
- if not self._initialized:
36
  return {
37
- "image_description": "[Gemini API Key Missing] Could not analyze image.",
38
  "ocr_text": "",
39
  "detected_problem": ""
40
  }
 
3
  import io
4
  import re
5
  import json
 
 
6
  from dotenv import load_dotenv
7
  from pathlib import Path
8
 
9
+ try:
10
+ from PIL import Image
11
+ from google import genai
12
+ _HAS_GEMINI_DEPS = True
13
+ except ImportError:
14
+ Image = None
15
+ genai = None
16
+ _HAS_GEMINI_DEPS = False
17
+
18
+
19
  # Load environment variables from backend/.env
20
  env_path = Path(__file__).parent.parent / '.env'
21
  load_dotenv(dotenv_path=env_path)
 
26
  self._initialized = False
27
  self.model_name = 'gemini-2.5-flash'
28
 
29
+ if self.api_key and _HAS_GEMINI_DEPS:
30
  try:
31
  self.client = genai.Client(api_key=self.api_key)
32
  self._initialized = True
 
34
  except Exception as e:
35
  print(f"[GeminiService] Initialization Error: {e}")
36
  else:
37
+ if not _HAS_GEMINI_DEPS:
38
+ print("[GeminiService] WARNING: PIL or google-genai package is not installed. Gemini service is disabled.")
39
+ else:
40
+ print("[GeminiService] WARNING: GEMINI_API_KEY not found in environment.")
41
 
42
  def analyze_image(self, image_base64: str, context_text: str = None) -> dict:
43
  """
44
  Perform OCR and image analysis using Gemini logic.
45
  """
46
+ if not self._initialized or not _HAS_GEMINI_DEPS:
47
  return {
48
+ "image_description": "[Gemini Service Offline] Could not analyze image.",
49
  "ocr_text": "",
50
  "detected_problem": ""
51
  }
backend/services/slack_notifier.py CHANGED
@@ -175,4 +175,3 @@ def notify_sla_breach(ticket: dict[str, Any]) -> bool:
175
 
176
  return sent
177
 
178
- # PR diff placeholder
 
175
 
176
  return sent
177
 
 
backend/services/spam_detector_service.py ADDED
@@ -0,0 +1,126 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import re
2
+ import urllib.parse
3
+
4
+ # Whitelist of trusted domains
5
+ SAFE_DOMAINS = {
6
+ "google.com", "github.com", "microsoft.com", "apple.com",
7
+ "supabase.com", "helpdesk.ai", "gssoc.org.in", "vercel.app",
8
+ "wikipedia.org", "yahoo.com", "outlook.com", "gmail.com",
9
+ "facebook.com", "linkedin.com", "twitter.com", "x.com"
10
+ }
11
+
12
+ # Suspicious top-level domains (TLDs)
13
+ SUSPICIOUS_TLDS = {
14
+ ".xyz", ".ru", ".su", ".zip", ".mov", ".click", ".link",
15
+ ".top", ".info", ".cc", ".biz", ".icu", ".club", ".work", ".gq", ".cf", ".ml"
16
+ }
17
+
18
+ # Phishing indicators / social engineering keywords
19
+ PHISHING_KEYWORDS = [
20
+ "verify your account", "immediate action required", "confirm your password",
21
+ "security alert login", "suspend your account", "billing update required",
22
+ "bank login", "login attempt from", "unauthorized login", "security verification",
23
+ "urgent action", "confirm your identity", "reset your security", "compromised"
24
+ ]
25
+
26
+ # Spam indicators / marketing bot keywords
27
+ SPAM_KEYWORDS = [
28
+ "seo services", "crypto trading", "casino bonus", "lottery winner",
29
+ "cash prize", "viagra", "buy cheap", "guaranteed profit", "double your money",
30
+ "make money fast", "free investment", "gift card", "work from home", "bitcoin profit"
31
+ ]
32
+
33
+ # URL Extraction Regex
34
+ URL_REGEX = re.compile(r'https?://[^\s<>"]+|www\.[^\s<>"]+')
35
+
36
+ def is_domain_safe(domain: str) -> bool:
37
+ """Check if the domain or its parent domain is in the safe list."""
38
+ domain = domain.lower()
39
+ if domain in SAFE_DOMAINS:
40
+ return True
41
+
42
+ # Check subdomains (e.g. sub.google.com -> google.com)
43
+ for safe in SAFE_DOMAINS:
44
+ if domain.endswith("." + safe):
45
+ return True
46
+ return False
47
+
48
+ def analyze_spam_phishing(text: str, ocr_text: str = "") -> dict:
49
+ """
50
+ Analyze text and OCR text for spam and phishing elements.
51
+ Returns analysis results including spam flag, risk level, reasons, and URLs.
52
+ """
53
+ combined_text = f"{text}\n{ocr_text}"
54
+ lower_text = combined_text.lower()
55
+
56
+ reasons = []
57
+ detected_urls = []
58
+ suspicious_urls = []
59
+
60
+ # 1. URL Scanning
61
+ urls = URL_REGEX.findall(combined_text)
62
+ for url in urls:
63
+ # Strip trailing punctuation commonly matched by regex
64
+ clean_url = url.rstrip('.,;:-?!()[]{}')
65
+ if clean_url not in detected_urls:
66
+ detected_urls.append(clean_url)
67
+
68
+ # Parse host
69
+ try:
70
+ parsed = urllib.parse.urlparse(clean_url if "://" in clean_url else "http://" + clean_url)
71
+ host = parsed.netloc.split(':')[0] # strip port
72
+
73
+ # Check TLD
74
+ has_suspicious_tld = any(host.endswith(tld) for tld in SUSPICIOUS_TLDS)
75
+ if has_suspicious_tld:
76
+ suspicious_urls.append(clean_url)
77
+ reasons.append(f"Suspicious TLD in link: {clean_url}")
78
+ continue
79
+
80
+ # Check if domain is untrusted AND has suspicious path keywords
81
+ if not is_domain_safe(host):
82
+ path_lower = parsed.path.lower()
83
+ query_lower = parsed.query.lower()
84
+ suspicious_path_keywords = ["login", "signin", "verify", "secure", "update", "account", "billing"]
85
+
86
+ if any(kw in path_lower or kw in query_lower or kw in host for kw in suspicious_path_keywords):
87
+ suspicious_urls.append(clean_url)
88
+ reasons.append(f"Untrusted URL containing security/account keywords: {clean_url}")
89
+ except Exception:
90
+ pass
91
+
92
+ # 2. Phishing Keyword Scanning
93
+ matched_phishing = [kw for kw in PHISHING_KEYWORDS if kw in lower_text]
94
+ if matched_phishing:
95
+ reasons.append(f"Detected phishing keyword patterns: {', '.join(matched_phishing[:3])}")
96
+
97
+ # 3. Spam Keyword Scanning
98
+ matched_spam = [kw for kw in SPAM_KEYWORDS if kw in lower_text]
99
+ if matched_spam:
100
+ reasons.append(f"Detected spam/marketing keyword patterns: {', '.join(matched_spam[:3])}")
101
+
102
+ # 4. Multi-Link bot check
103
+ if len(detected_urls) >= 5:
104
+ reasons.append(f"High link density ({len(detected_urls)} URLs detected)")
105
+
106
+ # 5. Risk Assessment
107
+ risk_level = "none"
108
+ is_spam = False
109
+
110
+ if len(suspicious_urls) > 0 or len(matched_phishing) >= 2:
111
+ risk_level = "high"
112
+ is_spam = True
113
+ elif len(matched_phishing) == 1 or len(matched_spam) >= 2 or len(detected_urls) >= 3:
114
+ risk_level = "medium"
115
+ is_spam = True
116
+ elif len(matched_spam) == 1 or len(detected_urls) > 0:
117
+ risk_level = "low"
118
+ is_spam = True
119
+
120
+ return {
121
+ "is_spam": is_spam,
122
+ "risk_level": risk_level,
123
+ "reasons": reasons,
124
+ "detected_urls": detected_urls,
125
+ "suspicious_urls": suspicious_urls
126
+ }
backend/tests/test_spam_detector.py ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import unittest
2
+ from backend.services.spam_detector_service import analyze_spam_phishing, is_domain_safe
3
+
4
+ class TestSpamDetector(unittest.TestCase):
5
+ def test_benign_text(self):
6
+ text = "Hello, I am having trouble logging into my email account. Can you please help me reset it?"
7
+ res = analyze_spam_phishing(text)
8
+ self.assertFalse(res["is_spam"])
9
+ self.assertEqual(res["risk_level"], "none")
10
+
11
+ def test_domain_safety(self):
12
+ self.assertTrue(is_domain_safe("google.com"))
13
+ self.assertTrue(is_domain_safe("sub.google.com"))
14
+ self.assertTrue(is_domain_safe("github.com"))
15
+ self.assertFalse(is_domain_safe("phish-login-update.ru"))
16
+ self.assertFalse(is_domain_safe("google-security.xyz"))
17
+
18
+ def test_phishing_by_url(self):
19
+ text = "Immediate action required: please update your profile here: http://phish-secure-login.xyz/verify"
20
+ res = analyze_spam_phishing(text)
21
+ self.assertTrue(res["is_spam"])
22
+ self.assertEqual(res["risk_level"], "high")
23
+ self.assertTrue(any("verify" in r.lower() or "tld" in r.lower() for r in res["reasons"]))
24
+ self.assertEqual(len(res["suspicious_urls"]), 1)
25
+
26
+ def test_spam_by_keywords(self):
27
+ text = "Get rich quick with our brand new crypto trading scheme! Double your money within 24 hours guaranteed!"
28
+ res = analyze_spam_phishing(text)
29
+ self.assertTrue(res["is_spam"])
30
+ self.assertEqual(res["risk_level"], "medium") # medium because of 2+ matched spam keywords
31
+ self.assertTrue(any("spam" in r.lower() or "marketing" in r.lower() for r in res["reasons"]))
32
+
33
+ def test_ocr_phishing_scan(self):
34
+ # Benign text but suspicious URL in OCR text
35
+ text = "Please see the attached document for invoice details."
36
+ ocr = "To verify your account credentials click: http://untrusted-bank-update.info/login"
37
+ res = analyze_spam_phishing(text, ocr)
38
+ self.assertTrue(res["is_spam"])
39
+ self.assertEqual(res["risk_level"], "high")
40
+ self.assertEqual(len(res["suspicious_urls"]), 1)
41
+
42
+ if __name__ == "__main__":
43
+ unittest.main()