urlformat-audit 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- urlformat_audit-0.1.0/LICENSE +21 -0
- urlformat_audit-0.1.0/PKG-INFO +137 -0
- urlformat_audit-0.1.0/README.md +116 -0
- urlformat_audit-0.1.0/pyproject.toml +34 -0
- urlformat_audit-0.1.0/src/urlformat_audit/__init__.py +6 -0
- urlformat_audit-0.1.0/src/urlformat_audit/audit.py +150 -0
- urlformat_audit-0.1.0/src/urlformat_audit/cli.py +103 -0
- urlformat_audit-0.1.0/src/urlformat_audit/factorial.py +41 -0
- urlformat_audit-0.1.0/src/urlformat_audit/io.py +49 -0
- urlformat_audit-0.1.0/src/urlformat_audit/parse.py +88 -0
- urlformat_audit-0.1.0/tests/test_basic.py +52 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 [Author names]
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: urlformat-audit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Detect class-conditional URL formatting (shortcut risk) in labelled phishing/malicious URL datasets
|
|
5
|
+
Project-URL: Homepage, https://github.com/MIT-Istiak/Learning-the-Template-Not-the-Threat-Format-Shortcuts-in-Cross-Dataset-Phishing-URL-Detection
|
|
6
|
+
Author: Md Istiak Tanvir - eruddro@gmail.com
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Keywords: dataset audit,machine learning,phishing,security,shortcut learning,url
|
|
10
|
+
Classifier: Intended Audience :: Science/Research
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
14
|
+
Classifier: Topic :: Security
|
|
15
|
+
Requires-Python: >=3.9
|
|
16
|
+
Requires-Dist: numpy>=1.21
|
|
17
|
+
Requires-Dist: pandas>=1.3
|
|
18
|
+
Provides-Extra: test
|
|
19
|
+
Requires-Dist: pytest>=7; extra == 'test'
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# urlformat-audit
|
|
23
|
+
|
|
24
|
+
Check a labelled phishing / malicious URL dataset for **format shortcuts**: URL formatting
|
|
25
|
+
(scheme, `www.`, trailing slash, path) that differs between the classes and lets a model
|
|
26
|
+
predict the label without learning anything about the threat.
|
|
27
|
+
|
|
28
|
+
Example: in some datasets every legitimate URL is written `https://www.<host>` and phishing
|
|
29
|
+
URLs are not. A model trained on such data learns "legitimate = this template". It scores
|
|
30
|
+
well in-dataset and fails on any other dataset.
|
|
31
|
+
|
|
32
|
+
# Author Details
|
|
33
|
+
|
|
34
|
+
Md Istiak Tanvir - eruddro@gmail.com
|
|
35
|
+
Msc in CSE - MIST
|
|
36
|
+
Dhaka, Bangladesh
|
|
37
|
+
|
|
38
|
+
`urlformat-audit` reports:
|
|
39
|
+
|
|
40
|
+
| Output | Meaning |
|
|
41
|
+
|---|---|
|
|
42
|
+
| **Class table** | % of URLs per class with each format property: `https`, `http`, no scheme, `www.`, trailing slash, path, query, port, IP host, uppercase, median length |
|
|
43
|
+
| **Format cells** | the 24 cells scheme (https/http/none) × `www.` × trailing slash × path, with class counts and purity |
|
|
44
|
+
| **Format-only rule** | predicts the majority label of each cell. Its MCC says how much of the label format alone explains. MCC ≥ 0.5 is flagged. |
|
|
45
|
+
| **Template rule** | "legitimate iff the URL matches `^https://www\.[a-z0-9.-]+$`" |
|
|
46
|
+
| **Trivial baseline** | predict the positive class everywhere |
|
|
47
|
+
| **NMI** | I(cell; label) / H(label): share of label entropy explained by the format cell (0 = none, 1 = all) |
|
|
48
|
+
| **Flags** | properties whose prevalence differs by ≥ 30 percentage points between the classes, and large single-class cells |
|
|
49
|
+
| **compare** | fit the format-only rule on dataset A, evaluate it on dataset B. This shows whether format cues transfer, fail, or invert. |
|
|
50
|
+
| **factorial** | writes the 24 format variants of every URL, for testing a trained model's sensitivity to formatting |
|
|
51
|
+
|
|
52
|
+
Requires Python ≥ 3.9 with numpy and pandas. Nothing else.
|
|
53
|
+
|
|
54
|
+
## Install
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
pip install urlformat-audit
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Command line
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
# one file with a label column (the positive class is the phishing/malicious label)
|
|
64
|
+
urlformat-audit audit data.csv --positive phishing
|
|
65
|
+
urlformat-audit audit data.csv --url-col URL --label-col label --positive 1 --json report.json
|
|
66
|
+
|
|
67
|
+
# one file per class, without a label column (CSV/TSV with a url column, or a JSON list of URLs)
|
|
68
|
+
urlformat-audit audit legit.json phish.json --label 0 1 --name "my dataset"
|
|
69
|
+
|
|
70
|
+
# does a format-only rule fitted on A transfer to B?
|
|
71
|
+
urlformat-audit compare A.csv B.csv --positive 1
|
|
72
|
+
|
|
73
|
+
# 24 format variants of every URL (columns: id = row of the input, cell, url_variant)
|
|
74
|
+
urlformat-audit factorial test.csv -o test_factorial.csv
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Example output (dataset of Sahingoz et al., 2019; legitimate = 0, phishing = 1), abridged:
|
|
78
|
+
|
|
79
|
+
```
|
|
80
|
+
Format-only rule (majority per cell): accuracy=0.756 balanced_accuracy=0.756 f1_positive=0.771 mcc=0.515 tpr=0.812 fpr=0.300
|
|
81
|
+
Template rule (legit iff ^https://www.<host>$): accuracy=0.505 ... mcc=0.000
|
|
82
|
+
Normalized mutual information I(cell;label)/H(label) = 0.266
|
|
83
|
+
|
|
84
|
+
Shortcut flags:
|
|
85
|
+
- scheme=https: 36.8% of negatives vs 1.8% of positives
|
|
86
|
+
- scheme=http: 63.2% of negatives vs 97.7% of positives
|
|
87
|
+
- format-only rule (4 cues, 24 cells) reaches MCC 0.515 / balanced accuracy 0.756 on this dataset
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Python API
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
from urlformat_audit import audit, compare, factorial_variants, format_features, FormatRule
|
|
94
|
+
|
|
95
|
+
r = audit(urls, labels, name="my data") # labels: 1 = phishing/malicious, 0 = legitimate
|
|
96
|
+
print(r.format_rule["mcc"], r.nmi_cell_label)
|
|
97
|
+
print(r.class_table) # pandas DataFrame
|
|
98
|
+
print(r.cell_table.head())
|
|
99
|
+
for f in r.flags: print(f)
|
|
100
|
+
r.to_dict() # JSON-serialisable
|
|
101
|
+
|
|
102
|
+
res = compare(train_urls, train_labels, test_urls, test_labels)
|
|
103
|
+
res["format_rule_transfer"]["mcc"], res["share_target_in_unseen_cells"]
|
|
104
|
+
|
|
105
|
+
factorial_variants("http://example.org/login/") # [(cell_name, url), ...] x 24
|
|
106
|
+
format_features(urls) # per-URL format columns + cell
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## How to read the result
|
|
110
|
+
|
|
111
|
+
- **Format-only rule MCC near 1** (or a NMI near 1): format alone separates the classes, so any
|
|
112
|
+
model that sees the raw URL can learn the shortcut. Cross-dataset results on this data need
|
|
113
|
+
format-controlled checks: factorial edits, host-only input, or a scheme-neutral representation.
|
|
114
|
+
- **High in-dataset MCC but low or negative `compare` MCC**: the format cue is specific to that dataset.
|
|
115
|
+
Expect transfer failures.
|
|
116
|
+
- **Low MCC**: format is not a major shortcut. This says nothing about other artifacts (domains, TLDs,
|
|
117
|
+
collection time).
|
|
118
|
+
|
|
119
|
+
The audit is descriptive. It cannot tell you whether a format difference is a collection
|
|
120
|
+
artifact or a real property of phishing URLs; it only tells you that a model can use it.
|
|
121
|
+
|
|
122
|
+
## Development
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
pip install -e ".[test]"
|
|
126
|
+
pytest
|
|
127
|
+
python -m build # sdist + wheel in dist/
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## Citation
|
|
131
|
+
|
|
132
|
+
If you use this tool, please cite the accompanying paper:
|
|
133
|
+
*Learning the Template, Not the Threat: Format Shortcuts in Cross-Dataset Phishing URL Detection* (authors and venue to be added).
|
|
134
|
+
|
|
135
|
+
## License
|
|
136
|
+
|
|
137
|
+
MIT
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# urlformat-audit
|
|
2
|
+
|
|
3
|
+
Check a labelled phishing / malicious URL dataset for **format shortcuts**: URL formatting
|
|
4
|
+
(scheme, `www.`, trailing slash, path) that differs between the classes and lets a model
|
|
5
|
+
predict the label without learning anything about the threat.
|
|
6
|
+
|
|
7
|
+
Example: in some datasets every legitimate URL is written `https://www.<host>` and phishing
|
|
8
|
+
URLs are not. A model trained on such data learns "legitimate = this template". It scores
|
|
9
|
+
well in-dataset and fails on any other dataset.
|
|
10
|
+
|
|
11
|
+
# Author Details
|
|
12
|
+
|
|
13
|
+
Md Istiak Tanvir - eruddro@gmail.com
|
|
14
|
+
Msc in CSE - MIST
|
|
15
|
+
Dhaka, Bangladesh
|
|
16
|
+
|
|
17
|
+
`urlformat-audit` reports:
|
|
18
|
+
|
|
19
|
+
| Output | Meaning |
|
|
20
|
+
|---|---|
|
|
21
|
+
| **Class table** | % of URLs per class with each format property: `https`, `http`, no scheme, `www.`, trailing slash, path, query, port, IP host, uppercase, median length |
|
|
22
|
+
| **Format cells** | the 24 cells scheme (https/http/none) × `www.` × trailing slash × path, with class counts and purity |
|
|
23
|
+
| **Format-only rule** | predicts the majority label of each cell. Its MCC says how much of the label format alone explains. MCC ≥ 0.5 is flagged. |
|
|
24
|
+
| **Template rule** | "legitimate iff the URL matches `^https://www\.[a-z0-9.-]+$`" |
|
|
25
|
+
| **Trivial baseline** | predict the positive class everywhere |
|
|
26
|
+
| **NMI** | I(cell; label) / H(label): share of label entropy explained by the format cell (0 = none, 1 = all) |
|
|
27
|
+
| **Flags** | properties whose prevalence differs by ≥ 30 percentage points between the classes, and large single-class cells |
|
|
28
|
+
| **compare** | fit the format-only rule on dataset A, evaluate it on dataset B. This shows whether format cues transfer, fail, or invert. |
|
|
29
|
+
| **factorial** | writes the 24 format variants of every URL, for testing a trained model's sensitivity to formatting |
|
|
30
|
+
|
|
31
|
+
Requires Python ≥ 3.9 with numpy and pandas. Nothing else.
|
|
32
|
+
|
|
33
|
+
## Install
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install urlformat-audit
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Command line
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
# one file with a label column (the positive class is the phishing/malicious label)
|
|
43
|
+
urlformat-audit audit data.csv --positive phishing
|
|
44
|
+
urlformat-audit audit data.csv --url-col URL --label-col label --positive 1 --json report.json
|
|
45
|
+
|
|
46
|
+
# one file per class, without a label column (CSV/TSV with a url column, or a JSON list of URLs)
|
|
47
|
+
urlformat-audit audit legit.json phish.json --label 0 1 --name "my dataset"
|
|
48
|
+
|
|
49
|
+
# does a format-only rule fitted on A transfer to B?
|
|
50
|
+
urlformat-audit compare A.csv B.csv --positive 1
|
|
51
|
+
|
|
52
|
+
# 24 format variants of every URL (columns: id = row of the input, cell, url_variant)
|
|
53
|
+
urlformat-audit factorial test.csv -o test_factorial.csv
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Example output (dataset of Sahingoz et al., 2019; legitimate = 0, phishing = 1), abridged:
|
|
57
|
+
|
|
58
|
+
```
|
|
59
|
+
Format-only rule (majority per cell): accuracy=0.756 balanced_accuracy=0.756 f1_positive=0.771 mcc=0.515 tpr=0.812 fpr=0.300
|
|
60
|
+
Template rule (legit iff ^https://www.<host>$): accuracy=0.505 ... mcc=0.000
|
|
61
|
+
Normalized mutual information I(cell;label)/H(label) = 0.266
|
|
62
|
+
|
|
63
|
+
Shortcut flags:
|
|
64
|
+
- scheme=https: 36.8% of negatives vs 1.8% of positives
|
|
65
|
+
- scheme=http: 63.2% of negatives vs 97.7% of positives
|
|
66
|
+
- format-only rule (4 cues, 24 cells) reaches MCC 0.515 / balanced accuracy 0.756 on this dataset
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Python API
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
from urlformat_audit import audit, compare, factorial_variants, format_features, FormatRule
|
|
73
|
+
|
|
74
|
+
r = audit(urls, labels, name="my data") # labels: 1 = phishing/malicious, 0 = legitimate
|
|
75
|
+
print(r.format_rule["mcc"], r.nmi_cell_label)
|
|
76
|
+
print(r.class_table) # pandas DataFrame
|
|
77
|
+
print(r.cell_table.head())
|
|
78
|
+
for f in r.flags: print(f)
|
|
79
|
+
r.to_dict() # JSON-serialisable
|
|
80
|
+
|
|
81
|
+
res = compare(train_urls, train_labels, test_urls, test_labels)
|
|
82
|
+
res["format_rule_transfer"]["mcc"], res["share_target_in_unseen_cells"]
|
|
83
|
+
|
|
84
|
+
factorial_variants("http://example.org/login/") # [(cell_name, url), ...] x 24
|
|
85
|
+
format_features(urls) # per-URL format columns + cell
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## How to read the result
|
|
89
|
+
|
|
90
|
+
- **Format-only rule MCC near 1** (or a NMI near 1): format alone separates the classes, so any
|
|
91
|
+
model that sees the raw URL can learn the shortcut. Cross-dataset results on this data need
|
|
92
|
+
format-controlled checks: factorial edits, host-only input, or a scheme-neutral representation.
|
|
93
|
+
- **High in-dataset MCC but low or negative `compare` MCC**: the format cue is specific to that dataset.
|
|
94
|
+
Expect transfer failures.
|
|
95
|
+
- **Low MCC**: format is not a major shortcut. This says nothing about other artifacts (domains, TLDs,
|
|
96
|
+
collection time).
|
|
97
|
+
|
|
98
|
+
The audit is descriptive. It cannot tell you whether a format difference is a collection
|
|
99
|
+
artifact or a real property of phishing URLs; it only tells you that a model can use it.
|
|
100
|
+
|
|
101
|
+
## Development
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
pip install -e ".[test]"
|
|
105
|
+
pytest
|
|
106
|
+
python -m build # sdist + wheel in dist/
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## Citation
|
|
110
|
+
|
|
111
|
+
If you use this tool, please cite the accompanying paper:
|
|
112
|
+
*Learning the Template, Not the Threat: Format Shortcuts in Cross-Dataset Phishing URL Detection* (authors and venue to be added).
|
|
113
|
+
|
|
114
|
+
## License
|
|
115
|
+
|
|
116
|
+
MIT
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.18"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "urlformat-audit"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Detect class-conditional URL formatting (shortcut risk) in labelled phishing/malicious URL datasets"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
requires-python = ">=3.9"
|
|
13
|
+
authors = [{ name = "Md Istiak Tanvir - eruddro@gmail.com" }]
|
|
14
|
+
keywords = ["phishing", "url", "dataset audit", "shortcut learning", "machine learning", "security"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Operating System :: OS Independent",
|
|
18
|
+
"Intended Audience :: Science/Research",
|
|
19
|
+
"Topic :: Security",
|
|
20
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
21
|
+
]
|
|
22
|
+
dependencies = ["numpy>=1.21", "pandas>=1.3"]
|
|
23
|
+
|
|
24
|
+
[project.optional-dependencies]
|
|
25
|
+
test = ["pytest>=7"]
|
|
26
|
+
|
|
27
|
+
[project.scripts]
|
|
28
|
+
urlformat-audit = "urlformat_audit.cli:main"
|
|
29
|
+
|
|
30
|
+
[project.urls]
|
|
31
|
+
Homepage = "https://github.com/MIT-Istiak/Learning-the-Template-Not-the-Threat-Format-Shortcuts-in-Cross-Dataset-Phishing-URL-Detection"
|
|
32
|
+
|
|
33
|
+
[tool.hatch.build.targets.wheel]
|
|
34
|
+
packages = ["src/urlformat_audit"]
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""urlformat-audit: detect class-conditional URL formatting (shortcut risk) in labelled URL datasets."""
|
|
2
|
+
__version__ = "0.1.0"
|
|
3
|
+
|
|
4
|
+
from .parse import url_parts, format_features, cell_of # noqa: F401
|
|
5
|
+
from .audit import audit, compare, binary_metrics, FormatRule, TEMPLATE_REGEX # noqa: F401
|
|
6
|
+
from .factorial import factorial_variants, variant, expand, CELLS # noqa: F401
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Class-conditional format audit and format-only baselines for labelled URL datasets."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import math
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Dict, Iterable, Optional, Sequence
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
import pandas as pd
|
|
10
|
+
|
|
11
|
+
from .parse import format_features
|
|
12
|
+
|
|
13
|
+
TEMPLATE_REGEX = r"^https://www\.[a-z0-9.-]+$"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# ----------------------------------------------------------------------------- metrics
|
|
17
|
+
def binary_metrics(y_true: Sequence[int], y_pred: Sequence[int]) -> Dict[str, float]:
|
|
18
|
+
"""Accuracy, balanced accuracy, F1 of the positive class (1) and Matthews correlation."""
|
|
19
|
+
y = np.asarray(y_true).astype(int)
|
|
20
|
+
p = np.asarray(y_pred).astype(int)
|
|
21
|
+
tp = float(np.sum((p == 1) & (y == 1))); tn = float(np.sum((p == 0) & (y == 0)))
|
|
22
|
+
fp = float(np.sum((p == 1) & (y == 0))); fn = float(np.sum((p == 0) & (y == 1)))
|
|
23
|
+
den = math.sqrt((tp + fp) * (tp + fn) * (tn + fp) * (tn + fn))
|
|
24
|
+
tpr = tp / (tp + fn) if tp + fn else float("nan")
|
|
25
|
+
tnr = tn / (tn + fp) if tn + fp else float("nan")
|
|
26
|
+
return {
|
|
27
|
+
"accuracy": (tp + tn) / max(len(y), 1),
|
|
28
|
+
"balanced_accuracy": (tpr + tnr) / 2,
|
|
29
|
+
"f1_positive": 2 * tp / (2 * tp + fp + fn) if (2 * tp + fp + fn) else 0.0,
|
|
30
|
+
"mcc": (tp * tn - fp * fn) / den if den > 0 else 0.0,
|
|
31
|
+
"tpr": tpr, "fpr": 1 - tnr,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def normalized_mutual_information(a: Sequence, b: Sequence) -> float:
|
|
36
|
+
"""I(A;B) / H(B): share of the label entropy explained by the format cell (0 = none, 1 = all)."""
|
|
37
|
+
t = pd.crosstab(pd.Series(a, name="a"), pd.Series(b, name="b")).to_numpy(dtype=float)
|
|
38
|
+
n = t.sum()
|
|
39
|
+
if n == 0:
|
|
40
|
+
return float("nan")
|
|
41
|
+
pab = t / n; pa = pab.sum(1, keepdims=True); pb = pab.sum(0, keepdims=True)
|
|
42
|
+
nz = pab > 0
|
|
43
|
+
mi = float(np.sum(pab[nz] * np.log(pab[nz] / (pa @ pb)[nz])))
|
|
44
|
+
hb = float(-np.sum(pb[pb > 0] * np.log(pb[pb > 0])))
|
|
45
|
+
return mi / hb if hb > 0 else float("nan")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# ----------------------------------------------------------------------------- format-only rule
|
|
49
|
+
@dataclass
|
|
50
|
+
class FormatRule:
|
|
51
|
+
"""Predicts the majority label of each format cell seen in training (fallback: overall majority)."""
|
|
52
|
+
cell_label: Dict[str, int] = field(default_factory=dict)
|
|
53
|
+
default: int = 1
|
|
54
|
+
|
|
55
|
+
@classmethod
|
|
56
|
+
def fit(cls, urls: Iterable[str], labels: Sequence[int]) -> "FormatRule":
|
|
57
|
+
f = format_features(urls)
|
|
58
|
+
y = np.asarray(labels).astype(int)
|
|
59
|
+
d = pd.DataFrame({"cell": f["cell"].values, "y": y})
|
|
60
|
+
maj = d.groupby("cell")["y"].mean()
|
|
61
|
+
return cls({c: int(v >= 0.5) for c, v in maj.items()}, int(y.mean() >= 0.5))
|
|
62
|
+
|
|
63
|
+
def predict(self, urls: Iterable[str]) -> np.ndarray:
|
|
64
|
+
f = format_features(urls)
|
|
65
|
+
return np.array([self.cell_label.get(c, self.default) for c in f["cell"]], dtype=int)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# ----------------------------------------------------------------------------- audit
|
|
69
|
+
@dataclass
|
|
70
|
+
class AuditResult:
|
|
71
|
+
name: str
|
|
72
|
+
n: int
|
|
73
|
+
class_table: pd.DataFrame # % of URLs per format property, per class
|
|
74
|
+
cell_table: pd.DataFrame # rows per format cell: counts per class and purity
|
|
75
|
+
format_rule: Dict[str, float] # in-sample metrics of the majority-per-cell rule
|
|
76
|
+
template_rule: Dict[str, float] # metrics of 'legitimate iff URL matches TEMPLATE_REGEX'
|
|
77
|
+
trivial: Dict[str, float] # metrics of predicting the positive class everywhere
|
|
78
|
+
nmi_cell_label: float
|
|
79
|
+
flags: list
|
|
80
|
+
|
|
81
|
+
def to_dict(self) -> dict:
|
|
82
|
+
return {"name": self.name, "n": self.n, "format_rule": self.format_rule, "template_rule": self.template_rule,
|
|
83
|
+
"trivial": self.trivial, "nmi_cell_label": self.nmi_cell_label, "flags": self.flags,
|
|
84
|
+
"class_table": self.class_table.reset_index().to_dict(orient="records"),
|
|
85
|
+
"cell_table": self.cell_table.reset_index().to_dict(orient="records")}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def audit(urls: Sequence[str], labels: Sequence[int], name: str = "dataset", flag_gap: float = 30.0) -> AuditResult:
|
|
89
|
+
"""Audit a labelled URL dataset. ``labels``: 1 = positive (e.g. phishing), 0 = negative (legitimate).
|
|
90
|
+
|
|
91
|
+
flag_gap: a format property is flagged when its prevalence differs between the classes by at least this many
|
|
92
|
+
percentage points.
|
|
93
|
+
"""
|
|
94
|
+
urls = [str(u) for u in urls]
|
|
95
|
+
y = np.asarray(labels).astype(int)
|
|
96
|
+
if set(np.unique(y)) - {0, 1}:
|
|
97
|
+
raise ValueError("labels must be 0/1 (1 = positive class)")
|
|
98
|
+
f = format_features(urls)
|
|
99
|
+
f["label"] = y
|
|
100
|
+
props = ["scheme=https", "scheme=http", "scheme=none", "scheme=other", "www", "trailing_slash", "path", "query", "port", "ip_host", "uppercase"]
|
|
101
|
+
rows = {}
|
|
102
|
+
for cls_, nm in [(0, "negative (0)"), (1, "positive (1)")]:
|
|
103
|
+
g = f[f.label == cls_]
|
|
104
|
+
r = {"n": len(g)}
|
|
105
|
+
for p in props:
|
|
106
|
+
if p.startswith("scheme="):
|
|
107
|
+
r[p] = 100 * float((g["scheme"] == p.split("=")[1]).mean()) if len(g) else float("nan")
|
|
108
|
+
else:
|
|
109
|
+
r[p] = 100 * float(g[p].mean()) if len(g) else float("nan")
|
|
110
|
+
r["median_length"] = float(g["length"].median()) if len(g) else float("nan")
|
|
111
|
+
rows[nm] = r
|
|
112
|
+
class_table = pd.DataFrame(rows).T
|
|
113
|
+
|
|
114
|
+
ct = pd.crosstab(f["cell"], f["label"]).reindex(columns=[0, 1], fill_value=0)
|
|
115
|
+
ct.columns = ["n_negative", "n_positive"]
|
|
116
|
+
ct["n"] = ct.sum(axis=1)
|
|
117
|
+
ct["share_positive"] = ct["n_positive"] / ct["n"]
|
|
118
|
+
ct["purity"] = np.maximum(ct["share_positive"], 1 - ct["share_positive"])
|
|
119
|
+
ct = ct.sort_values("n", ascending=False)
|
|
120
|
+
|
|
121
|
+
rule = FormatRule.fit(urls, y)
|
|
122
|
+
fmt = binary_metrics(y, rule.predict(urls))
|
|
123
|
+
tpl_pred = (~pd.Series(urls).str.strip().str.match(TEMPLATE_REGEX)).astype(int).values
|
|
124
|
+
tpl = binary_metrics(y, tpl_pred)
|
|
125
|
+
triv = binary_metrics(y, np.ones(len(y), int))
|
|
126
|
+
nmi = normalized_mutual_information(f["cell"], y)
|
|
127
|
+
|
|
128
|
+
flags = []
|
|
129
|
+
for p in props:
|
|
130
|
+
gap = abs(class_table.loc["positive (1)", p] - class_table.loc["negative (0)", p])
|
|
131
|
+
if gap >= flag_gap:
|
|
132
|
+
flags.append(f"{p}: {class_table.loc['negative (0)', p]:.1f}% of negatives vs {class_table.loc['positive (1)', p]:.1f}% of positives")
|
|
133
|
+
if fmt["mcc"] >= 0.5:
|
|
134
|
+
flags.append(f"format-only rule (4 cues, 24 cells) reaches MCC {fmt['mcc']:.3f} / balanced accuracy {fmt['balanced_accuracy']:.3f} on this dataset")
|
|
135
|
+
pure = ct[(ct["purity"] >= 0.99) & (ct["n"] >= 100)]
|
|
136
|
+
if len(pure):
|
|
137
|
+
flags.append(f"{len(pure)} format cell(s) with >=100 URLs are >=99% one class, covering {100 * pure['n'].sum() / len(y):.1f}% of the data")
|
|
138
|
+
return AuditResult(name, len(y), class_table, ct, fmt, tpl, triv, nmi, flags)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def compare(train_urls: Sequence[str], train_labels: Sequence[int], test_urls: Sequence[str], test_labels: Sequence[int]) -> Dict[str, object]:
|
|
142
|
+
"""Transfer of the format-only rule from one dataset to another, and cell coverage of the target."""
|
|
143
|
+
rule = FormatRule.fit(train_urls, train_labels)
|
|
144
|
+
y = np.asarray(test_labels).astype(int)
|
|
145
|
+
res = binary_metrics(y, rule.predict(test_urls))
|
|
146
|
+
tr_cells = set(rule.cell_label)
|
|
147
|
+
te = format_features(test_urls)
|
|
148
|
+
return {"format_rule_transfer": res, "trivial_target": binary_metrics(y, np.ones(len(y), int)),
|
|
149
|
+
"share_target_in_unseen_cells": float((~te["cell"].isin(tr_cells)).mean()),
|
|
150
|
+
"train_cell_majority": rule.cell_label}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Command-line interface: urlformat-audit {audit,compare,factorial}."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
import pandas as pd
|
|
10
|
+
|
|
11
|
+
from . import __version__
|
|
12
|
+
from .audit import audit, compare
|
|
13
|
+
from .factorial import expand
|
|
14
|
+
from .io import binarize, read_table, resolve_columns
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _load(path, url_col, label_col, positive, extra=None):
|
|
18
|
+
df = read_table(path)
|
|
19
|
+
u, l = resolve_columns(df, url_col, label_col)
|
|
20
|
+
urls = df[u].astype(str).tolist()
|
|
21
|
+
if l is None:
|
|
22
|
+
if extra is None:
|
|
23
|
+
raise ValueError(f"{path}: no label column; pass --label-col (or --label for single-class files)")
|
|
24
|
+
y = np.full(len(urls), int(extra))
|
|
25
|
+
else:
|
|
26
|
+
y = binarize(df[l], positive)
|
|
27
|
+
keep = y >= 0
|
|
28
|
+
if (~keep).any():
|
|
29
|
+
print(f"note: {path}: {(~keep).sum()} row(s) without a label were skipped", file=sys.stderr)
|
|
30
|
+
return [x for x, k in zip(urls, keep) if k], y[keep]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _fmt(d):
|
|
34
|
+
return " ".join(f"{k}={v:.3f}" for k, v in d.items() if isinstance(v, float))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def main(argv=None):
|
|
38
|
+
ap = argparse.ArgumentParser(prog="urlformat-audit", description="Audit class-conditional URL formatting (shortcut risk) in labelled URL datasets.")
|
|
39
|
+
ap.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
40
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
41
|
+
|
|
42
|
+
def common(p):
|
|
43
|
+
p.add_argument("--url-col"); p.add_argument("--label-col")
|
|
44
|
+
p.add_argument("--positive", help="label value of the positive class (e.g. phishing, bad, 1)")
|
|
45
|
+
|
|
46
|
+
a = sub.add_parser("audit", help="format audit of one labelled dataset")
|
|
47
|
+
a.add_argument("path", nargs="+", help="one CSV/TSV/JSON file, or one file per class with --label")
|
|
48
|
+
a.add_argument("--label", nargs="+", type=int, help="class (0/1) of each file when files have no label column")
|
|
49
|
+
a.add_argument("--name", default=None); a.add_argument("--json", help="write the full result as JSON")
|
|
50
|
+
a.add_argument("--cells", type=int, default=10, help="number of format cells to print")
|
|
51
|
+
common(a)
|
|
52
|
+
|
|
53
|
+
c = sub.add_parser("compare", help="transfer of the format-only rule from TRAIN to TEST")
|
|
54
|
+
c.add_argument("train"); c.add_argument("test"); common(c)
|
|
55
|
+
|
|
56
|
+
f = sub.add_parser("factorial", help="write the 24 factorial format variants of every URL")
|
|
57
|
+
f.add_argument("path"); f.add_argument("-o", "--out", required=True); f.add_argument("--url-col")
|
|
58
|
+
|
|
59
|
+
args = ap.parse_args(argv)
|
|
60
|
+
if args.cmd == "audit":
|
|
61
|
+
if len(args.path) > 1 or args.label:
|
|
62
|
+
labs = args.label or []
|
|
63
|
+
if len(labs) != len(args.path):
|
|
64
|
+
ap.error("give one --label per file")
|
|
65
|
+
parts = [_load(p, args.url_col, args.label_col, args.positive, extra=l) for p, l in zip(args.path, labs)]
|
|
66
|
+
urls = sum([p[0] for p in parts], []); y = np.concatenate([p[1] for p in parts])
|
|
67
|
+
else:
|
|
68
|
+
urls, y = _load(args.path[0], args.url_col, args.label_col, args.positive)
|
|
69
|
+
r = audit(urls, y, name=args.name or args.path[0])
|
|
70
|
+
pd.set_option("display.width", 200)
|
|
71
|
+
print(f"# {r.name}: {r.n:,} URLs ({100 * y.mean():.1f}% positive)\n")
|
|
72
|
+
print("Format properties (% of URLs per class):")
|
|
73
|
+
print(r.class_table.round(1).to_string(), "\n")
|
|
74
|
+
print(f"Format cells (scheme|www|slash|path), top {args.cells}:")
|
|
75
|
+
print(r.cell_table.head(args.cells).round(3).to_string(), "\n")
|
|
76
|
+
print("Format-only rule (majority per cell): ", _fmt(r.format_rule))
|
|
77
|
+
print("Template rule (legit iff ^https://www.<host>$):", _fmt(r.template_rule))
|
|
78
|
+
print("Trivial (all positive): ", _fmt(r.trivial))
|
|
79
|
+
print(f"Normalized mutual information I(cell;label)/H(label) = {r.nmi_cell_label:.3f}\n")
|
|
80
|
+
print("Shortcut flags:" if r.flags else "No shortcut flags.")
|
|
81
|
+
for fl in r.flags:
|
|
82
|
+
print(" -", fl)
|
|
83
|
+
if args.json:
|
|
84
|
+
with open(args.json, "w") as fh:
|
|
85
|
+
json.dump(r.to_dict(), fh, indent=1, default=float)
|
|
86
|
+
elif args.cmd == "compare":
|
|
87
|
+
tu, ty = _load(args.train, args.url_col, args.label_col, args.positive)
|
|
88
|
+
su, sy = _load(args.test, args.url_col, args.label_col, args.positive)
|
|
89
|
+
r = compare(tu, ty, su, sy)
|
|
90
|
+
print("Format-only rule fitted on TRAIN, applied to TEST:", _fmt(r["format_rule_transfer"]))
|
|
91
|
+
print("Trivial (all positive) on TEST: ", _fmt(r["trivial_target"]))
|
|
92
|
+
print(f"Share of TEST URLs in format cells unseen in TRAIN: {100 * r['share_target_in_unseen_cells']:.1f}%")
|
|
93
|
+
elif args.cmd == "factorial":
|
|
94
|
+
df = read_table(args.path)
|
|
95
|
+
u = args.url_col or next(c for c in ("url", "URL") if c in df.columns)
|
|
96
|
+
out = expand(df[u].astype(str).tolist())
|
|
97
|
+
out.to_csv(args.out, index=False)
|
|
98
|
+
print(f"wrote {len(out):,} rows ({len(df):,} URLs x 24 cells) to {args.out}")
|
|
99
|
+
return 0
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
if __name__ == "__main__":
|
|
103
|
+
sys.exit(main())
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Factorial format edits: rebuild a URL in every combination of scheme x www x trailing slash x path."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from itertools import product
|
|
5
|
+
from typing import Iterable, List, Tuple
|
|
6
|
+
|
|
7
|
+
import pandas as pd
|
|
8
|
+
|
|
9
|
+
from .parse import url_parts
|
|
10
|
+
|
|
11
|
+
FACTOR_SCHEMES = ("https", "http", "none")
|
|
12
|
+
CELLS: List[Tuple[str, bool, bool, bool]] = [(s, w, sl, p) for s, w, sl, p in product(FACTOR_SCHEMES, (True, False), (True, False), (True, False))]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def cell_name(scheme: str, www: bool, slash: bool, path: bool) -> str:
|
|
16
|
+
return "|".join([scheme, "www" if www else "nowww", "slash" if slash else "noslash", "path" if path else "nopath"])
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def variant(url: str, scheme: str, www: bool, slash: bool, path: bool) -> str:
|
|
20
|
+
"""Rebuild ``url`` with the given format; the path is everything after the host.
|
|
21
|
+
|
|
22
|
+
>>> variant("http://x.org/a/b/", "https", True, False, False)
|
|
23
|
+
'https://www.x.org'
|
|
24
|
+
"""
|
|
25
|
+
p = url_parts(url)
|
|
26
|
+
head = "" if scheme == "none" else f"{scheme}://"
|
|
27
|
+
body = ("www." if www else "") + p.host + (p.tail if path else "")
|
|
28
|
+
return head + body + ("/" if slash else "")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def factorial_variants(url: str) -> List[Tuple[str, str]]:
|
|
32
|
+
"""All 24 (cell name, edited URL) pairs for one URL."""
|
|
33
|
+
return [(cell_name(*c), variant(url, *c)) for c in CELLS]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def expand(urls: Iterable[str], ids: Iterable = None) -> pd.DataFrame:
|
|
37
|
+
"""Long table (id, cell, url_variant) for scoring with any model."""
|
|
38
|
+
urls = list(urls)
|
|
39
|
+
ids = list(ids) if ids is not None else list(range(len(urls)))
|
|
40
|
+
rows = [(i, c, v) for i, u in zip(ids, urls) for c, v in factorial_variants(u)]
|
|
41
|
+
return pd.DataFrame(rows, columns=["id", "cell", "url_variant"])
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Reading labelled URL datasets from CSV/JSON with flexible column and label handling."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
from typing import Optional, Tuple
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
import pandas as pd
|
|
9
|
+
|
|
10
|
+
URL_COLS = ("url", "URL", "Url", "uri")
|
|
11
|
+
LABEL_COLS = ("label", "Label", "class", "Class", "ClassLabel", "type", "status", "result")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def read_table(path: str) -> pd.DataFrame:
|
|
15
|
+
if path.endswith(".json"):
|
|
16
|
+
with open(path) as fh:
|
|
17
|
+
data = json.load(fh)
|
|
18
|
+
return pd.DataFrame({"url": data}) if isinstance(data, list) else pd.DataFrame(data)
|
|
19
|
+
sep = "\t" if path.endswith((".tsv", ".txt")) else ","
|
|
20
|
+
return pd.read_csv(path, sep=sep, dtype=str, keep_default_na=False, encoding="utf-8-sig")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def resolve_columns(df: pd.DataFrame, url_col: Optional[str], label_col: Optional[str]) -> Tuple[str, Optional[str]]:
|
|
24
|
+
u = url_col or next((c for c in URL_COLS if c in df.columns), None)
|
|
25
|
+
if u is None:
|
|
26
|
+
raise ValueError(f"no URL column found; pass --url-col (columns: {list(df.columns)})")
|
|
27
|
+
l = label_col or next((c for c in LABEL_COLS if c in df.columns), None)
|
|
28
|
+
return u, l
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def binarize(values: pd.Series, positive: Optional[str]) -> np.ndarray:
|
|
32
|
+
"""Map raw labels to 0/1. ``positive`` names the positive (e.g. phishing) value; empty labels -> -1."""
|
|
33
|
+
v = values.astype(str).str.strip()
|
|
34
|
+
nonempty = sorted(set(v) - {""})
|
|
35
|
+
if positive is None:
|
|
36
|
+
if set(nonempty) <= {"0", "1", "0.0", "1.0"}:
|
|
37
|
+
positive = "1"
|
|
38
|
+
else:
|
|
39
|
+
raise ValueError(f"cannot infer the positive label from {nonempty[:10]}; pass --positive")
|
|
40
|
+
|
|
41
|
+
def norm(x: str) -> str:
|
|
42
|
+
x = x.lower()
|
|
43
|
+
try:
|
|
44
|
+
return str(float(x))
|
|
45
|
+
except ValueError:
|
|
46
|
+
return x
|
|
47
|
+
|
|
48
|
+
p = norm(positive)
|
|
49
|
+
return np.array([-1 if x == "" else int(norm(x) == p) for x in v], dtype=int)
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Parsing of the format properties of URL strings (no network access, no external parsers)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Iterable, Optional
|
|
7
|
+
|
|
8
|
+
import pandas as pd
|
|
9
|
+
|
|
10
|
+
_SCHEME = re.compile(r"^([A-Za-z][A-Za-z0-9+.-]*)://")
|
|
11
|
+
_IPV4 = re.compile(r"^\d{1,3}(?:\.\d{1,3}){3}$")
|
|
12
|
+
|
|
13
|
+
SCHEMES = ("https", "http", "none") # 'other' schemes (ftp, ...) are mapped to 'other' in features
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class URLParts:
|
|
18
|
+
scheme: Optional[str] # lower-cased scheme or None
|
|
19
|
+
host: str # host[:port] without a leading 'www.' (case kept)
|
|
20
|
+
www: bool # host started with 'www.'
|
|
21
|
+
tail: str # everything after the host (path, query, fragment) without a trailing '/'
|
|
22
|
+
trailing_slash: bool # the string ended with '/' after something non-empty
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def has_path(self) -> bool:
|
|
26
|
+
return len(self.tail) > 0
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _split_host(rest: str) -> tuple[str, str]:
|
|
30
|
+
cuts = [i for i in (rest.find("/"), rest.find("?"), rest.find("#")) if i >= 0]
|
|
31
|
+
cut = min(cuts) if cuts else len(rest)
|
|
32
|
+
return rest[:cut], rest[cut:]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def url_parts(url: str) -> URLParts:
|
|
36
|
+
"""Split a URL string into the parts used by the audit.
|
|
37
|
+
|
|
38
|
+
>>> url_parts("http://www.Example.org/a/b/")
|
|
39
|
+
URLParts(scheme='http', host='Example.org', www=True, tail='/a/b', trailing_slash=True)
|
|
40
|
+
"""
|
|
41
|
+
u = str(url).strip()
|
|
42
|
+
m = _SCHEME.match(u)
|
|
43
|
+
scheme = m.group(1).lower() if m else None
|
|
44
|
+
rest = u[m.end():] if m else u
|
|
45
|
+
host, tail = _split_host(rest)
|
|
46
|
+
www = host.lower().startswith("www.")
|
|
47
|
+
if www:
|
|
48
|
+
host = host[4:]
|
|
49
|
+
slash = (host + tail).endswith("/") and len((host + tail).rstrip("/")) > 0
|
|
50
|
+
if slash:
|
|
51
|
+
tail = tail.rstrip("/")
|
|
52
|
+
return URLParts(scheme, host, www, tail, slash)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def scheme_group(scheme: Optional[str]) -> str:
|
|
56
|
+
if scheme is None:
|
|
57
|
+
return "none"
|
|
58
|
+
return scheme if scheme in ("http", "https") else "other"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def cell_of(url: str) -> str:
|
|
62
|
+
"""Format cell 'scheme|www|slash|path' used by the audit and the factorial edits."""
|
|
63
|
+
p = url_parts(url)
|
|
64
|
+
return "|".join([scheme_group(p.scheme), "www" if p.www else "nowww",
|
|
65
|
+
"slash" if p.trailing_slash else "noslash", "path" if p.has_path else "nopath"])
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def format_features(urls: Iterable[str]) -> pd.DataFrame:
|
|
69
|
+
"""One row per URL with boolean/numeric format features and its format cell."""
|
|
70
|
+
rows = []
|
|
71
|
+
for u in urls:
|
|
72
|
+
s = str(u).strip()
|
|
73
|
+
p = url_parts(s)
|
|
74
|
+
hostname = p.host.split(":")[0]
|
|
75
|
+
rows.append({
|
|
76
|
+
"scheme": scheme_group(p.scheme),
|
|
77
|
+
"www": p.www,
|
|
78
|
+
"trailing_slash": p.trailing_slash,
|
|
79
|
+
"path": p.has_path,
|
|
80
|
+
"query": "?" in p.tail,
|
|
81
|
+
"port": bool(re.search(r":\d+$", p.host)),
|
|
82
|
+
"ip_host": bool(_IPV4.match(hostname)) or hostname.startswith("["),
|
|
83
|
+
"uppercase": any(c.isupper() for c in s),
|
|
84
|
+
"length": len(s),
|
|
85
|
+
"cell": "|".join([scheme_group(p.scheme), "www" if p.www else "nowww",
|
|
86
|
+
"slash" if p.trailing_slash else "noslash", "path" if p.has_path else "nopath"]),
|
|
87
|
+
})
|
|
88
|
+
return pd.DataFrame(rows)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
|
|
3
|
+
from urlformat_audit import audit, compare, factorial_variants, url_parts, variant, FormatRule, format_features
|
|
4
|
+
from urlformat_audit.cli import main
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def test_url_parts():
|
|
8
|
+
p = url_parts("http://www.Example.org/a/b/")
|
|
9
|
+
assert (p.scheme, p.host, p.www, p.tail, p.trailing_slash) == ("http", "Example.org", True, "/a/b", True)
|
|
10
|
+
p = url_parts("example.org")
|
|
11
|
+
assert p.scheme is None and not p.has_path and not p.trailing_slash
|
|
12
|
+
assert url_parts("https://a.com/").trailing_slash and not url_parts("https://a.com/").has_path
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_factorial():
|
|
16
|
+
v = factorial_variants("http://x.org/a/")
|
|
17
|
+
assert len(v) == 24 and len({c for c, _ in v}) == 24
|
|
18
|
+
assert variant("http://x.org/a/b/", "https", True, False, False) == "https://www.x.org"
|
|
19
|
+
assert variant("x.org", "none", False, True, True) == "x.org/"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_audit_detects_template_shortcut():
|
|
23
|
+
legit = [f"https://www.site{i}.com" for i in range(300)]
|
|
24
|
+
phish = [f"http://bad{i}.xyz/login/" for i in range(300)]
|
|
25
|
+
r = audit(legit + phish, [0] * 300 + [1] * 300)
|
|
26
|
+
assert r.format_rule["mcc"] == 1.0 and r.template_rule["mcc"] == 1.0
|
|
27
|
+
assert any("format-only rule" in f for f in r.flags)
|
|
28
|
+
assert abs(r.nmi_cell_label - 1.0) < 1e-9
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_compare_and_rule():
|
|
32
|
+
tr = ["https://www.a.com", "http://b.com/x/"] * 50
|
|
33
|
+
ytr = [0, 1] * 50
|
|
34
|
+
te = ["http://www.c.edu/", "https://www.d.org"]
|
|
35
|
+
res = compare(tr, ytr, te, [0, 0])
|
|
36
|
+
assert 0 <= res["share_target_in_unseen_cells"] <= 1
|
|
37
|
+
assert list(FormatRule.fit(tr, ytr).predict(["https://www.z.com"])) == [0]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_features_columns():
|
|
41
|
+
f = format_features(["https://1.2.3.4:8080/x?q=1"])
|
|
42
|
+
assert bool(f.ip_host[0]) and bool(f.port[0]) and bool(f["query"][0])
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_cli(tmp_path, capsys):
|
|
46
|
+
p = tmp_path / "d.csv"
|
|
47
|
+
p.write_text("url,label\nhttps://www.a.com,good\nhttp://b.com/x/,bad\nhttps://www.c.com,good\nhttp://d.com/,bad\n")
|
|
48
|
+
assert main(["audit", str(p), "--positive", "bad"]) == 0
|
|
49
|
+
assert "Format-only rule" in capsys.readouterr().out
|
|
50
|
+
out = tmp_path / "f.csv"
|
|
51
|
+
assert main(["factorial", str(p), "-o", str(out)]) == 0
|
|
52
|
+
assert out.read_text().count("\n") == 4 * 24 + 1
|