Che237 commited on
Commit
95a33f2
·
verified ·
1 Parent(s): 7414180

Update the CyberForge ML service

Browse files
Files changed (2) hide show
  1. trainer.py +126 -38
  2. url_features.py +6 -2
trainer.py CHANGED
@@ -1,6 +1,13 @@
1
  """
2
  Trains the CyberForge detection models from public, labelled data.
3
 
 
 
 
 
 
 
 
4
  This runs inside the Hugging Face Space (app.py starts it from a protected
5
  endpoint). It is never run on a developer machine.
6
 
@@ -47,19 +54,25 @@ from url_features import (ALL_FEATURES, ATTACK_FEATURES, LEXICAL_FEATURES, bare_
47
 
48
  logger = logging.getLogger("cyberforge.trainer")
49
 
50
- URL_COLLECTION = "bgspaditya/byt-malicious-url-treatment" # ~640k URLs: benign / phishing / malware / defacement
 
51
  PHISHING_REFERENCE = "pirocheto/phishing-url" # 11k balanced full URLs, CC-BY-4.0, with its own test split
 
52
  ATTACK_PAYLOADS = "shengqin/web-attacks" # SQL-injection and XSS payloads, and normal strings
53
 
54
  DEFAULTS = {
55
- "benign_sample": 120000,
56
- "phishing_sample": 80000,
 
 
57
  "malware_sample": 30000,
58
  "attack_benign_sample": 40000,
59
  "test_fraction": 0.2,
60
  "max_iter": 300,
61
  "learning_rate": 0.1,
62
  "max_leaf_nodes": 31,
 
 
63
  "anomaly_contamination": 0.02,
64
  "seed": 42,
65
  }
@@ -69,8 +82,10 @@ DEFAULTS = {
69
  # none of the well-known legitimate URLs below.
70
  GATE = {"min_auc": 0.90, "max_false_positive_rate": 0.05, "max_known_legitimate_flagged": 0}
71
  # The anomaly model has no labels to be scored against, so its gate is that it
72
- # flags few held-out benign URLs and more malicious ones than benign ones.
73
- ANOMALY_GATE = {"max_benign_flagged": 0.05}
 
 
74
 
75
  # Well-known legitimate URLs, several of them long or full of parameters: the
76
  # kind the first models called critical. They are never used for training.
@@ -163,10 +178,29 @@ def clean_urls(values) -> List[str]:
163
  return out
164
 
165
 
 
 
 
 
 
 
 
166
  def load_url_collection(token: Optional[str]) -> Dict[str, List[str]]:
167
  paths = _download(URL_COLLECTION, lambda name: name.startswith("data/train") and name.endswith(".parquet"), token)
168
  frame = pd.concat([pd.read_parquet(path, columns=["url", "type"]) for path in paths], ignore_index=True)
169
- return {kind: clean_urls(frame.loc[frame["type"] == kind, "url"].tolist()) for kind in ("benign", "phishing", "malware")}
 
 
 
 
 
 
 
 
 
 
 
 
170
 
171
 
172
  def load_phishing_reference(token: Optional[str]) -> Dict[str, Dict[str, List[str]]]:
@@ -199,6 +233,18 @@ def load_payloads(token: Optional[str]) -> Dict[str, List[str]]:
199
  return out
200
 
201
 
 
 
 
 
 
 
 
 
 
 
 
 
202
  def compose_url(base: str, payload: str, rng: random.Random) -> str:
203
  """Put a payload where one arrives in practice: as a query value on an
