Spaces:
Running
Running
Update the CyberForge ML service
Browse files- trainer.py +126 -38
- 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 |
-
|
|
|
|
| 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 |
-
"
|
|
|
|
|
|
|
| 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
|
| 73 |
-
|
|
|
|
|
|
|
| 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", "
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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],
|
| 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 |
-
|
| 265 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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"]),
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 <
|
| 373 |
-
reasons.append("it
|
| 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
|
| 418 |
progress("training phishing_detection")
|
| 419 |
-
positives =
|
| 420 |
-
|
|
|
|
|
|
|
| 421 |
results["phishing_detection"] = train_classifier(
|
| 422 |
-
"phishing_detection",
|
| 423 |
extra_tests={PHISHING_REFERENCE + " (test split)": {"negatives": reference["test"]["legitimate"], "positives": reference["test"]["phishing"]}})
|
| 424 |
|
| 425 |
-
if "malware_detection" in
|
| 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
|
| 432 |
progress("training web_attack_detection")
|
| 433 |
if not payloads["attack"]:
|
| 434 |
raise TrainingError("No attack payloads were loaded")
|
| 435 |
-
bases = sample(
|
| 436 |
-
with_query = [url for url in collection["benign"] if "?" in url]
|
| 437 |
-
ordinary =
|
| 438 |
-
|
|
|
|
|
|
|
|
|
|
| 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
|
| 447 |
progress("training anomaly_detection")
|
| 448 |
-
malicious = sample(
|
| 449 |
-
results["anomaly_detection"] = train_anomaly(
|
| 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
|
| 114 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|