HeshamXOR commited on
Commit
698d738
·
verified ·
1 Parent(s): 34bbf1b

refactor: Relocate ML models to backend package, update path loaders, simplify Dockerfile

Browse files
backend/app/models/url.py CHANGED
@@ -51,6 +51,12 @@ class URLAnalyzeResponse(BaseModel):
51
  malicious_score: float = Field(..., ge=0.0, le=1.0)
52
  confidence: str
53
  confidence_level: URLConfidenceLevel
 
 
 
 
 
 
54
  threshold_used: float
55
  base_model_scores: Dict[str, float]
56
  top_signals: Dict[str, float]
@@ -62,7 +68,7 @@ class URLModelInfoResponse(BaseModel):
62
  model_type: str
63
  base_models: List[str]
64
  meta_learner: str
 
65
  scaler_features: int
66
  meta_features: int
67
- dl_available: bool
68
  feature_categories: List[str]
 
51
  malicious_score: float = Field(..., ge=0.0, le=1.0)
52
  confidence: str
53
  confidence_level: URLConfidenceLevel
54
+ raw_score: float = Field(
55
+ ...,
56
+ ge=0.0,
57
+ le=1.0,
58
+ description="Meta-learner score before isotonic calibration.",
59
+ )
60
  threshold_used: float
61
  base_model_scores: Dict[str, float]
62
  top_signals: Dict[str, float]
 
68
  model_type: str
69
  base_models: List[str]
70
  meta_learner: str
71
+ calibration: str
72
  scaler_features: int
73
  meta_features: int
 
74
  feature_categories: List[str]
backend/app/routers/url.py CHANGED
@@ -39,13 +39,15 @@ async def analyze_url(
39
  ):
40
  """
41
  Analyze a URL for malicious indicators using the stacking HyperModel.
42
-
43
- The HyperModel combines 5 base models:
44
- - XGBoost & LightGBM on 54 lexical features
45
  - TF-IDF + Logistic Regression on raw URL text
46
- - CharCNN & CharBiLSTM deep learning on character-level URL encoding
47
-
48
- Their probability outputs are stacked and fed to an XGBoost meta-learner.
 
 
49
  """
50
  try:
51
  url_service = get_url_service()
@@ -89,9 +91,9 @@ async def url_model_info():
89
  "model_type": "Stacking HyperModel",
90
  "base_models": [],
91
  "meta_learner": "hidden",
 
92
  "scaler_features": 0,
93
  "meta_features": 0,
94
- "dl_available": False,
95
  "feature_categories": [],
96
  }
97
  try:
@@ -111,7 +113,8 @@ async def url_health():
111
  return {
112
  "status": "healthy" if url_service.model_loaded else "unhealthy",
113
  "model_loaded": url_service.model_loaded,
114
- "dl_available": url_service._dl_available,
 
115
  }
116
  except Exception as e:
117
  return {
 
39
  ):
40
  """
41
  Analyze a URL for malicious indicators using the stacking HyperModel.
42
+
43
+ The HyperModel combines 3 base models:
44
+ - XGBoost & LightGBM on 66 lexical features
45
  - TF-IDF + Logistic Regression on raw URL text
46
+
47
+ Their P(malicious) outputs are stacked into a logistic-regression
48
+ meta-learner, whose score is then passed through isotonic calibration.
49
+ The response carries both `raw_score` (pre-calibration) and
50
+ `malicious_score` (calibrated, and what the verdict uses).
51
  """
52
  try:
53
  url_service = get_url_service()
 
91
  "model_type": "Stacking HyperModel",
92
  "base_models": [],
93
  "meta_learner": "hidden",
94
+ "calibration": "hidden",
95
  "scaler_features": 0,
96
  "meta_features": 0,
 
97
  "feature_categories": [],
98
  }
99
  try:
 
113
  return {
114
  "status": "healthy" if url_service.model_loaded else "unhealthy",
115
  "model_loaded": url_service.model_loaded,
116
+ "base_models": list(url_service.BASE_MODEL_NAMES),
117
+ "calibrated": url_service.calibrator is not None,
118
  }
119
  except Exception as e:
120
  return {
backend/app/services/feedback_service.py CHANGED
@@ -16,7 +16,8 @@ import json
16
  import logging
17
  import sqlite3
18
  import time
19
- from datetime import datetime, timezone
 
20
  from pathlib import Path
21
  from typing import Optional
22
 
@@ -244,6 +245,49 @@ class FeedbackService:
244
  return None
245
  return None
246
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
247
  def get_stats(self) -> dict:
248
  conn = self._get_conn()
249
 
@@ -301,6 +345,7 @@ class FeedbackService:
301
  "recent_malware": recent_m,
302
  "recent_url": recent_u,
303
  "model_updates": updates,
 
304
  }
305
 
306
  # ── Incremental model updates ─────────────────────────────────────────
@@ -381,11 +426,18 @@ class FeedbackService:
381
  self, min_samples: int = 10, lr: float = 0.01, rounds: int = 5
382
  ) -> dict:
383
  """
384
- Incrementally update the URL meta-learner with accumulated
385
- user feedback. Reconstructs 10 meta-features from stored
386
- base model scores.
 
 
 
 
 
 
387
  """
388
- import xgboost as xgb
 
389
  from .url_service import get_url_service
390
 
391
  conn = self._get_conn()
@@ -401,45 +453,69 @@ class FeedbackService:
401
  "message": f"Need ≥ {min_samples} samples, have {len(rows)}",
402
  }
403
 
404
- BASE_MODEL_ORDER = [
405
- "XGBoost", "LightGBM", "TF-IDF + LR", "CharCNN", "CharBiLSTM"
406
- ]
407
 
408
  meta_features = []
409
  labels = []
410
  for r in rows:
411
  scores = json.loads(r["base_model_scores"])