204
  ordinary site, encoded or not, or (less often) in the path."""
@@ -258,11 +304,20 @@ def score_urls(model, names: List[str], urls: List[str]) -> List[Dict]:
258
  return [{"url": url, "score": round(float(score), 4)} for url, score in zip(urls, scores)]
259
 
260
 
261
- def train_classifier(name: str, positives: List[str], negatives: List[str], names: List[str], settings: Dict,
262
- extra_tests: Optional[Dict[str, Dict[str, List[str]]]] = None) -> Dict:
263
- """Fit one classifier and measure it on sites it did not train on."""
264
- urls = negatives + positives
265
- labels = np.array([0] * len(negatives) + [1] * len(positives))
 
 
 
 
 
 
 
 
 
266
  groups = np.array([registered_domain(url) for url in urls])
267
  splitter = GroupShuffleSplit(n_splits=1, test_size=settings["test_fraction"], random_state=settings["seed"])
268
  train_index, test_index = next(splitter.split(urls, labels, groups))
@@ -270,14 +325,23 @@ def train_classifier(name: str, positives: List[str], negatives: List[str], name
270
  table = feature_table(urls)[:, columns(names)]
271
  model = HistGradientBoostingClassifier(
272
  max_iter=int(settings["max_iter"]), learning_rate=float(settings["learning_rate"]),
273
- max_leaf_nodes=int(settings["max_leaf_nodes"]), class_weight="balanced",
 
274
  early_stopping=True, validation_fraction=0.1, n_iter_no_change=15, random_state=settings["seed"],
275
  )
276
  started = time.time()
277
  model.fit(table[train_index], labels[train_index])
278
  seconds = round(time.time() - started, 1)
279
 
280
- holdout = measure(labels[test_index], model.predict_proba(table[test_index])[:, 1])
 
 
 
 
 
 
 
 
281
  report = {
282
  "model_type": "HistGradientBoostingClassifier",
283
  "feature_names": names,
@@ -285,8 +349,10 @@ def train_classifier(name: str, positives: List[str], negatives: List[str], name
285
  "training": {
286
  "rows": int(len(train_index)), "positives": int(labels[train_index].sum()),
287
  "sites": int(len(set(groups[train_index]))), "seconds": seconds, "iterations": int(model.n_iter_),
 
 
288
  },
289
- "holdout": {**holdout, "sites": int(len(set(groups[test_index]))),
290
  "note": "URLs from sites that were not in the training rows"},
291
  "other_datasets": {},
292
  }
@@ -369,8 +435,8 @@ def train_anomaly(benign: List[str], malicious: List[str], names: List[str], set
369
  reasons = []
370
  if benign_flagged > ANOMALY_GATE["max_benign_flagged"]:
371
  reasons.append(f"it flags {benign_flagged} of held-out benign URLs, above {ANOMALY_GATE['max_benign_flagged']}")
372
- if malicious_flagged <= benign_flagged:
373
- reasons.append("it does not flag malicious URLs more often than benign ones")
374
  report["gate"] = {"rules": ANOMALY_GATE, "passed": not reasons, "reasons": reasons}
375
  return {"model": model, "report": report}
376
 
@@ -389,15 +455,25 @@ def run_training(options: Dict, token: Optional[str], model_repo: str, progress:
389
  wanted = options.get("models") or ["phishing_detection", "malware_detection", "web_attack_detection", "anomaly_detection"]
390
  dry_run = bool(options.get("dry_run", False))
391
  rng = random.Random(settings["seed"])
 
 
 
 
 
 
392
 
393
  progress("downloading data")
 
394
  collection = load_url_collection(token)
395
  reference = load_phishing_reference(token)
 
396
  payloads = load_payloads(token) if "web_attack_detection" in wanted else {"attack": [], "normal": []}
397
  sources = {
 
398
  URL_COLLECTION: {"revision": _revision(URL_COLLECTION, token), "rows": {kind: len(urls) for kind, urls in collection.items()}},
399
  PHISHING_REFERENCE: {"revision": _revision(PHISHING_REFERENCE, token),
400
  "rows": {split: {status: len(urls) for status, urls in parts.items()} for split, parts in reference.items()}},
 
401
  ATTACK_PAYLOADS: {"revision": _revision(ATTACK_PAYLOADS, token), "rows": {kind: len(texts) for kind, texts in payloads.items()}},
402
  }
403
 
@@ -406,54 +482,66 @@ def run_training(options: Dict, token: Optional[str], model_repo: str, progress:
406
  rng.shuffle(items)
407
  return items[: int(size)]
408
 
409
- benign = sample(collection["benign"], settings["benign_sample"])
410
  # URLs of the reference test split must not be trained on.
411
  reference_test = {bare_url(url).lower() for part in reference["test"].values() for url in part}
412
  keep = lambda urls: [url for url in urls if bare_url(url).lower() not in reference_test] # noqa: E731
413
 
 
 
 
 
 
 
 
 
 
 
 
414
  results: Dict[str, Dict] = {}
415
- try_models = [name for name in wanted]
416
 
417
- if "phishing_detection" in try_models:
418
  progress("training phishing_detection")
419
- positives = keep(sample(collection["phishing"], settings["phishing_sample"]) + reference["train"]["phishing"])
420
- negatives = keep(benign + reference["train"]["legitimate"])
 
 
421
  results["phishing_detection"] = train_classifier(
422
- "phishing_detection", clean_urls(positives), clean_urls(negatives), LEXICAL_FEATURES, settings,
423
  extra_tests={PHISHING_REFERENCE + " (test split)": {"negatives": reference["test"]["legitimate"], "positives": reference["test"]["phishing"]}})
424
 
425
- if "malware_detection" in try_models:
426
  progress("training malware_detection")
427
- positives = sample(collection["malware"], settings["malware_sample"])
428
- results["malware_detection"] = train_classifier(
429
- "malware_detection", positives, keep(benign + reference["train"]["legitimate"]), LEXICAL_FEATURES, settings)
430
 
431
- if "web_attack_detection" in try_models:
432
  progress("training web_attack_detection")
433
  if not payloads["attack"]:
434
  raise TrainingError("No attack payloads were loaded")
435
- bases = sample(collection["benign"], settings["attack_benign_sample"])
436
- with_query = [url for url in collection["benign"] if "?" in url]
437
- ordinary = clean_urls(bases + sample(with_query, settings["attack_benign_sample"]) + reference["train"]["legitimate"])
438
- ordinary += [compose_url(rng.choice(bases), text, rng) for text in payloads["normal"]]
 
 
 
439
  # each payload is carried on two different sites, so the model cannot learn a site
440
- attacks = [compose_url(rng.choice(bases), text, rng) for text in payloads["attack"] for _ in range(2)]
441
- results["web_attack_detection"] = train_classifier(
442
- "web_attack_detection", clean_urls(attacks), clean_urls(ordinary), ATTACK_FEATURES, settings)
443
  results["web_attack_detection"]["report"]["training"]["note"] = (
444
  "attack rows are real SQL-injection and XSS payloads placed as a query value or path segment on benign sites")
445
 
446
- if "anomaly_detection" in try_models:
447
  progress("training anomaly_detection")
448
- malicious = sample(collection["phishing"], 10000) + sample(collection["malware"], 10000)
449
- results["anomaly_detection"] = train_anomaly(keep(benign), malicious, LEXICAL_FEATURES, settings)
450
 
451
  trained_at = datetime.utcnow().isoformat() + "Z"
452
  reports, accepted = {}, {}
453
  for name, result in results.items():
454
  report = result["report"]
455
  report.update({"name": name, "trained_at": trained_at, "settings": settings, "sources": sources,
456
- "feature_set": "url_features.py, scheme-free"})
457
  reports[name] = report
458
  if report["gate"]["passed"]:
459
  accepted[name] = result["model"]
 
1
  """
