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.
@@ -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