import json
from datetime import UTC, datetime
from ipaddress import ip_network
from pathlib import Path
import pandas as pd
from openbotrisk.ip_enrichment import (
ASdbTable,
IPinfoTable,
IntervalSet,
ListedIPs,
PeeringDBTable,
ProviderTable,
RouteTable,
artifact_provenance,
enrich,
matching_manifest_record,
)
REPO = Path.cwd().parent if Path.cwd().name == "notebooks" else Path.cwd()
RAW = REPO / "data/raw/ip-enrichment/2026-10-25"
IP_DIR = REPO / "data/IP"
IPTOASN_PATH = IP_DIR / "ip2asn-combined.tsv.gz"
IPINFO_PATH = IP_DIR / "ipinfo_lite.parquet"
ASDB_PATH = IP_DIR / "2026-03_categorized_ases.csv"
PEERINGDB_PATH = REPO / "notebooks/peeringdb-net.json"
SAMPLE_SEED = 20261026Public IP enrichment: worked example
Companion notebook for the site page Public IP enrichment: what the flags actually mean. The implementation lives in openbotrisk.ip_enrichment; this notebook loads existing immutable raw snapshots, measures coverage, and demonstrates the stable output schema.
True means matched, False means checked and not matched, and NA means the source could not be applied. This notebook never downloads data: execution and site rendering use the named local snapshots only.
In [1]:
In [2]:
# Execution is intentionally local-only. Missing inputs remain unavailable.
manifest_path = RAW / "manifest.jsonl"
manifest = (
[json.loads(line) for line in manifest_path.read_text().splitlines()]
if manifest_path.exists()
else []
)
def peeringdb_generation_time(path):
if not path.exists():
return None
generated = json.loads(path.read_text()).get("meta", {}).get("generated")
return (
datetime.fromtimestamp(generated, UTC).isoformat(timespec="seconds")
if generated is not None
else None
)
source_specs = [
{"key": "route", "source": "IPtoASN", "path": IPTOASN_PATH, "url": "https://iptoasn.com/data/ip2asn-combined.tsv.gz", "schema": "start, end, ASN, country code, description", "prior_use": "route origin/name/country", "source_updated_at": None},
{"key": "peeringdb", "source": "PeeringDB", "path": PEERINGDB_PATH, "url": "https://www.peeringdb.com/api/net", "schema": "API network rows: ASN, name, info_types, website, scope, updated", "prior_use": "exploratory inspection only", "source_updated_at": peeringdb_generation_time(PEERINGDB_PATH)},
{"key": "ipinfo", "source": "IPinfo Lite", "path": IPINFO_PATH, "url": "https://ipinfo.io/developers/ipinfo-lite-database", "schema": "network, country/code, continent/code, ASN, AS name/domain", "prior_use": "exploratory inspection only", "source_updated_at": None},
{"key": "providers", "source": "rezmoss aggregate", "path": RAW / "rezmoss_all_providers.json", "url": "https://raw.githubusercontent.com/rezmoss/cloud-provider-ip-addresses/main/all_providers/all_providers.json", "schema": "CIDR, IP version, provider, service, region, last_updated", "prior_use": "provider name only", "source_updated_at": None},
{"key": "asdb", "source": "Stanford ASdb", "path": ASDB_PATH, "url": "https://asdb.stanford.edu/about", "schema": "ASN plus numbered layer-1/layer-2 category pairs", "prior_use": "exploratory merge only", "source_updated_at": None},
{"key": "vpn", "source": "X4BNet VPN IPv4", "path": RAW / "x4bnet_vpn_ipv4.txt", "url": "https://raw.githubusercontent.com/X4BNet/lists_vpn/main/output/vpn/ipv4.txt", "schema": "newline-delimited IPv4 CIDRs", "prior_use": "VPN range flag", "source_updated_at": None},
{"key": "datacentre", "source": "X4BNet datacentre IPv4", "path": RAW / "x4bnet_datacenter_ipv4.txt", "url": "https://raw.githubusercontent.com/X4BNet/lists_vpn/main/output/datacenter/ipv4.txt", "schema": "newline-delimited IPv4 CIDRs", "prior_use": "datacentre range flag", "source_updated_at": None},
{"key": "bad_asn", "source": "bad-asn-list", "path": RAW / "bad_asn_list.csv", "url": "https://raw.githubusercontent.com/brianhama/bad-asn-list/master/bad-asn-list.csv", "schema": "ASN and maintainer label CSV", "prior_use": "hosting-ASN heuristic", "source_updated_at": None},
{"key": "tor", "source": "Tor bulk exit list", "path": RAW / "tor_bulk_exit.txt", "url": "https://check.torproject.org/torbulkexitlist", "schema": "newline-delimited exit IP addresses", "prior_use": "dated exact-address flag", "source_updated_at": None},
]
def source_record(spec):
path = spec["path"]
record = {**spec, "path": str(path.relative_to(REPO)), "local_path": path}
if not path.exists():
return {**record, "status": "unavailable", "reason": "local artifact not found", "retrieved_at": None, "file_mtime": None, "sha256": None}
item = artifact_provenance(path.name, spec["url"], path, source_updated_at=spec["source_updated_at"])
evidence = matching_manifest_record(manifest, path.name, item.sha256)
return {
**record,
"status": "available",
"reason": None,
"retrieved_at": evidence.get("retrieved_at") if evidence else None,
"source_updated_at": spec["source_updated_at"] or (evidence.get("source_updated_at") if evidence else None),
"file_mtime": item.file_mtime,
"sha256": item.sha256,
}
source_records = [source_record(spec) for spec in source_specs]
records_by_key = {record["key"]: record for record in source_records}
def available_path(key):
record = records_by_key[key]
return record["local_path"] if record["status"] == "available" else NoneIn [3]:
vpn_path = available_path("vpn")
datacentre_path = available_path("datacentre")
providers_path = available_path("providers")
bad_asn_path = available_path("bad_asn")
tor_path = available_path("tor")
route_path = available_path("route")
peeringdb_path = available_path("peeringdb")
ipinfo_path = available_path("ipinfo")
asdb_path = available_path("asdb")
vpn = IntervalSet.from_file(vpn_path) if vpn_path is not None else None
datacentre = IntervalSet.from_file(datacentre_path) if datacentre_path is not None else None
providers = pd.read_json(providers_path, convert_dates=False) if providers_path is not None else None
cloud = ProviderTable(providers) if providers is not None else None
bad_asn = (
pd.read_csv(bad_asn_path).drop_duplicates("ASN")
if bad_asn_path is not None
else None
)
bad_asns = set(bad_asn["ASN"]) if bad_asn is not None else None
tor = ListedIPs.from_file(tor_path) if tor_path is not None else None
route = RouteTable.from_tsv(route_path) if route_path is not None else None
peeringdb = PeeringDBTable.from_json(peeringdb_path) if peeringdb_path is not None else None
ipinfo = IPinfoTable.from_parquet(ipinfo_path) if ipinfo_path is not None else None
asdb = ASdbTable.from_csv(asdb_path) if asdb_path is not None else None
loaded_sources = {
"route": route, "peeringdb": peeringdb, "ipinfo": ipinfo, "providers": cloud,
"asdb": asdb, "vpn": vpn, "datacentre": datacentre, "bad_asn": bad_asns, "tor": tor,
}Local source inventory
This inventory is generated from the same provenance records used to load the sources. Integrated means the source participates in enrich() in this run; it does not imply complete coverage. Retrieval, source publication/generation, and filesystem modification times are separate. A manifest retrieval time appears only when its recorded SHA-256 matches the current local bytes. Unknown dates remain unknown.
In [4]:
inventory_rows = []
for record in source_records:
loaded = loaded_sources[record["key"]] is not None
inventory_rows.append({
"source": record["source"],
"path": record["path"],
"schema": record["schema"],
"status": "integrated" if loaded else "unavailable",
"reason": None if loaded else record["reason"] or "loader unavailable",
"retrieved_at": record["retrieved_at"],
"source_updated_at": record["source_updated_at"],
"file_mtime": record["file_mtime"],
"prior_use": record["prior_use"],
})
source_inventory = pd.DataFrame(inventory_rows)
source_inventory| source | path | schema | status | reason | retrieved_at | source_updated_at | file_mtime | prior_use | |
|---|---|---|---|---|---|---|---|---|---|
| 0 | IPtoASN | data/IP/ip2asn-combined.tsv.gz | start, end, ASN, country code, description | integrated | None | None | None | 2026-10-24T12:38:06+00:00 | route origin/name/country |
| 1 | PeeringDB | notebooks/peeringdb-net.json | API network rows: ASN, name, info_types, websi... | integrated | None | None | 2026-10-24T16:21:34+00:00 | 2026-10-24T19:41:12+00:00 | exploratory inspection only |
| 2 | IPinfo Lite | data/IP/ipinfo_lite.parquet | network, country/code, continent/code, ASN, AS... | integrated | None | None | None | 2026-10-24T13:02:50+00:00 | exploratory inspection only |
| 3 | rezmoss aggregate | data/raw/ip-enrichment/2026-10-25/rezmoss_all_... | CIDR, IP version, provider, service, region, l... | integrated | None | 2026-10-25T10:54:51+00:00 | None | 2026-10-25T10:54:51+00:00 | provider name only |
| 4 | Stanford ASdb | data/IP/2026-03_categorized_ases.csv | ASN plus numbered layer-1/layer-2 category pairs | integrated | None | None | None | 2026-10-24T12:39:51+00:00 | exploratory merge only |
| 5 | X4BNet VPN IPv4 | data/raw/ip-enrichment/2026-10-25/x4bnet_vpn_i... | newline-delimited IPv4 CIDRs | integrated | None | 2026-10-25T10:54:43+00:00 | None | 2026-10-25T10:54:43+00:00 | VPN range flag |
| 6 | X4BNet datacentre IPv4 | data/raw/ip-enrichment/2026-10-25/x4bnet_datac... | newline-delimited IPv4 CIDRs | integrated | None | 2026-10-25T10:54:43+00:00 | None | 2026-10-25T10:54:43+00:00 | datacentre range flag |
| 7 | bad-asn-list | data/raw/ip-enrichment/2026-10-25/bad_asn_list... | ASN and maintainer label CSV | integrated | None | 2026-10-25T10:54:51+00:00 | None | 2026-10-25T10:54:51+00:00 | hosting-ASN heuristic |
| 8 | Tor bulk exit list | data/raw/ip-enrichment/2026-10-25/tor_bulk_exi... | newline-delimited exit IP addresses | integrated | None | 2026-10-25T10:54:51+00:00 | None | 2026-10-25T10:54:51+00:00 | dated exact-address flag |
Coverage and dependence
In [5]:
cloud_any = None
if providers is not None:
provider_v4 = providers.loc[providers["ip_version"] == "IPv4", "cidr"]
cloud_any = IntervalSet(ip_network(cidr, strict=False) for cidr in provider_v4)
coverage_rows = []
for name, value, reason in [
("X4BNet VPN", vpn, "X4BNet VPN source unavailable"),
("X4BNet datacentre", datacentre, "X4BNet datacentre source unavailable"),
("rezmoss provider union (IPv4)", cloud_any, "rezmoss provider source unavailable"),
]:
if value is None:
coverage_rows.append({"source": name, "networks": pd.NA, "addresses": pd.NA, "pct_of_ipv4": pd.NA, "status": "unavailable", "reason": reason})
else:
coverage_rows.append({"source": name, "networks": len(value), "addresses": value.addresses, "pct_of_ipv4": round(value.addresses / 2**32 * 100, 2), "status": "available", "reason": None})
coverage = pd.DataFrame(coverage_rows).set_index("source")
coverage| networks | addresses | pct_of_ipv4 | status | reason | |
|---|---|---|---|---|---|
| source | |||||
| X4BNet VPN | 10931 | 3170072 | 0.07 | available | None |
| X4BNet datacentre | 44584 | 395597872 | 9.21 | available | None |
| rezmoss provider union (IPv4) | 12842 | 255279702 | 5.94 | available | None |
In [6]:
def overlap_diagnostic(name, left, right, left_name, right_name):
missing = [label for value, label in ((left, left_name), (right, right_name)) if value is None]
if missing:
return {"comparison": name, "overlap_addresses": pd.NA, "denominator_addresses": pd.NA, "overlap_share": pd.NA, "status": "unavailable", "reason": f"missing {', '.join(missing)}"}
if left.addresses == 0:
return {"comparison": name, "overlap_addresses": pd.NA, "denominator_addresses": 0, "overlap_share": pd.NA, "status": "unavailable", "reason": "zero-address denominator"}
count = left.overlap_addresses(right)
return {"comparison": name, "overlap_addresses": count, "denominator_addresses": left.addresses, "overlap_share": count / left.addresses, "status": "available", "reason": None}
overlap = pd.DataFrame([
overlap_diagnostic("provider union also in X4BNet datacentre", cloud_any, datacentre, "rezmoss provider source", "X4BNet datacentre source"),
overlap_diagnostic("X4BNet VPN also in X4BNet datacentre", vpn, datacentre, "X4BNet VPN source", "X4BNet datacentre source"),
]).set_index("comparison")
overlap| overlap_addresses | denominator_addresses | overlap_share | status | reason | |
|---|---|---|---|---|---|
| comparison | |||||
| provider union also in X4BNet datacentre | 234191767 | 255279702 | 0.917393 | available | None |
| X4BNet VPN also in X4BNet datacentre | 3145854 | 3170072 | 0.992360 | available | None |
Enrich some addresses
The real snapshot examples include Janet/Jisc (AS786), whose PeeringDB record reports Educational/Research, Andrews & Arnold (AS20712), whose record reports Cable/DSL/ISP, and Amazon.com (AS16509), whose record reports Enterprise. These are network-level, self-reported classifications. Educational context does not show how many users share an IP, and Enterprise is not a reliable shared-office indicator—as Amazon’s classification illustrates. None establishes that a particular user is a student, employee, residential subscriber, or behind NAT. The examples also include IPv6, IPv4-mapped IPv6, malformed input, and null input so the distinction between False and NA is visible.
In [7]:
demo = [
"45.88.80.1", # Route AS786; PeeringDB reports Educational/Research
"1.44.96.1", # Route AS16509; PeeringDB reports Enterprise
"52.95.110.1", # AWS range with detailed provider metadata
"8.8.8.8", # Google resolver; infrastructure is not an abuse verdict
"81.2.69.142", # AS20712; PeeringDB reports Cable/DSL/ISP
"2606:4700:4700::1111", # Real IPv6; IPinfo/provider support, route table does not
"2001:db8::1", # Documentation IPv6; no real lookup claim
"::ffff:34.82.1.5", # Deliberately normalized to IPv4
"1.2.3.4:443", # Port attached -> unparseable -> NA
"not an ip",
None,
]In [8]:
out = enrich(
demo,
route=route,
cloud=cloud,
peeringdb=peeringdb,
ipinfo=ipinfo,
asdb=asdb,
tor=tor,
vpn=vpn,
datacentre=datacentre,
bad_asns=bad_asns,
)
out[[
"ip_input", "route_asn", "ipinfo_asn", "route_ipinfo_asn_agree",
"peeringdb_name", "peeringdb_info_types",
"education_network_reported", "enterprise_network_reported",
"cable_dsl_isp_network_reported", "cloud_matches",
]]| ip_input | route_asn | ipinfo_asn | route_ipinfo_asn_agree | peeringdb_name | peeringdb_info_types | education_network_reported | enterprise_network_reported | cable_dsl_isp_network_reported | cloud_matches | |
|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 45.88.80.1 | 786 | 786 | True | Janet | (Educational/Research,) | True | False | False | () |
| 1 | 1.44.96.1 | 16509 | 16509 | True | Amazon.com | (Enterprise,) | False | True | False | () |
| 2 | 52.95.110.1 | 16509 | 16509 | True | Amazon.com | (Enterprise,) | False | True | False | (aws,) |
| 3 | 8.8.8.8 | 15169 | 15169 | True | Google LLC | (Content,) | False | False | False | (googleservices,) |
| 4 | 81.2.69.142 | 20712 | 20712 | True | Andrews & Arnold Ltd | (Cable/DSL/ISP,) | False | False | True | () |
| 5 | 2606:4700:4700::1111 | <NA> | 13335 | <NA> | <NA> | <NA> | <NA> | <NA> | <NA> | (cloudflare,) |
| 6 | 2001:db8::1 | <NA> | <NA> | <NA> | <NA> | <NA> | <NA> | <NA> | <NA> | () |
| 7 | ::ffff:34.82.1.5 | 396982 | 396982 | True | Google Cloud | () | False | False | False | (googlecloud,) |
| 8 | 1.2.3.4:443 | <NA> | <NA> | <NA> | <NA> | <NA> | <NA> | <NA> | <NA> | <NA> |
| 9 | not an ip | <NA> | <NA> | <NA> | <NA> | <NA> | <NA> | <NA> | <NA> | <NA> |
| 10 | None | <NA> | <NA> | <NA> | <NA> | <NA> | <NA> | <NA> | <NA> | <NA> |
cloud_matches remains a tuple of provider names for compatibility. cloud_match_details keeps CIDR, provider, service, region and source update together for every matching row, with exact duplicates removed and overlaps retained. Provider region describes the published infrastructure range, not necessarily the user’s location. No relay status is inferred from a generic cloud match. NA means the source could not be applied.
In [9]:
out[[
"ip_input", "ipinfo_country", "ipinfo_continent", "ipinfo_as_name",
"ipinfo_as_domain", "cloud_match_details", "asdb_category_pairs",
]]| ip_input | ipinfo_country | ipinfo_continent | ipinfo_as_name | ipinfo_as_domain | cloud_match_details | asdb_category_pairs | |
|---|---|---|---|---|---|---|---|
| 0 | 45.88.80.1 | United Kingdom | Europe | Jisc Services Limited | jisc.ac.uk | () | ({'layer1': 'Education and Research', 'layer2'... |
| 1 | 1.44.96.1 | Australia | Oceania | Amazon.com, Inc. | amazon.com | () | ({'layer1': 'Computer and Information Technolo... |
| 2 | 52.95.110.1 | United States | North America | Amazon.com, Inc. | amazon.com | ({'cidr': '52.95.110.0/24', 'provider': 'aws',... | ({'layer1': 'Computer and Information Technolo... |
| 3 | 8.8.8.8 | United States | North America | Google LLC | google.com | ({'cidr': '8.8.8.0/24', 'provider': 'googleser... | ({'layer1': 'Service', 'layer2': None}, {'laye... |
| 4 | 81.2.69.142 | United Kingdom | Europe | Andrews & Arnold Ltd | aa.net.uk | () | ({'layer1': 'Computer and Information Technolo... |
| 5 | 2606:4700:4700::1111 | United States | North America | Cloudflare, Inc. | cloudflare.com | ({'cidr': '2606:4700::/32', 'provider': 'cloud... | <NA> |
| 6 | 2001:db8::1 | <NA> | <NA> | <NA> | <NA> | () | <NA> |
| 7 | ::ffff:34.82.1.5 | United States | North America | Google LLC | google.com | ({'cidr': '34.82.0.0/15', 'provider': 'googlec... | ({'layer1': 'Service', 'layer2': None}, {'laye... |
| 8 | 1.2.3.4:443 | <NA> | <NA> | <NA> | <NA> | <NA> | <NA> |
| 9 | not an ip | <NA> | <NA> | <NA> | <NA> | <NA> | <NA> |
| 10 | None | <NA> | <NA> | <NA> | <NA> | <NA> | <NA> |
In [10]:
# Report this share next to any flag rate.
out.drop(columns=["ip_input"]).isna().mean().rename("NA share").round(2)ip_normalized 0.27
ip_version 0.27
route_asn 0.45
asn_name 0.45
asn_country 0.45
cloud_matches 0.27
cloud_match_details 0.27
ipinfo_country 0.36
ipinfo_continent 0.36
ipinfo_asn 0.36
ipinfo_as_name 0.36
ipinfo_as_domain 0.36
route_ipinfo_asn_agree 0.45
peeringdb_name 0.45
peeringdb_info_types 0.45
peeringdb_website 0.45
peeringdb_info_scope 0.45
peeringdb_updated 0.45
education_network_reported 0.45
enterprise_network_reported 0.45
cable_dsl_isp_network_reported 0.45
asdb_category_pairs 0.45
asdb_layer1_categories 0.45
asdb_layer2_categories 0.45
tor_exit_listed 0.45
vpn_listed 0.45
datacentre_listed 0.45
hosting_asn_listed 0.45
Name: NA share, dtype: float64
Variable dictionary
| Field | Meaning | Source | Missing-value semantics | Main limitation |
|---|---|---|---|---|
ip_input |
Original submitted value | Event input | Preserved null input | May not be a parseable address |
ip_normalized, ip_version |
Canonical address and family | Parser | NA when parsing fails |
IPv4-mapped IPv6 is deliberately normalized to IPv4 |
route_asn, asn_name, asn_country |
Origin ASN and route-table descriptions | IPtoASN snapshot | NA for invalid, IPv6, ASN 0, no route, or absent source |
Route origin is not end-user identity; asn_country is not IP geolocation |
ipinfo_country, ipinfo_continent |
IP range geolocation labels | IPinfo Lite | NA for no matching row or absent source |
Approximate IP-range geography, not user location or ASN country |
ipinfo_asn, ipinfo_as_name, ipinfo_as_domain |
IPinfo’s ASN assignment and AS metadata | IPinfo Lite | NA when the row or field is missing |
Kept separate from route origin |
route_ipinfo_asn_agree |
Whether normalized route and IPinfo ASNs agree | Derived comparison | NA unless both ASNs exist |
Disagreement identifies source difference, not which source is correct |
peeringdb_name, peeringdb_website, peeringdb_info_scope, peeringdb_updated |
Network record metadata | PeeringDB joined on route_asn |
NA for no active record or absent field/source |
Community-maintained organisational context |
peeringdb_info_types |
Every self-reported network type | PeeringDB | NA for no record/unavailable type; empty tuple for known empty list |
Self-reported, non-exclusive and incomplete |
education_network_reported, enterprise_network_reported, cable_dsl_isp_network_reported |
Membership in three reported PeeringDB categories | PeeringDB | NA if record/types unavailable; False only for a known type list lacking the category |
Not proof of users, purpose, NAT, safety or expected usage |
cloud_matches |
Compatible tuple of all matching provider names | rezmoss aggregate | Empty tuple means checked/no match; NA means unsupported/unavailable |
Third-party aggregation; overlapping labels are retained |
cloud_match_details |
Matching CIDR/provider/service/region/source-update records | rezmoss aggregate | Empty tuple means checked/no match; NA means unsupported/unavailable |
Region belongs to the published range, not necessarily the user; no generic relay inference |
asdb_category_pairs, asdb_layer1_categories, asdb_layer2_categories |
All supplied industry category pairs and convenience projections | Stanford ASdb joined on route_asn |
NA for no ASN row/source |
Inferred classifications; disagreements with PeeringDB are not reconciled |
tor_exit_listed |
Exact address present in the dated Tor exit artifact | Tor bulk list | NA when family/source unavailable |
Dated shared-infrastructure observation, not abuse evidence |
vpn_listed, datacentre_listed |
Match in separate community IPv4 range lists | X4BNet | NA for IPv6/invalid/absent source |
Broad heuristic coverage; not independent labels |
hosting_asn_listed |
Route ASN present in curated hosting-ASN list | bad-asn-list | NA without route/source |
Maintainer heuristic, not ground truth |
Classification completeness and ASN agreement
The ASN tables use distinct non-zero IPtoASN route ASNs as their denominator. The GB subset means ASNs attached to at least one route row whose IPtoASN country field is exactly GB; it is not a census of networks serving UK users. The IP-level tables use fixed-seed samples across all eligible IPinfo IPv4 rows and, separately, rows whose IPinfo country code is GB. IPinfo country is IP-range geolocation and remains distinct from IPtoASN’s ASN country. These are ASN-level and source-row diagnostics, not traffic-, user-, or address-space-weighted estimates.
In [11]:
def route_asn_subset(country=None):
if route is None:
return None
mask = route.asn != 0
if country is not None:
mask &= route.country == country
return pd.Series(pd.array(pd.unique(route.asn[mask]), dtype="Int64"))
def pct(count, denominator):
return pd.NA if denominator == 0 or pd.isna(count) else count / denominator * 100
def classification_row(scope, asns, source, table, classification_field):
empty = {"scope": scope, "source": source, "denominator_asns": pd.NA, "no_record": pd.NA, "no_record_pct": pd.NA, "classification_missing_or_empty": pd.NA, "classification_missing_or_empty_pct": pd.NA, "usable_classification": pd.NA, "usable_classification_pct": pd.NA}
if asns is None:
return {**empty, "status": "unavailable", "reason": "IPtoASN route source unavailable"}
denominator = len(asns)
if table is None:
return {**empty, "denominator_asns": denominator, "status": "unavailable", "reason": f"{source} source unavailable"}
if denominator == 0:
return {**empty, "denominator_asns": 0, "status": "unavailable", "reason": "no eligible route ASNs"}
present = table.record_presence(asns).eq(True)
joined = table.lookup(asns)
usable = joined[classification_field].map(lambda value: value is not pd.NA and value is not None and len(value) > 0)
no_record = int((~present).sum())
missing = int((present & ~usable).sum())
usable_count = int((present & usable).sum())
return {**empty, "denominator_asns": denominator, "no_record": no_record, "no_record_pct": pct(no_record, denominator), "classification_missing_or_empty": missing, "classification_missing_or_empty_pct": pct(missing, denominator), "usable_classification": usable_count, "usable_classification_pct": pct(usable_count, denominator), "status": "available", "reason": None}
route_asn_scopes = {
"all distinct non-zero route ASNs": route_asn_subset(),
"IPtoASN country=GB distinct non-zero route ASNs": route_asn_subset("GB"),
}
asn_classification_coverage = pd.DataFrame([
classification_row(scope, asns, source, table, field)
for scope, asns in route_asn_scopes.items()
for source, table, field in [
("PeeringDB", peeringdb, "peeringdb_info_types"),
("Stanford ASdb", asdb, "asdb_category_pairs"),
]
])
asn_classification_coverage| scope | source | denominator_asns | no_record | no_record_pct | classification_missing_or_empty | classification_missing_or_empty_pct | usable_classification | usable_classification_pct | status | reason | |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | all distinct non-zero route ASNs | PeeringDB | 79310 | 50027 | 63.077796 | 8039 | 10.136175 | 21244 | 26.786030 | available | None |
| 1 | all distinct non-zero route ASNs | Stanford ASdb | 79310 | 5745 | 7.243727 | 0 | 0.000000 | 73565 | 92.756273 | available | None |
| 2 | IPtoASN country=GB distinct non-zero route ASNs | PeeringDB | 2072 | 1118 | 53.957529 | 217 | 10.472973 | 737 | 35.569498 | available | None |
| 3 | IPtoASN country=GB distinct non-zero route ASNs | Stanford ASdb | 2072 | 114 | 5.501931 | 0 | 0.000000 | 1958 | 94.498069 | available | None |
In [12]:
ipinfo_source_rows = (
pd.read_parquet(ipinfo_path, columns=["network", "country_code"])
if ipinfo_path is not None
else None
)
def sampled_output(scope, country_code=None):
if ipinfo_source_rows is None:
return None, 0, "IPinfo artifact unavailable"
eligible = ipinfo_source_rows.loc[~ipinfo_source_rows["network"].str.contains(":", na=False)]
if country_code is not None:
eligible = eligible.loc[eligible["country_code"].eq(country_code)]
if eligible.empty:
return None, 0, f"no eligible {scope} IPv4 rows"
selected = eligible.sample(n=min(5_000, len(eligible)), random_state=SAMPLE_SEED).sort_index()
ips = selected["network"].str.split("/", n=1).str[0]
result = enrich(ips, route=route, cloud=cloud, peeringdb=peeringdb, ipinfo=ipinfo, asdb=asdb, tor=tor, vpn=vpn, datacentre=datacentre, bad_asns=bad_asns)
return result, len(eligible), None
sample_scopes = {
"all IPinfo IPv4 source rows": sampled_output("overall"),
"IPinfo country_code=GB IPv4 source rows": sampled_output("GB", "GB"),
}
def metric(scope, name, count, denominator, reason=None):
available = reason is None and denominator is not None and denominator > 0 and count is not pd.NA
return {"scope": scope, "metric": name, "count": count if available else pd.NA, "denominator": denominator, "percent": pct(count, denominator) if available else pd.NA, "status": "available" if available else "unavailable", "reason": reason or ("zero denominator" if not available else None)}
sample_metrics = []
for scope, (sample_out, eligible_count, sample_reason) in sample_scopes.items():
if sample_out is None:
sample_metrics.append(metric(scope, "sample rows", pd.NA, eligible_count, sample_reason))
continue
sample_size = len(sample_out)
sample_metrics.append(metric(scope, "sample rows", sample_size, eligible_count))
route_known = int(sample_out["route_asn"].notna().sum())
ipinfo_known = int(sample_out["ipinfo_asn"].notna().sum())
route_reason = None if route is not None else "IPtoASN route source unavailable"
ipinfo_reason = None if ipinfo is not None else "IPinfo lookup source unavailable"
sample_metrics.append(metric(scope, "route ASN supplied", route_known, sample_size, route_reason))
sample_metrics.append(metric(scope, "IPinfo ASN supplied", ipinfo_known, sample_size, ipinfo_reason))
both = sample_out["route_asn"].notna() & sample_out["ipinfo_asn"].notna()
both_count = int(both.sum())
comparison_reason = route_reason or ipinfo_reason
sample_metrics.append(metric(scope, "both ASNs supplied", both_count, sample_size, comparison_reason))
agreement_reason = comparison_reason or ("no rows with both ASNs" if both_count == 0 else None)
sample_metrics.append(metric(scope, "ASN agreements", int(sample_out.loc[both, "route_ipinfo_asn_agree"].eq(True).sum()), both_count, agreement_reason))
sample_metrics.append(metric(scope, "ASN disagreements", int(sample_out.loc[both, "route_ipinfo_asn_agree"].eq(False).sum()), both_count, agreement_reason))
ip_sample_summary = pd.DataFrame(sample_metrics)
ip_sample_summary| scope | metric | count | denominator | percent | status | reason | |
|---|---|---|---|---|---|---|---|
| 0 | all IPinfo IPv4 source rows | sample rows | 5000 | 1337977 | 0.373699 | available | None |
| 1 | all IPinfo IPv4 source rows | route ASN supplied | 4398 | 5000 | 87.960000 | available | None |
| 2 | all IPinfo IPv4 source rows | IPinfo ASN supplied | 4421 | 5000 | 88.420000 | available | None |
| 3 | all IPinfo IPv4 source rows | both ASNs supplied | 4397 | 5000 | 87.940000 | available | None |
| 4 | all IPinfo IPv4 source rows | ASN agreements | 4352 | 4397 | 98.976575 | available | None |
| 5 | all IPinfo IPv4 source rows | ASN disagreements | 45 | 4397 | 1.023425 | available | None |
| 6 | IPinfo country_code=GB IPv4 source rows | sample rows | 5000 | 67290 | 7.430525 | available | None |
| 7 | IPinfo country_code=GB IPv4 source rows | route ASN supplied | 4369 | 5000 | 87.380000 | available | None |
| 8 | IPinfo country_code=GB IPv4 source rows | IPinfo ASN supplied | 4388 | 5000 | 87.760000 | available | None |
| 9 | IPinfo country_code=GB IPv4 source rows | both ASNs supplied | 4366 | 5000 | 87.320000 | available | None |
| 10 | IPinfo country_code=GB IPv4 source rows | ASN agreements | 4345 | 4366 | 99.519011 | available | None |
| 11 | IPinfo country_code=GB IPv4 source rows | ASN disagreements | 21 | 4366 | 0.480989 | available | None |
In [13]:
def sample_classification_row(scope, sample_out, source, table, classification_field, sample_reason):
empty = {"scope": scope, "source": source, "denominator_sample_rows": pd.NA, "route_asn_unavailable": pd.NA, "route_asn_unavailable_pct": pd.NA, "no_source_record": pd.NA, "no_source_record_pct": pd.NA, "classification_missing_or_empty": pd.NA, "classification_missing_or_empty_pct": pd.NA, "usable_classification": pd.NA, "usable_classification_pct": pd.NA}
if sample_out is None:
return {**empty, "status": "unavailable", "reason": sample_reason}
denominator = len(sample_out)
if route is None:
return {**empty, "denominator_sample_rows": denominator, "route_asn_unavailable": denominator, "route_asn_unavailable_pct": pct(denominator, denominator), "status": "unavailable", "reason": "IPtoASN route source unavailable"}
if table is None:
return {**empty, "denominator_sample_rows": denominator, "status": "unavailable", "reason": f"{source} source unavailable"}
if denominator == 0:
return {**empty, "denominator_sample_rows": 0, "status": "unavailable", "reason": "empty sample"}
route_asns = sample_out["route_asn"]
route_missing = route_asns.isna()
present = table.record_presence(route_asns).eq(True)
joined = table.lookup(route_asns)
usable = joined[classification_field].map(lambda value: value is not pd.NA and value is not None and len(value) > 0)
no_record = int((~route_missing & ~present).sum())
missing = int((present & ~usable).sum())
usable_count = int((present & usable).sum())
route_missing_count = int(route_missing.sum())
return {**empty, "denominator_sample_rows": denominator, "route_asn_unavailable": route_missing_count, "route_asn_unavailable_pct": pct(route_missing_count, denominator), "no_source_record": no_record, "no_source_record_pct": pct(no_record, denominator), "classification_missing_or_empty": missing, "classification_missing_or_empty_pct": pct(missing, denominator), "usable_classification": usable_count, "usable_classification_pct": pct(usable_count, denominator), "status": "available", "reason": None}
ip_sample_classification = pd.DataFrame([
sample_classification_row(scope, sample_out, source, table, field, sample_reason)
for scope, (sample_out, _, sample_reason) in sample_scopes.items()
for source, table, field in [
("PeeringDB", peeringdb, "peeringdb_info_types"),
("Stanford ASdb", asdb, "asdb_category_pairs"),
]
])
ip_sample_classification| scope | source | denominator_sample_rows | route_asn_unavailable | route_asn_unavailable_pct | no_source_record | no_source_record_pct | classification_missing_or_empty | classification_missing_or_empty_pct | usable_classification | usable_classification_pct | status | reason | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | all IPinfo IPv4 source rows | PeeringDB | 5000 | 602 | 12.04 | 900 | 18.00 | 301 | 6.02 | 3197 | 63.94 | available | None |
| 1 | all IPinfo IPv4 source rows | Stanford ASdb | 5000 | 602 | 12.04 | 69 | 1.38 | 0 | 0.00 | 4329 | 86.58 | available | None |
| 2 | IPinfo country_code=GB IPv4 source rows | PeeringDB | 5000 | 631 | 12.62 | 525 | 10.50 | 157 | 3.14 | 3687 | 73.74 | available | None |
| 3 | IPinfo country_code=GB IPv4 source rows | Stanford ASdb | 5000 | 631 | 12.62 | 31 | 0.62 | 0 | 0.00 | 4338 | 86.76 | available | None |
Provenance
In [14]:
provenance = pd.DataFrame([
{
"source": record["source"],
"path": record["path"],
"retrieved_at": record["retrieved_at"],
"source_updated_at": record["source_updated_at"],
"file_mtime": record["file_mtime"],
"sha256": record["sha256"],
"url": record["url"],
"status": record["status"],
"reason": record["reason"],
}
for record in source_records
])
provenance| source | path | retrieved_at | source_updated_at | file_mtime | sha256 | url | status | reason | |
|---|---|---|---|---|---|---|---|---|---|
| 0 | IPtoASN | data/IP/ip2asn-combined.tsv.gz | None | None | 2026-10-24T12:38:06+00:00 | 7336abc7611351b0261023c6268293bc9d4a02fb32a3ea... | https://iptoasn.com/data/ip2asn-combined.tsv.gz | available | None |
| 1 | PeeringDB | notebooks/peeringdb-net.json | None | 2026-10-24T16:21:34+00:00 | 2026-10-24T19:41:12+00:00 | ccdcaea36cef0b2752c1b53abafae8c06c3d4684ad39e2... | https://www.peeringdb.com/api/net | available | None |
| 2 | IPinfo Lite | data/IP/ipinfo_lite.parquet | None | None | 2026-10-24T13:02:50+00:00 | 9972cc59727492fec87ddb1da55c14faeabb75362401d3... | https://ipinfo.io/developers/ipinfo-lite-database | available | None |
| 3 | rezmoss aggregate | data/raw/ip-enrichment/2026-10-25/rezmoss_all_... | 2026-10-25T10:54:51+00:00 | None | 2026-10-25T10:54:51+00:00 | fa5c03243d283b27af15bf7cad73f42cf768e21f9c0654... | https://raw.githubusercontent.com/rezmoss/clou... | available | None |
| 4 | Stanford ASdb | data/IP/2026-03_categorized_ases.csv | None | None | 2026-10-24T12:39:51+00:00 | 00ba179031d6eac793638c9ad1dd2ff03f71424b53a2f4... | https://asdb.stanford.edu/about | available | None |
| 5 | X4BNet VPN IPv4 | data/raw/ip-enrichment/2026-10-25/x4bnet_vpn_i... | 2026-10-25T10:54:43+00:00 | None | 2026-10-25T10:54:43+00:00 | d5555f06a151141d08d79bbbcb96150f979860b48d8bfd... | https://raw.githubusercontent.com/X4BNet/lists... | available | None |
| 6 | X4BNet datacentre IPv4 | data/raw/ip-enrichment/2026-10-25/x4bnet_datac... | 2026-10-25T10:54:43+00:00 | None | 2026-10-25T10:54:43+00:00 | 380eccc1e05d05b3535dff6c380a0fb9d8bd6978018c97... | https://raw.githubusercontent.com/X4BNet/lists... | available | None |
| 7 | bad-asn-list | data/raw/ip-enrichment/2026-10-25/bad_asn_list... | 2026-10-25T10:54:51+00:00 | None | 2026-10-25T10:54:51+00:00 | 0af8e58dc07ba070a10c9083a1a9f5c9fc76be35fab32b... | https://raw.githubusercontent.com/brianhama/ba... | available | None |
| 8 | Tor bulk exit list | data/raw/ip-enrichment/2026-10-25/tor_bulk_exi... | 2026-10-25T10:54:51+00:00 | None | 2026-10-25T10:54:51+00:00 | 6756af70704aa18c358c48563b54ec27c962b86f0dc2b5... | https://check.torproject.org/torbulkexitlist | available | None |