2
  Trains the CyberForge detection models from public, labelled data.
3
 
4
+ (Second version. The first took its benign and phishing URLs from one older
5
+ collection whose benign URLs are deep links from a crawl and whose phishing
6
+ URLs are mostly short. A dry run showed the phishing model had learned exactly
7
+ that, path length, and it scored bbc.co.uk/news at 0.96; the acceptance gate
8
+ rejected it. The benign and phishing examples now come from several sources
9
+ that overlap in shape, including the landing pages of popular sites.)
10
+
11
  This runs inside the Hugging Face Space (app.py starts it from a protected
12
  endpoint). It is never run on a developer machine.
13
 
 
54
 
55
  logger = logging.getLogger("cyberforge.trainer")
56
 
57
+ PHISHING_COLLECTION = "Mitake/PhishingURLsANDBenignURLs" # ~886k URLs: benign (0) / phishing (1), CC-BY-SA-4.0
58
+ URL_COLLECTION = "bgspaditya/byt-malicious-url-treatment" # ~640k URLs; used for its malware URLs and some benign ones
59
  PHISHING_REFERENCE = "pirocheto/phishing-url" # 11k balanced full URLs, CC-BY-4.0, with its own test split
60
+ POPULAR_SITES = "phl-ldm/tranco_top_million" # landing pages of top-million sites, CC-BY-4.0 (one small file of it)
61
  ATTACK_PAYLOADS = "shengqin/web-attacks" # SQL-injection and XSS payloads, and normal strings