412
- mf = []
413
- for name in BASE_MODEL_ORDER:
414
- mal = scores.get(name, 0.5)
415
- mf.extend([1.0 - mal, mal])
416
  meta_features.append(mf)
417
  labels.append(1 if r["correct_label"] == "Malicious" else 0)
418
 
419
  X = np.array(meta_features, dtype=np.float32)
420
- y = np.array(labels, dtype=np.float32)
421
-
422
- url_service = get_url_service()
423
- booster = url_service.meta_learner.get_booster()
424
- dtrain = xgb.DMatrix(X, label=y)
425
 
426
- params = {
427
- "objective": "binary:logistic",
428
- "eval_metric": "logloss",
429
- "learning_rate": lr,
430
- "max_depth": 3,
431
- }
 
 
 
 
 
 
432
 
433
- updated = xgb.train(
434
- params, dtrain, num_boost_round=rounds, xgb_model=booster
 
 
 
 
 
 
 
 
 
 
435
  )
436
-
437
- # Patch sklearn model
438
- url_service.meta_learner._Booster = updated
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
439
 
440
  # Persist
441
  meta_path = url_service.models_dir / "hypermodel_meta.joblib"
442
- joblib.dump(url_service.meta_learner, meta_path)
443
 
444
  # Log update
445
  now = datetime.now(timezone.utc).isoformat()
@@ -450,14 +526,15 @@ class FeedbackService:
450
  )
451
  conn.commit()
452
 
453
- logger.info(
454
- f"URL meta-learner updated: {len(rows)} samples, lr={lr}, rounds={rounds}"
455
- )
456
  return {
457
  "success": True,
458
  "model_type": "url",
459
  "samples_used": len(rows),
460
- "message": f"URL meta-learner updated with {len(rows)} feedback samples ({rounds} rounds, lr={lr})",
 
 
 
461
  }
462
 
463
 
 
16
  import logging
17
  import sqlite3
18
  import time
19
+ import warnings
20
+ from datetime import datetime, timedelta, timezone
21
  from pathlib import Path
22
  from typing import Optional
23
 
 
245
  return None
246
  return None
247
 
248
+ def get_daily_counts(self, days: int = 30) -> list[dict]:
249
+ """
250
+ Real per-day feedback counts for the dashboard time series.
251
+
252
+ The frontend used to synthesise this from the totals with a modulo seed,
253
+ which drew a plausible-looking 30-day chart out of a single number. Both
254
+ feedback tables store created_at, so the actual series is available.
255
+
256
+ Returns one entry per day, oldest first, with zero-filled gaps so the
257
+ chart has a continuous x-axis.
258
+ """
259
+ conn = self._get_conn()
260
+
261
+ def counts_for(table: str) -> dict[str, int]:
262
+ # Table name is interpolated, so restrict it to a known set rather
263
+ # than trusting callers.
264
+ if table not in {"malware_feedback", "url_feedback"}:
265
+ raise ValueError(f"unexpected table {table!r}")
266
+ rows = conn.execute(
267
+ "SELECT substr(created_at, 1, 10) AS day, COUNT(*) AS n "
268
+ f"FROM {table} WHERE created_at >= ? GROUP BY day",
269
+ (self._cutoff_iso(days),),
270
+ ).fetchall()
271
+ return {r["day"]: r["n"] for r in rows}
272
+
273
+ malware = counts_for("malware_feedback")
274
+ url = counts_for("url_feedback")
275
+
276
+ today = datetime.now(timezone.utc).date()
277
+ series = []
278
+ for offset in range(days - 1, -1, -1):
279
+ day = today - timedelta(days=offset)
280
+ key = day.isoformat()
281
+ m = malware.get(key, 0)
282
+ u = url.get(key, 0)
283
+ series.append({"date": key, "malware": m, "url": u, "total": m + u})
284
+ return series
285
+
286
+ @staticmethod
287
+ def _cutoff_iso(days: int) -> str:
288
+ cutoff = datetime.now(timezone.utc) - timedelta(days=days - 1)
289
+ return cutoff.date().isoformat()
290
+
291
  def get_stats(self) -> dict:
292
  conn = self._get_conn()
293
 
 
345
  "recent_malware": recent_m,
346
  "recent_url": recent_u,
347
  "model_updates": updates,
348
+ "daily_counts": self.get_daily_counts(30),
349
  }
350
 
351
  # ── Incremental model updates ─────────────────────────────────────────
 
426
  self, min_samples: int = 10, lr: float = 0.01, rounds: int = 5
427
  ) -> dict:
428
  """
429
+ Incrementally update the URL meta-learner with accumulated user feedback.
430
+ Reconstructs the 3 meta-features (one P(malicious) per base model) from
431
+ stored base model scores.
432
+
433
+ The meta-learner is a LogisticRegression, so 'incremental' here means
434
+ refitting on the original decision surface plus the feedback rows rather
435
+ than appending boosting rounds. `lr` and `rounds` are accepted for API
436
+ compatibility and ignored; `rounds` is reported back so callers can see
437
+ it had no effect.
438
  """
439
+ from sklearn.linear_model import LogisticRegression
440
+
441
  from .url_service import get_url_service
442
 
443
  conn = self._get_conn()
 
453
  "message": f"Need ≥ {min_samples} samples, have {len(rows)}",
454
  }
455
 
456
+ # One column per base model, P(malicious) only - matches the 3-column
457
+ # meta-feature layout the calibrated ensemble was trained on.
458
+ BASE_MODEL_ORDER = ["XGBoost", "LightGBM", "TF-IDF + LR"]
459
 
460
  meta_features = []
461
  labels = []
462
  for r in rows:
463
  scores = json.loads(r["base_model_scores"])
464
+ mf = [float(scores.get(name, 0.5)) for name in BASE_MODEL_ORDER]
 
 
 
465
  meta_features.append(mf)
466
  labels.append(1 if r["correct_label"] == "Malicious" else 0)
467
 
468
  X = np.array(meta_features, dtype=np.float32)