62
 
63
  DEFAULTS = {
64
+ "benign_sample": 120000, # benign URLs from the phishing collection
65
+ "collection_benign_sample": 40000, # benign URLs from the older collection (deep links)
66
+ "popular_sample": 60000, # landing pages of popular sites
67
+ "phishing_sample": 120000,
68
  "malware_sample": 30000,
69
  "attack_benign_sample": 40000,
70
  "test_fraction": 0.2,
71
  "max_iter": 300,
72
  "learning_rate": 0.1,
73
  "max_leaf_nodes": 31,
74
+ "min_samples_leaf": 20,
75
+ "l2_regularization": 0.0,
76
  "anomaly_contamination": 0.02,
77
  "seed": 42,
78
  }
 
82
  # none of the well-known legitimate URLs below.
83
  GATE = {"min_auc": 0.90, "max_false_positive_rate": 0.05, "max_known_legitimate_flagged": 0}
84
  # The anomaly model has no labels to be scored against, so its gate is that it
85
+ # flags few held-out benign URLs and clearly more malicious ones. (The first
86
+ # version only asked for "more"; a model that flagged 1.8% of malicious URLs
87
+ # against 1.6% of benign ones passed, which is no separation at all.)
88
+ ANOMALY_GATE = {"max_benign_flagged": 0.05, "min_malicious_flagged": 0.10, "min_ratio": 3.0}
89
 
90
  # Well-known legitimate URLs, several of them long or full of parameters: the
91
  # kind the first models called critical. They are never used for training.
 
178
  return out
179
 
180
 
181
+ def load_phishing_collection(token: Optional[str]) -> Dict[str, List[str]]:
182
+ path = _download(PHISHING_COLLECTION, lambda name: name.endswith(".csv"), token)[0]
183
+ frame = pd.read_csv(path, usecols=["url", "label"])
184
+ return {"benign": clean_urls(frame.loc[frame["label"] == 0, "url"].tolist()),
185
+ "phishing": clean_urls(frame.loc[frame["label"] == 1, "url"].tolist())}
186
+
187
+
188
  def load_url_collection(token: Optional[str]) -> Dict[str, List[str]]:
189
  paths = _download(URL_COLLECTION, lambda name: name.startswith("data/train") and name.endswith(".parquet"), token)
190
  frame = pd.concat([pd.read_parquet(path, columns=["url", "type"]) for path in paths], ignore_index=True)
191
+ return {kind: clean_urls(frame.loc[frame["type"] == kind, "url"].tolist()) for kind in ("benign", "malware")}
192
+
193
+
194
+ def load_popular_sites(token: Optional[str]) -> List[str]:
195
+ """Landing pages of popular sites: the page a visitor actually ends up on,
196
+ and the site's bare address. Only the dataset's small consent file is read."""
197
+ path = _download(POPULAR_SITES, lambda name: name.endswith("cookie_warning_consents.parquet"), token)[0]
198
+ frame = pd.read_parquet(path)
199
+ urls: List[str] = []
200
+ for column in ("url", "crawled_url"):
201
+ if column in frame.columns:
202
+ urls += frame[column].tolist()
203
+ return clean_urls(urls)
204
 
205
 
206
  def load_phishing_reference(token: Optional[str]) -> Dict[str, Dict[str, List[str]]]:
 
233
  return out
234
 
235
 
236
+ def labelled(parts: Dict[str, List[str]]) -> List[tuple]:
237
+ """[(url, source), ...] from {source: [urls]}, each URL once."""
238
+ seen, rows = set(), []
239
+ for source, urls in parts.items():
240
+ for url in urls:
241
+ key = bare_url(url).lower()
242
+ if key and key not in seen:
243
+ seen.add(key)
244
+ rows.append((url, source))
245
+ return rows
246
+
247
+
248
  def compose_url(base: str, payload: str, rng: random.Random) -> str:
249
  """Put a payload where one arrives in practice: as a query value on an
250
  ordinary site, encoded or not, or (less often) in the path."""
 
304
  return [{"url": url, "score": round(float(score), 4)} for url, score in zip(urls, scores)]
305
 
306
 
307
+ def train_classifier(name: str, positives: Dict[str, List[str]], negatives: Dict[str, List[str]], names: List[str],
308
+ settings: Dict, extra_tests: Optional[Dict[str, Dict[str, List[str]]]] = None) -> Dict:
309
+ """Fit one classifier and measure it on sites it did not train on.
310
+ `positives` and `negatives` are {source: [urls]}; results are also broken
311
+ down by source, so one source cannot hide behind another."""
312
+ negative_rows, positive_rows = labelled(negatives), labelled(positives)
313
+ # a URL listed on both sides teaches nothing; drop it from both
314
+ clash = {bare_url(url).lower() for url, _ in negative_rows} & {bare_url(url).lower() for url, _ in positive_rows}
315
+ negative_rows = [row for row in negative_rows if bare_url(row[0]).lower() not in clash]
316
+ positive_rows = [row for row in positive_rows if bare_url(row[0]).lower() not in clash]
317
+
318
+ urls = [url for url, _ in negative_rows + positive_rows]
319
+ sources = np.array([source for _, source in negative_rows + positive_rows])
320
+ labels = np.array([0] * len(negative_rows) + [1] * len(positive_rows))
321
  groups = np.array([registered_domain(url) for url in urls])
322
  splitter = GroupShuffleSplit(n_splits=1, test_size=settings["test_fraction"], random_state=settings["seed"])
323
  train_index, test_index = next(splitter.split(urls, labels, groups))
 
325
  table = feature_table(urls)[:, columns(names)]
326
  model = HistGradientBoostingClassifier(
327
  max_iter=int(settings["max_iter"]), learning_rate=float(settings["learning_rate"]),
328
+ max_leaf_nodes=int(settings["max_leaf_nodes"]), min_samples_leaf=int(settings["min_samples_leaf"]),
329
+ l2_regularization=float(settings["l2_regularization"]), class_weight="balanced",
330
  early_stopping=True, validation_fraction=0.1, n_iter_no_change=15, random_state=settings["seed"],
331
  )
332
  started = time.time()
333
  model.fit(table[train_index], labels[train_index])
334
  seconds = round(time.time() - started, 1)
335
 
336
+ test_scores = model.predict_proba(table[test_index])[:, 1]
337
+ holdout = measure(labels[test_index], test_scores)
338
+ by_source = {}
339
+ for source in sorted(set(sources[test_index])):
340
+ mask = sources[test_index] == source
341
+ flagged = float(np.mean(test_scores[mask] >= 0.5))
342
+ positive = bool(labels[test_index][mask][0])
343
+ by_source[source] = {"urls": int(mask.sum()), "kind": "positive" if positive else "negative",
344
+ ("recall" if positive else "false_positive_rate"): round(flagged, 4)}
345
  report = {
346
  "model_type": "HistGradientBoostingClassifier",
347
  "feature_names": names,
 
349
  "training": {
350
  "rows": int(len(train_index)), "positives": int(labels[train_index].sum()),
351
  "sites": int(len(set(groups[train_index]))), "seconds": seconds, "iterations": int(model.n_iter_),
352
+ "rows_by_source": {source: int(np.sum(sources[train_index] == source)) for source in sorted(set(sources))},
353
+ "dropped_as_listed_on_both_sides": len(clash),
354
  },
355
+ "holdout": {**holdout, "sites": int(len(set(groups[test_index]))), "by_source": by_source,
356
  "note": "URLs from sites that were not in the training rows"},
357
  "other_datasets": {},
358
  }
 