469
+ y = np.array(labels, dtype=np.int32)
 
 
 
 
470
 
471
+ # A refit needs both classes present, otherwise LogisticRegression raises.
472
+ if len(np.unique(y)) < 2:
473
+ only = "Malicious" if y[0] == 1 else "Benign"
474
+ return {
475
+ "success": False,
476
+ "model_type": "url",
477
+ "samples_used": len(rows),
478
+ "message": (
479
+ f"All {len(rows)} feedback samples are labelled {only}. "
480
+ "Need both classes to refit the meta-learner."
481
+ ),
482
+ }
483
 
484
+ url_service = get_url_service()
485
+ old_meta = url_service.meta_learner
486
+
487
+ # Anchor the refit to the existing decision surface: sample the current
488
+ # meta-learner across the unit cube and mix those pseudo-rows in, so a
489
+ # handful of feedback samples nudges the boundary instead of replacing it.
490
+ # The grid reaches 0.0/1.0 so the anchor carries the model's confident
491
+ # regions, not just its middle.
492
+ levels = (0.0, 0.15, 0.4, 0.6, 0.85, 1.0)
493
+ grid = np.array(
494
+ [[a, b, c] for a in levels for b in levels for c in levels],
495
+ dtype=np.float32,
496
  )
497
+ with warnings.catch_warnings():
498
+ warnings.filterwarnings("ignore", message="X does not have valid feature names")
499
+ grid_labels = (old_meta.predict_proba(grid)[:, 1] > 0.5).astype(np.int32)
500
+
501
+ X_fit = np.vstack([grid, X])
502
+ y_fit = np.concatenate([grid_labels, y])
503
+ # Feedback rows outweigh the anchor per-sample so real corrections move the
504
+ # boundary, but the anchor keeps total mass so a handful of rows can't
505
+ # flatten the surface. Capped at 25% of the grid's mass in aggregate.
506
+ per_row = min(8.0, (0.25 * len(grid)) / max(len(X), 1))
507
+ weights = np.concatenate([
508
+ np.ones(len(grid), dtype=np.float32),
509
+ np.full(len(X), per_row, dtype=np.float32),
510
+ ])
511
+
512
+ updated = LogisticRegression(C=1.0, max_iter=500, solver="lbfgs", random_state=42)
513
+ updated.fit(X_fit, y_fit, sample_weight=weights)
514
+ url_service.meta_learner = updated
515
 
516
  # Persist
517
  meta_path = url_service.models_dir / "hypermodel_meta.joblib"
518
+ joblib.dump(updated, meta_path)
519
 
520
  # Log update
521
  now = datetime.now(timezone.utc).isoformat()
 
526
  )
527
  conn.commit()
528
 
529
+ logger.info(f"URL meta-learner refit on {len(rows)} feedback samples")
 
 
530
  return {
531
  "success": True,
532
  "model_type": "url",
533
  "samples_used": len(rows),
534
+ "message": (
535
+ f"URL meta-learner refit with {len(rows)} feedback samples "
536
+ f"(logistic meta-learner: lr/rounds not applicable)"
537
+ ),
538
  }
539
 
540
 
backend/app/services/url_feature_engineering.py CHANGED
@@ -1,5 +1,5 @@
1
  """
2
- URL Feature Engineering — 54 lexical features for malicious URL detection.
3
 
4
  Adapted from the URL model notebook for backend integration.
5
  Features are grouped into 8 categories:
 
1
  """
2
+ URL Feature Engineering — 66 lexical features for malicious URL detection.
3
 
4
  Adapted from the URL model notebook for backend integration.
5
  Features are grouped into 8 categories:
backend/app/services/url_service.py CHANGED
@@ -1,399 +1,297 @@
1
- """
2
- URL Malware Detection Service — Stacking HyperModel.
3
-
4
- Full inference pipeline:
5
- 1. Extract 54 lexical features from URL
6
- 2. Scale features with StandardScaler
7
- 3. Run base models (XGBoost, LightGBM, and optionally CharCNN + CharBiLSTM)
8
- 4. Stack their probability outputs → meta-features
9
- 5. Meta-learner (XGBoost) produces final prediction
10
- """
11
-
12
- from __future__ import annotations
13
-
14
- import logging
15
- import time
16
- import warnings
17
- from pathlib import Path
18
-
19
-
20
- import numpy as np
21
- import joblib
22
-
23
- from .url_feature_engineering import extract_url_features, extract_url_features_dict, FEATURE_NAMES
24
-
25
- logger = logging.getLogger(__name__)
26
-
27
- # ── DL architecture definitions (inline, no external dependency) ──────────────
28
- # These mirror the exact architectures from the training notebook.
29
-
30
- MAX_URL_LEN = 200
31
-
32
- # Character lookup table
33
- _CHAR_LUT = np.zeros(256, dtype=np.int64)
34
- _PRINTABLE = (
35
- "abcdefghijklmnopqrstuvwxyz"
36
- "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
37
- "0123456789"
38
- "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~ "
39
- )
40
- for _i, _ch in enumerate(_PRINTABLE, start=1):
41
- _CHAR_LUT[ord(_ch)] = _i
42
- _UNK_ID = len(_PRINTABLE) + 1 # 96
43
- VOCAB_SIZE = _UNK_ID + 1 # 97
44
-
45
- try:
46
- import torch
47
- import torch.nn as nn
48
- TORCH_AVAILABLE = True
49
- except ImportError:
50
- TORCH_AVAILABLE = False
51
- logger.warning("PyTorch not available — DL base models will be skipped")
52
-
53
-
54
- def _url_to_tensor(urls: list[str], max_len: int = MAX_URL_LEN):
55
- """Vectorised char→int encoding for a batch of URL strings."""
56
- arr = np.zeros((len(urls), max_len), dtype=np.int64)
57
- for i, url in enumerate(urls):
58
- raw = str(url)[:max_len]
59
- chars = np.frombuffer(raw.encode("ascii", "ignore"), dtype=np.uint8)
60
- if len(chars) == 0:
61
- continue
62
- ids = _CHAR_LUT[chars]
63
- ids = np.where(ids > 0, ids, _UNK_ID)
64
- arr[i, : len(ids)] = ids[: max_len]
65
- import torch
66
- return torch.from_numpy(arr)
67
-
68
-
69
- if TORCH_AVAILABLE:
70
- class CharCNNNet(nn.Module):
71
- """1-D CNN on character-level URL embeddings."""
72
- def __init__(self, num_classes: int = 2, embed_dim: int = 64):
73
- super().__init__()
74
- self.embed = nn.Embedding(VOCAB_SIZE, embed_dim, padding_idx=0)
75
- self.convs = nn.Sequential(
76
- nn.Conv1d(embed_dim, 128, kernel_size=3, padding=1),
77
- nn.BatchNorm1d(128), nn.ReLU(), nn.MaxPool1d(2),
78
- nn.Conv1d(128, 128, kernel_size=3, padding=1),
79
- nn.BatchNorm1d(128), nn.ReLU(), nn.MaxPool1d(2),
80
- nn.Conv1d(128, 256, kernel_size=3, padding=1),
81
- nn.BatchNorm1d(256), nn.ReLU(), nn.AdaptiveMaxPool1d(1),
82
- )
83
- self.head = nn.Sequential(
84
- nn.Dropout(0.3), nn.Linear(256, 128), nn.ReLU(),
85
- nn.Dropout(0.2), nn.Linear(128, num_classes),
86
- )
87
-
88
- def forward(self, x):
89
- x = self.embed(x).permute(0, 2, 1)
90
- x = self.convs(x).squeeze(-1)
91
- return self.head(x)
92
-
93
- class CharBiLSTMNet(nn.Module):
94
- """Bidirectional LSTM on character embeddings."""
95
- def __init__(self, num_classes: int = 2, embed_dim: int = 64,
96
- hidden: int = 128, n_layers: int = 2):
97
- super().__init__()
98
- self.embed = nn.Embedding(VOCAB_SIZE, embed_dim, padding_idx=0)
99
- self.lstm = nn.LSTM(
100
- embed_dim, hidden, num_layers=n_layers,
101
- batch_first=True, bidirectional=True, dropout=0.3,
102
- )
103
- self.head = nn.Sequential(
104
- nn.Dropout(0.3), nn.Linear(hidden * 2, 128), nn.ReLU(),
105
- nn.Dropout(0.2), nn.Linear(128, num_classes),
106
- )
107
-
108
- def forward(self, x):
109
- x = self.embed(x)
110
- _, (h, _) = self.lstm(x)
111
- h = torch.cat([h[-2], h[-1]], dim=-1)
112
- return self.head(h)
113
-
114
-
115
- # Highly trusted popular domains to prevent false positives on official/major websites
116
- TRUSTED_DOMAINS = {
117
- "google.com", "wikipedia.org", "wikimedia.org", "github.com", "github.io",
118
- "apple.com", "microsoft.com", "microsoftonline.com", "live.com", "outlook.com",
119
- "office.com", "bing.com", "msn.com", "yahoo.com", "amazon.com",
120
- "paypal.com", "wellsfargo.com", "facebook.com", "instagram.com", "whatsapp.com",
121
- "twitter.com", "x.com", "linkedin.com", "netflix.com", "dropbox.com", "zoom.us",
122
- "salesforce.com", "adobe.com", "ebay.com", "chase.com", "bankofamerica.com",
123
- "citibank.com", "hsbc.com", "dhl.com", "fedex.com", "usps.com", "spotify.com",
124
- "reddit.com", "google.co.uk", "google.ca", "google.de", "google.fr",
125
- "amazon.co.uk", "amazon.de", "amazon.co.jp"
126
- }
127
-
128
-
129
- def _get_registered_domain(url: str) -> str:
130
- """Helper to extract the registered domain (e.g. google.com) from a URL."""
131
- try:
132
- from urllib.parse import urlparse
133
- try:
134
- import tldextract
135
- except ImportError:
136
- tldextract = None
137
- except Exception:
138
- return ""
139
-
140
- url = str(url).strip()
141
- if "://" not in url:
142
- url = f"http://{url}"
143
-
144
- try:
145
- parsed = urlparse(url)
146
- hostname = parsed.hostname or ""
147
- except Exception:
148
- hostname = ""
149
-
150
- if not hostname:
151
- return ""
152
-
153
- hostname = hostname.lower()
154
- if ":" in hostname:
155
- hostname = hostname.split(":")[0]
156
-
157
- if tldextract is not None:
158
- try:
159
- ext = tldextract.extract(url)
160
- if ext.domain and ext.suffix:
161
- return f"{ext.domain}.{ext.suffix}".lower()
162
- except Exception:
163
- pass
164
-
165
- # Fallback to last two parts of the hostname
166
- parts = hostname.split(".")
167
- if len(parts) >= 2:
168
- return f"{parts[-2]}.{parts[-1]}".lower()
169
- return hostname
170
-
171
-
172
- class URLModelService:
173
- """
174
- Stacking HyperModel service for URL malware detection.
175
-
176
- Loads all base models + meta-learner at initialization time.
177
- Thread-safe for concurrent FastAPI requests.
178
- """
179
-
180
- def __init__(self, models_dir: str | Path | None = None):
181
- if models_dir is None:
182
- # Default to urlmodel_info/ in project root
183
- models_dir = Path(__file__).parent.parent.parent.parent / "urlmodel_info"
184
- self.models_dir = Path(models_dir)
185
-
186
- self.scaler = None
187
- self.meta_learner = None
188
- self.xgb_model = None
189
- self.lgbm_model = None
190
- self.cnn_net = None
191
- self.bilstm_net = None
192
- self.model_loaded = False
193
- self._dl_available = False
194
-
195
- self._load_models()
196
-
197
- def _load_models(self):
198
- """Load all model components."""
199
- logger.info("Loading URL HyperModel components...")
200
-
201
- with warnings.catch_warnings():
202
- warnings.simplefilter("ignore")
203
-
204
- # 1. Scaler
205
- scaler_path = self.models_dir / "scaler.joblib"
206
- self.scaler = joblib.load(scaler_path)
207
- logger.info(f" Scaler loaded ({self.scaler.n_features_in_} features)")
208
-
209
- # 2. Meta-learner
210
- meta_path = self.models_dir / "hypermodel_meta.joblib"
211
- self.meta_learner = joblib.load(meta_path)
212
- logger.info(f" Meta-learner loaded ({self.meta_learner.n_features_in_} meta-features)")
213
-
214
- # 3. XGBoost base model
215
- xgb_path = self.models_dir / "xgb_binary.joblib"
216
- self.xgb_model = joblib.load(xgb_path)
217
- logger.info(" XGBoost base model loaded")
218
-
219
- # 4. LightGBM base model
220
- lgbm_path = self.models_dir / "lgbm_binary.joblib"
221
- self.lgbm_model = joblib.load(lgbm_path)
222
- logger.info(" LightGBM base model loaded")
223
-
224
- # 5. Deep Learning models (optional — requires PyTorch)
225
- if TORCH_AVAILABLE:
226
- try:
227
- cnn_path = self.models_dir / "char_cnn_net.pt"
228
- bilstm_path = self.models_dir / "char_bilstm_net.pt"
229
-
230
- self.cnn_net = CharCNNNet(num_classes=2)
231
- self.cnn_net.load_state_dict(
232
- torch.load(cnn_path, map_location="cpu", weights_only=True)
233
- )
234
- self.cnn_net.eval()
235
- logger.info(" CharCNN loaded")
236
-
237
- self.bilstm_net = CharBiLSTMNet(num_classes=2)
238
- self.bilstm_net.load_state_dict(
239
- torch.load(bilstm_path, map_location="cpu", weights_only=True)
240
- )
241
- self.bilstm_net.eval()
242
- logger.info(" CharBiLSTM loaded")
243
-
244
- self._dl_available = True
245
- except Exception as e:
246
- logger.warning(f" DL models failed to load: {e}")
247
- self._dl_available = False
248
-
249
- self.model_loaded = True
250
- n_base = 2 + (2 if self._dl_available else 0)
251
- logger.info(f"URL HyperModel ready ({n_base} base models + meta-learner)")
252
-
253
- def predict(self, url: str, threshold: float = 0.5) -> dict:
254
- """
255
- Run the full stacking pipeline on a single URL.
256
-
257
- Returns dict with:
258
- - url: input URL
259
- - prediction: 'Malicious' or 'Benign'
260
- - malicious_score: float [0, 1]
261
- - confidence: str percentage
262
- - confidence_level: 'High', 'Medium', or 'Low'
263
- - base_model_scores: dict of individual model scores
264
- - top_signals: dict of most important feature values
265
- - inference_time_ms: float
266
- """
267
- start = time.perf_counter()
268
-
269
- # 1. Extract features
270
- features = extract_url_features(url)
271
- features_dict = extract_url_features_dict(url)
272
-
273
- # Ensure backward compatibility with existing model/scaler
274
- if self.scaler is not None:
275
- features = features[:self.scaler.n_features_in_]
276
- X_feat = features.reshape(1, -1)
277
-
278
- # 2. Scale features
279
- X_scaled = self.scaler.transform(X_feat).astype(np.float32)
280
-
281
- # 3. Get base model predictions
282
- p_xgb = self.xgb_model.predict_proba(X_scaled)
283
- p_lgbm = self.lgbm_model.predict_proba(X_scaled)
284
-
285
- base_scores = {
286
- "XGBoost": float(p_xgb[0, 1]),
287
- "LightGBM": float(p_lgbm[0, 1]),
288
- }
289
-
290
- # Determine how many features the meta-learner expects (e.g. 4 or 8)
291
- meta_feats_expected = getattr(self.meta_learner, "n_features_in_", 8)
292
-
293
- # 4. DL models (if available and expected by the meta-learner)
294
- if meta_feats_expected == 4:
295
- meta_features = np.hstack([p_xgb, p_lgbm])
296
- else:
297
- if self._dl_available:
298
- xt = _url_to_tensor([url])
299
- with torch.no_grad():
300
- p_cnn = torch.softmax(self.cnn_net(xt).float(), dim=-1).numpy()
301
- p_bilstm = torch.softmax(self.bilstm_net(xt).float(), dim=-1).numpy()
302
- base_scores["CharCNN"] = float(p_cnn[0, 1])
303
- base_scores["CharBiLSTM"] = float(p_bilstm[0, 1])
304
- meta_features = np.hstack([p_xgb, p_lgbm, p_cnn, p_bilstm])
305
- else:
306
- # Fallback: pad with zeros for missing DL models
307
- zeros = np.zeros((1, 2), dtype=np.float32)
308
- meta_features = np.hstack([p_xgb, p_lgbm, zeros, zeros])
309
-
310
- meta_features = meta_features.astype(np.float32)
311
-
312
- # 5. Meta-learner prediction
313
- final_proba = self.meta_learner.predict_proba(meta_features)
314
- malicious_score = float(final_proba[0, 1])
315
-
316
- # Check domain-level trust resolver to prevent high-entropy administrative false positives
317
- reg_domain = _get_registered_domain(url)
318
- if reg_domain and reg_domain in TRUSTED_DOMAINS:
319
- malicious_score = malicious_score * 0.15 # Dampen but don't hard-cap for trusted domains
320
-
321
- # Determine verdict
322
- prediction = "Malicious" if malicious_score > threshold else "Benign"
323
- confidence_pct = abs(malicious_score - 0.5) * 200
324
-
325
- if confidence_pct >= 80:
326
- confidence_level = "High"
327
- elif confidence_pct >= 50:
328
- confidence_level = "Medium"
329
- else:
330
- confidence_level = "Low"
331
-
332
- # Top feature signals
333
- important_features = [
334
- "has_ip", "has_suspicious_tld", "phishing_keyword_count",
335
- "brand_in_subdomain", "has_punycode", "is_shortened",
336
- "url_length", "num_subdomains", "has_https", "domain_entropy",
337
- "has_suspicious_ext", "has_hex_encoding", "brand_count",
338
- "path_depth", "url_entropy",
339
- ]
340
- top_signals = {}
341
- for feat in important_features:
342
- if feat in features_dict:
343
- val = features_dict[feat]
344
- if val != 0:
345
- top_signals[feat] = round(float(val), 4)
346
-
347
- elapsed = (time.perf_counter() - start) * 1000
348
-
349
- return {
350
- "url": url,
351
- "prediction": prediction,
352
- "malicious_score": round(malicious_score, 6),
353
- "confidence": f"{confidence_pct:.1f}%",
354
- "confidence_level": confidence_level,
355
- "threshold_used": threshold,
356
- "base_model_scores": base_scores,
357
- "top_signals": top_signals,
358
- "inference_time_ms": round(elapsed, 2),
359
- }
360
-
361
- def get_model_info(self) -> dict:
362
- """Return info about loaded models."""
363
- return {
364
- "model_type": "Stacking HyperModel",
365
- "base_models": list(self._get_base_model_names()),
366
- "meta_learner": "XGBoost (GPU-trained)",
367
- "scaler_features": self.scaler.n_features_in_,
368
- "meta_features": self.meta_learner.n_features_in_,
369
- "dl_available": self._dl_available,
370
- "feature_categories": [
371
- "Length/structural (7)",
372
- "Character counts (14)",
373
- "Character ratios (5)",
374
- "Entropy/randomness (2)",
375
- "Boolean indicators (10)",
376
- "Domain/TLD signals (6)",
377
- "Suspicious patterns (5)",
378
- "Path & query (5)",
379
- ],
380
- }
381
-
382
- def _get_base_model_names(self):
383
- meta_feats_expected = getattr(self.meta_learner, "n_features_in_", 8)
384
- names = ["XGBoost", "LightGBM"]
385
- if meta_feats_expected == 8 and self._dl_available:
386
- names.extend(["CharCNN", "CharBiLSTM"])
387
- return names
388
-
389
-
390
- # Singleton accessor
391
- _url_service: URLModelService | None = None
392
-
393
-
394
- def get_url_service() -> URLModelService:
395
- """Get or create the singleton URL model service."""
396
- global _url_service
397
- if _url_service is None:
398
- _url_service = URLModelService()
399
- return _url_service
 