435
  reasons = []
436
  if benign_flagged > ANOMALY_GATE["max_benign_flagged"]:
437
  reasons.append(f"it flags {benign_flagged} of held-out benign URLs, above {ANOMALY_GATE['max_benign_flagged']}")
438
+ if malicious_flagged < ANOMALY_GATE["min_malicious_flagged"] or malicious_flagged < ANOMALY_GATE["min_ratio"] * benign_flagged:
439
+ reasons.append(f"it flags {malicious_flagged} of malicious URLs against {benign_flagged} of benign ones, which is not a useful separation")
440
  report["gate"] = {"rules": ANOMALY_GATE, "passed": not reasons, "reasons": reasons}
441
  return {"model": model, "report": report}
442
 
 
455
  wanted = options.get("models") or ["phishing_detection", "malware_detection", "web_attack_detection", "anomaly_detection"]
456
  dry_run = bool(options.get("dry_run", False))
457
  rng = random.Random(settings["seed"])
458
+ # Features a run is asked to leave out, to measure whether a model depends
459
+ # on something it should not. The list is recorded with the model.
460
+ dropped = [name for name in (options.get("drop_features") or []) if isinstance(name, str)]
461
+ lexical = [name for name in LEXICAL_FEATURES if name not in dropped]
462
+ attack = [name for name in ATTACK_FEATURES if name not in dropped]
463
+ settings["drop_features"] = dropped
464
 
465
  progress("downloading data")
466
+ phishing = load_phishing_collection(token)
467
  collection = load_url_collection(token)
468
  reference = load_phishing_reference(token)
469
+ popular = load_popular_sites(token)
470
  payloads = load_payloads(token) if "web_attack_detection" in wanted else {"attack": [], "normal": []}
471
  sources = {
472
+ PHISHING_COLLECTION: {"revision": _revision(PHISHING_COLLECTION, token), "rows": {kind: len(urls) for kind, urls in phishing.items()}},
473
  URL_COLLECTION: {"revision": _revision(URL_COLLECTION, token), "rows": {kind: len(urls) for kind, urls in collection.items()}},
474
  PHISHING_REFERENCE: {"revision": _revision(PHISHING_REFERENCE, token),
475
  "rows": {split: {status: len(urls) for status, urls in parts.items()} for split, parts in reference.items()}},
476
+ POPULAR_SITES: {"revision": _revision(POPULAR_SITES, token), "rows": {"landing pages": len(popular)}},
477
  ATTACK_PAYLOADS: {"revision": _revision(ATTACK_PAYLOADS, token), "rows": {kind: len(texts) for kind, texts in payloads.items()}},
478
  }
479
 
 
482
  rng.shuffle(items)
483
  return items[: int(size)]
484
 
 
485
  # URLs of the reference test split must not be trained on.
486
  reference_test = {bare_url(url).lower() for part in reference["test"].values() for url in part}
487
  keep = lambda urls: [url for url in urls if bare_url(url).lower() not in reference_test] # noqa: E731
488
 
489
+ # Benign URLs of several shapes, so that no model can tell benign from
490
+ # hostile by how one list happens to be written: deep links, short
491
+ # addresses and bare domains, landing pages of popular sites.
492
+ benign = {
493
+ "phishing collection, benign": keep(sample(phishing["benign"], settings["benign_sample"])),
494
+ "older collection, benign": keep(sample(collection["benign"], settings["collection_benign_sample"])),
495
+ "popular sites": keep(sample(popular, settings["popular_sample"])),
496
+ "reference, legitimate": keep(reference["train"]["legitimate"]),
497
+ }
498
+ all_benign = [url for urls in benign.values() for url in urls]
499
+
500
  results: Dict[str, Dict] = {}
 