1
+ """
2
+ URL Malware Detection Service - Stacking HyperModel.
3
+
4
+ Full inference pipeline:
5
+ 1. Extract 66 lexical features from the URL
6
+ 2. Scale features with StandardScaler
7
+ 3. Run three base models: XGBoost, LightGBM (both on scaled features)
8
+ and TF-IDF + LogisticRegression (on the raw URL text)
9
+ 4. Stack their P(malicious) outputs into a 3-column meta-feature vector
10
+ 5. LogisticRegression meta-learner produces a raw score
11
+ 6. IsotonicRegression calibrates that score into a usable probability
12
+
13
+ Step 6 is what stops scores saturating at 1.0, and together with the cleaned
14
+ training corpus it is what removed the benign false positives - the old model
15
+ called github.com and google.com malicious because its labels said so.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import logging
21
+ import time
22
+ import warnings
23
+ from pathlib import Path
24
+
25
+ import numpy as np
26
+ import joblib
27
+
28
+ from .url_feature_engineering import extract_url_features, extract_url_features_dict
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+
33
+ # Highly trusted popular domains to prevent false positives on official/major websites.
34
+ # Retained deliberately: the retrained model still has one residual false positive on
35
+ # the OAuth consent-URL pattern, so this stays as the final safety net.
36
+ TRUSTED_DOMAINS = {
37
+ "google.com", "wikipedia.org", "wikimedia.org", "github.com", "github.io",
38
+ "apple.com", "microsoft.com", "microsoftonline.com", "live.com", "outlook.com",
39
+ "office.com", "bing.com", "msn.com", "yahoo.com", "amazon.com",
40
+ "paypal.com", "wellsfargo.com", "facebook.com", "instagram.com", "whatsapp.com",
41
+ "twitter.com", "x.com", "linkedin.com", "netflix.com", "dropbox.com", "zoom.us",
42
+ "salesforce.com", "adobe.com", "ebay.com", "chase.com", "bankofamerica.com",
43
+ "citibank.com", "hsbc.com", "dhl.com", "fedex.com", "usps.com", "spotify.com",
44
+ "reddit.com", "google.co.uk", "google.ca", "google.de", "google.fr",
45
+ "amazon.co.uk", "amazon.de", "amazon.co.jp"
46
+ }
47
+
48
+
49
+ def _get_registered_domain(url: str) -> str:
50
+ """Helper to extract the registered domain (e.g. google.com) from a URL."""
51
+ try:
52
+ from urllib.parse import urlparse
53
+ try:
54
+ import tldextract
55
+ except ImportError:
56
+ tldextract = None
57
+ except Exception:
58
+ return ""
59
+
60
+ url = str(url).strip()
61
+ if "://" not in url:
62
+ url = f"http://{url}"
63
+
64
+ try:
65
+ parsed = urlparse(url)
66
+ hostname = parsed.hostname or ""
67
+ except Exception:
68
+ hostname = ""
69
+
70
+ if not hostname:
71
+ return ""
72
+
73
+ hostname = hostname.lower()
74
+ if ":" in hostname:
75
+ hostname = hostname.split(":")[0]
76
+
77
+ if tldextract is not None:
78
+ try:
79
+ ext = tldextract.extract(url)
80
+ if ext.domain and ext.suffix:
81
+ return f"{ext.domain}.{ext.suffix}".lower()
82
+ except Exception:
83
+ pass
84
+
85
+ # Fallback to last two parts of the hostname
86
+ parts = hostname.split(".")
87
+ if len(parts) >= 2:
88
+ return f"{parts[-2]}.{parts[-1]}".lower()
89
+ return hostname
90
+
91
+
92
+ class URLModelService:
93
+ """
94
+ Stacking HyperModel service for URL malware detection.
95
+
96
+ Loads all base models, the meta-learner and the calibrator at init.
97
+ Thread-safe for concurrent FastAPI requests.
98
+ """
99
+
100
+ BASE_MODEL_NAMES = ("XGBoost", "LightGBM", "TF-IDF + LR")
101
+
102
+ def __init__(self, models_dir: str | Path | None = None):
103
+ if models_dir is None:
104
+ # Default to urlmodel_info/ in project root
105
+ models_dir = Path(__file__).parent.parent.parent.parent / "urlmodel_info"
106
+ self.models_dir = Path(models_dir)
107
+
108
+ self.scaler = None
109
+ self.meta_learner = None
110
+ self.calibrator = None
111
+ self.xgb_model = None
112
+ self.lgbm_model = None
113
+ self.tfidf_lr = None
114
+ self.model_loaded = False
115
+
116
+ self._load_models()
117
+
118
+ def _load_models(self):
119
+ """Load all model components."""
120
+ logger.info("Loading URL HyperModel components...")
121
+
122
+ with warnings.catch_warnings():
123
+ warnings.simplefilter("ignore")
124
+
125
+ self.scaler = joblib.load(self.models_dir / "scaler.joblib")
126
+ logger.info(f" Scaler loaded ({self.scaler.n_features_in_} features)")
127
+
128
+ self.meta_learner = joblib.load(self.models_dir / "hypermodel_meta.joblib")
129
+ n_meta = getattr(self.meta_learner, "n_features_in_", None)
130
+ logger.info(f" Meta-learner loaded ({n_meta} meta-features)")
131
+
132
+ self.calibrator = joblib.load(self.models_dir / "hypermodel_calibrator.joblib")
133
+ logger.info(" Isotonic calibrator loaded")
134
+
135
+ self.xgb_model = joblib.load(self.models_dir / "xgb_binary.joblib")
136
+ logger.info(" XGBoost base model loaded")
137
+
138
+ self.lgbm_model = joblib.load(self.models_dir / "lgbm_binary.joblib")
139
+ logger.info(" LightGBM base model loaded")
140
+
141
+ self.tfidf_lr = joblib.load(self.models_dir / "tfidf_lr_binary.joblib")
142
+ logger.info(" TF-IDF + LogisticRegression base model loaded")
143
+
144
+ if n_meta is not None and n_meta != len(self.BASE_MODEL_NAMES):
145
+ # Loud rather than silently wrong: a mismatch here means the artifacts on
146
+ # disk are not the 3-column ensemble this code stacks for.
147
+ raise ValueError(
148
+ f"Meta-learner expects {n_meta} features but this service stacks "
149
+ f"{len(self.BASE_MODEL_NAMES)} base models "
150
+ f"({', '.join(self.BASE_MODEL_NAMES)}). Model artifacts in "
151
+ f"{self.models_dir} do not match the expected ensemble topology."
152
+ )
153
+
154
+ self.model_loaded = True
155
+ logger.info(
156
+ f"URL HyperModel ready ({len(self.BASE_MODEL_NAMES)} base models "
157
+ f"+ meta-learner + isotonic calibration)"
158
+ )
159
+
160
+ def predict(self, url: str, threshold: float = 0.5) -> dict:
161
+ """
162
+ Run the full stacking pipeline on a single URL.
163
+
164
+ Returns dict with:
165
+ - url: input URL
166
+ - prediction: 'Malicious' or 'Benign'
167
+ - malicious_score: calibrated float [0, 1]
168
+ - raw_score: meta-learner score before isotonic calibration
169
+ - confidence: str percentage
170
+ - confidence_level: 'High', 'Medium', or 'Low'
171
+ - base_model_scores: dict of individual model scores
172
+ - top_signals: dict of most important feature values
173
+ - inference_time_ms: float
174
+ """
175
+ start = time.perf_counter()
176
+
177
+ # 1. Extract features
178
+ features = extract_url_features(url)
179
+ features_dict = extract_url_features_dict(url)
180
+
181
+ # Guard against a feature-extractor/scaler drift
182
+ if self.scaler is not None:
183
+ features = features[:self.scaler.n_features_in_]
184
+ X_feat = features.reshape(1, -1)
185
+
186
+ # 2. Scale features
187
+ X_scaled = self.scaler.transform(X_feat).astype(np.float32)
188
+
189
+ # 3. Base model P(malicious). TF-IDF/LR consumes the raw URL string,
190
+ # not the engineered features.
191
+ #
192
+ # LightGBM was fitted on a bare numpy array, so it carries placeholder
193
+ # feature names (Column_0..65) and warns on every call about the array we
194
+ # pass. Column ORDER is what matters and is verified identical to
195
+ # FEATURE_NAMES, so the warning is noise - suppressed here rather than
196
+ # globally so real warnings still surface.
197
+ with warnings.catch_warnings():
198
+ warnings.filterwarnings(
199
+ "ignore", message="X does not have valid feature names"
200
+ )
201
+ p_xgb = float(self.xgb_model.predict_proba(X_scaled)[0, 1])
202
+ p_lgbm = float(self.lgbm_model.predict_proba(X_scaled)[0, 1])
203
+ p_tfidf = float(self.tfidf_lr.predict_proba([str(url)])[0, 1])
204
+
205
+ base_scores = {
206
+ "XGBoost": p_xgb,
207
+ "LightGBM": p_lgbm,
208
+ "TF-IDF + LR": p_tfidf,
209
+ }
210
+
211
+ # 4. Stack -> 5. meta-learner -> 6. isotonic calibration
212
+ meta_features = np.array([[p_xgb, p_lgbm, p_tfidf]], dtype=np.float32)
213
+ raw_score = float(self.meta_learner.predict_proba(meta_features)[0, 1])
214
+ malicious_score = float(self.calibrator.predict([raw_score])[0])
215
+
216
+ # Domain-level trust resolver, for residual edge cases like OAuth consent URLs
217
+ reg_domain = _get_registered_domain(url)
218
+ if reg_domain and reg_domain in TRUSTED_DOMAINS:
219
+ malicious_score = malicious_score * 0.15 # Dampen but don't hard-cap
220
+
221
+ # Determine verdict
222
+ prediction = "Malicious" if malicious_score > threshold else "Benign"
223
+ # Isotonic calibration can map to exactly 0.0/1.0, which would render as a
224
+ # flat 100.0% - a certainty no classifier has. Cap the reported confidence
225
+ # at 99.9 so the string never claims more than the model can support. The
226
+ # verdict and malicious_score are unaffected.
227
+ confidence_pct = min(99.9, abs(malicious_score - 0.5) * 200)
228
+
229
+ if confidence_pct >= 80:
230
+ confidence_level = "High"
231
+ elif confidence_pct >= 50:
232
+ confidence_level = "Medium"
233
+ else:
234
+ confidence_level = "Low"
235
+
236
+ # Top feature signals
237
+ important_features = [
238
+ "has_ip", "has_suspicious_tld", "phishing_keyword_count",
239
+ "brand_in_subdomain", "has_punycode", "is_shortened",
240
+ "url_length", "num_subdomains", "has_https", "domain_entropy",
241
+ "has_suspicious_ext", "has_hex_encoding", "brand_count",
242
+ "path_depth", "url_entropy",
243
+ ]
244
+ top_signals = {}
245
+ for feat in important_features:
246
+ if feat in features_dict:
247
+ val = features_dict[feat]
248
+ if val != 0:
249
+ top_signals[feat] = round(float(val), 4)
250
+
251
+ elapsed = (time.perf_counter() - start) * 1000
252
+
253
+ return {
254
+ "url": url,
255
+ "prediction": prediction,
256
+ "malicious_score": round(malicious_score, 6),
257
+ "raw_score": round(raw_score, 6),
258
+ "confidence": f"{confidence_pct:.1f}%",
259
+ "confidence_level": confidence_level,
260
+ "threshold_used": threshold,
261
+ "base_model_scores": base_scores,
262
+ "top_signals": top_signals,
263
+ "inference_time_ms": round(elapsed, 2),
264
+ }
265
+
266
+ def get_model_info(self) -> dict:
267
+ """Return info about loaded models."""
268
+ return {
269
+ "model_type": "Stacking HyperModel (calibrated)",
270
+ "base_models": list(self.BASE_MODEL_NAMES),
271
+ "meta_learner": "LogisticRegression",
272
+ "calibration": "IsotonicRegression on out-of-fold meta scores",
273
+ "scaler_features": self.scaler.n_features_in_,
274
+ "meta_features": getattr(self.meta_learner, "n_features_in_", None),
275
+ "feature_categories": [
276
+ "Length/structural (7)",
277
+ "Character counts (14)",
278
+ "Character ratios (5)",
279
+ "Entropy/randomness (2)",
280
+ "Boolean indicators (10)",
281
+ "Domain/TLD signals (6)",
282
+ "Suspicious patterns (5)",
283
+ "Path & query (5)",
284
+ ],
285
+ }
286
+
287
+
288
+ # Singleton accessor
289
+ _url_service: URLModelService | None = None
290
+
291
+
292
+ def get_url_service() -> URLModelService:
293
+ """Get or create the singleton URL model service."""
294
+ global _url_service
295
+ if _url_service is None:
296
+ _url_service = URLModelService()
297
+ return _url_service
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
urlmodel_info/hypermodel_calibrator.joblib ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:1a7cb24e5c2e73af5e40020fb00a989953c51cb7d6178d5f829b0216220268d6
3
+ size 5735
urlmodel_info/hypermodel_meta.joblib CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:270f07caa538357f439a25dcc433d45f2c376cd47ca673173a58383f5ef28d39
3
- size 118639
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:2e15aa8454216faee298ab6ec31dfd5e5fb51ba9a5046dd9b97fdb2badd38369
3
+ size 879
urlmodel_info/lgbm_binary.joblib CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:9c4c1f87ca80b2dd69abb25c568568dee29fcb529ebb4a73e6e58a16581e1c95
3
- size 366262
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:4c18f6fc8832fb18c4b3def87bcd991bac582006e325802e95a7eb0aa0387d36
3
+ size 2113830
urlmodel_info/scaler.joblib CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:0340c41cb7fccee8a44f0099b9a5e61b81be5e74c09206ea8b54d9320c3f8aec
3
  size 2167
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:81771f11487019d710177ee4720b2ace069a74c5ff12d2b1b86b01f6909fb153
3
  size 2167
urlmodel_info/tfidf_lr_binary.joblib CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:9cd31b7d37e5b721f95a22483d9e45a28a7b7dc60ca1694cc9c265e797ba517b
3
- size 824640
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:5684ee91ca2f39ae05af312a740a68353f1bd1b6629ac2588291c4d3a4bac0db
3
+ size 1355680
urlmodel_info/xgb_binary.joblib CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:9cd7da8abb1fe42878e4f8fdf677eddd8b11a7bf257f704f14582312d947ca6d
3
- size 470189
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:9bcf0af9b9013d0d507d0929ebadd8be67eae58d54a547dbd58490b6200a449f
3
+ size 2017153