501
 
502
+ if "phishing_detection" in wanted:
503
  progress("training phishing_detection")
504
+ positives = {
505
+ "phishing collection, phishing": keep(sample(phishing["phishing"], settings["phishing_sample"])),
506
+ "reference, phishing": keep(reference["train"]["phishing"]),
507
+ }
508
  results["phishing_detection"] = train_classifier(
509
+ "phishing_detection", positives, benign, lexical, settings,
510
  extra_tests={PHISHING_REFERENCE + " (test split)": {"negatives": reference["test"]["legitimate"], "positives": reference["test"]["phishing"]}})
511
 
512
+ if "malware_detection" in wanted:
513
  progress("training malware_detection")
514
+ positives = {"older collection, malware": sample(collection["malware"], settings["malware_sample"])}
515
+ results["malware_detection"] = train_classifier("malware_detection", positives, benign, lexical, settings)
 
516
 
517
+ if "web_attack_detection" in wanted:
518
  progress("training web_attack_detection")
519
  if not payloads["attack"]:
520
  raise TrainingError("No attack payloads were loaded")
521
+ bases = sample(all_benign, settings["attack_benign_sample"])
522
+ with_query = [url for url in phishing["benign"] + collection["benign"] if "?" in url]
523
+ ordinary = {
524
+ **benign,
525
+ "benign URLs with a query": keep(sample(with_query, settings["attack_benign_sample"])),
526
+ "normal strings placed like payloads": [compose_url(rng.choice(bases), text, rng) for text in payloads["normal"]],
527
+ }
528
  # each payload is carried on two different sites, so the model cannot learn a site
529
+ attacks = {"payloads placed on benign sites": [compose_url(rng.choice(bases), text, rng) for text in payloads["attack"] for _ in range(2)]}
530
+ results["web_attack_detection"] = train_classifier("web_attack_detection", attacks, ordinary, attack, settings)
 
531
  results["web_attack_detection"]["report"]["training"]["note"] = (
532
  "attack rows are real SQL-injection and XSS payloads placed as a query value or path segment on benign sites")
533
 
534
+ if "anomaly_detection" in wanted:
535
  progress("training anomaly_detection")
536
+ malicious = sample(phishing["phishing"], 10000) + sample(collection["malware"], 10000)
537
+ results["anomaly_detection"] = train_anomaly(clean_urls(all_benign), malicious, lexical, settings)
538
 
539
  trained_at = datetime.utcnow().isoformat() + "Z"
540
  reports, accepted = {}, {}
541
  for name, result in results.items():
542
  report = result["report"]
543
  report.update({"name": name, "trained_at": trained_at, "settings": settings, "sources": sources,
544
+ "feature_set": "url_features.py, scheme-free and without a leading www."})
545
  reports[name] = report
546
  if report["gate"]["passed"]:
547
  accepted[name] = result["model"]
url_features.py CHANGED
@@ -110,8 +110,12 @@ def split_url(url: str) -> Dict:
110
 
111
 
112
  def bare_url(url: str) -> str:
113
- """The URL as the models see it: no scheme, no surrounding space."""
114
- return SCHEME.sub("", str(url).strip(), count=1)
 
 
 
 
115
 
116
 
117
  def _entropy(text: str) -> float:
 
110
 
111
 
112
  def bare_url(url: str) -> str:
113
+ """The URL as the models see it: no scheme, no leading "www.", no
114
+ surrounding space. Like the scheme, "www." says how a list was written, not
115
+ what the site is: one collection writes every benign URL without it and
116
+ another with it."""
117
+ bare = SCHEME.sub("", str(url).strip(), count=1)
118
+ return bare[4:] if bare[:4].lower() == "www." and len(bare) > 4 else bare
119
 
120
 
121
  def _entropy(text: str) -> float: