ms-cloud-endpoints 0.1.0__py3-none-any.whl

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,4 @@
1
+ """Collect Microsoft 365, Entra and Azure domains and IP ranges from the documented Microsoft sources."""
2
+
3
+ __version__ = "0.1.0"
4
+ GENERATOR = "ms-cloud-endpoints"
@@ -0,0 +1,6 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ sys.exit(main())
@@ -0,0 +1,255 @@
1
+ """Command line entry point: fetch the selected sources and write the combined document as JSON and/or YAML."""
2
+
3
+ import argparse
4
+ import logging
5
+ import os
6
+ import sys
7
+ import uuid
8
+ from collections.abc import Callable
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from . import GENERATOR, __version__
13
+ from .clouds import CLOUDS, Cloud, resolve_clouds
14
+ from .fetch import Fetcher, HttpFetcher
15
+ from .model import SourceResult
16
+ from .netutil import utc_now
17
+ from .output import Format, write_document
18
+ from .sources import m365, m365_additional, private_dns, service_tags
19
+ from .summary import build_summary
20
+
21
+ log = logging.getLogger(GENERATOR)
22
+
23
+ SOURCES = ("m365", "m365-additional", "service-tags", "private-dns")
24
+ CLIENT_REQUEST_ID_ENV = "MS_ENDPOINTS_CLIENT_REQUEST_ID"
25
+
26
+ EXIT_OK = 0
27
+ EXIT_ERROR = 1
28
+ EXIT_PARTIAL = 3
29
+
30
+
31
+ def build_parser() -> argparse.ArgumentParser:
32
+ parser = argparse.ArgumentParser(
33
+ prog=GENERATOR,
34
+ description=(
35
+ "Collect Microsoft 365, Entra and Azure domains and IP ranges from the documented Microsoft sources "
36
+ "and write them as JSON and/or YAML."
37
+ ),
38
+ )
39
+ parser.add_argument(
40
+ "--cloud",
41
+ action="append",
42
+ choices=[*CLOUDS, "all"],
43
+ help="cloud(s) to collect; repeatable (default: public)",
44
+ )
45
+ parser.add_argument(
46
+ "--source",
47
+ action="append",
48
+ choices=SOURCES,
49
+ help="source(s) to collect; repeatable (default: all)",
50
+ )
51
+ parser.add_argument(
52
+ "--tag",
53
+ action="append",
54
+ metavar="GLOB",
55
+ help="only include Azure service tags whose name matches GLOB (case-insensitive); repeatable, "
56
+ "e.g. --tag AzureCloud --tag 'AzureActiveDirectory*'",
57
+ )
58
+ parser.add_argument(
59
+ "--ipv6",
60
+ action=argparse.BooleanOptionalAction,
61
+ help="include IPv6 prefixes (--ipv6, the default) or drop them from the output (--no-ipv6)",
62
+ )
63
+ parser.add_argument("--no-summary", action="store_true", help="omit the aggregated summary section")
64
+ parser.add_argument(
65
+ "--format",
66
+ choices=["json", "yaml", "both"],
67
+ help="output format (default: json on stdout, both when --output is given)",
68
+ )
69
+ parser.add_argument(
70
+ "-o",
71
+ "--output",
72
+ metavar="PATH",
73
+ help="output file or directory (a trailing '/' or an existing directory receives "
74
+ "ms-cloud-endpoints.json/.yaml); default: stdout",
75
+ )
76
+ parser.add_argument(
77
+ "--client-request-id",
78
+ help=f"GUID for the Microsoft 365 web service (default: ${CLIENT_REQUEST_ID_ENV}, else a GUID persisted "
79
+ "per machine under $XDG_CONFIG_HOME/ms-cloud-endpoints/)",
80
+ )
81
+ parser.add_argument("--timeout", type=float, default=60.0, help="HTTP timeout in seconds (default: 60)")
82
+ parser.add_argument(
83
+ "--keep-going",
84
+ action="store_true",
85
+ help="record failed sources in metadata and still write the output (exit code 3)",
86
+ )
87
+ parser.add_argument("-v", "--verbose", action="count", default=0, help="more logging (-v info, -vv debug)")
88
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
89
+ return parser
90
+
91
+
92
+ def _config_dir() -> Path:
93
+ base = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")
94
+ return Path(base) / GENERATOR
95
+
96
+
97
+ def resolve_client_request_id(explicit: str | None) -> str:
98
+ """Microsoft asks for one stable GUID per machine, so a generated GUID is persisted and reused."""
99
+ for candidate in (explicit, os.environ.get(CLIENT_REQUEST_ID_ENV)):
100
+ if candidate:
101
+ return str(uuid.UUID(candidate))
102
+ path = _config_dir() / "client-request-id"
103
+ try:
104
+ return str(uuid.UUID(path.read_text(encoding="utf-8").strip()))
105
+ except (OSError, ValueError):
106
+ pass
107
+ generated = str(uuid.uuid4())
108
+ try:
109
+ path.parent.mkdir(parents=True, exist_ok=True)
110
+ path.write_text(generated + "\n", encoding="utf-8")
111
+ except OSError as exc:
112
+ log.warning("could not persist ClientRequestId to %s: %s", path, exc)
113
+ return generated
114
+
115
+
116
+ def _resolve_formats(fmt: str | None, output: str | None) -> list[Format]:
117
+ if fmt is None:
118
+ fmt = "both" if output is not None else "json"
119
+ return ["json", "yaml"] if fmt == "both" else [fmt] # type: ignore[list-item]
120
+
121
+
122
+ def collect_document(
123
+ fetcher: Fetcher,
124
+ clouds: list[Cloud],
125
+ sources: list[str],
126
+ *,
127
+ client_request_id: str | None,
128
+ tag_patterns: list[str] | None,
129
+ include_ipv6: bool,
130
+ include_summary: bool,
131
+ keep_going: bool,
132
+ ) -> tuple[dict[str, Any], list[dict[str, Any]]]:
133
+ """Return ``(document, errors)``. Without ``keep_going`` the first failing source raises."""
134
+ source_records: list[dict[str, Any]] = []
135
+ errors: list[dict[str, Any]] = []
136
+ document: dict[str, Any] = {
137
+ "metadata": {
138
+ "generator": GENERATOR,
139
+ "generator_version": __version__,
140
+ "generated_at": None,
141
+ "clouds": [cloud.name for cloud in clouds],
142
+ "sources_selected": sources,
143
+ "options": {"tag_filter": tag_patterns or None, "ipv6": include_ipv6},
144
+ "client_request_id": client_request_id,
145
+ "sources": source_records,
146
+ }
147
+ }
148
+
149
+ def run(name: str, section: str, collector: Callable[[], SourceResult]) -> SourceResult | None:
150
+ try:
151
+ result = collector()
152
+ except Exception as exc:
153
+ if not keep_going:
154
+ raise
155
+ log.error("source %s failed: %s", name, exc)
156
+ error = {"source": name, "status": "error", "error": f"{type(exc).__name__}: {exc}"}
157
+ errors.append(error)
158
+ source_records.append(error)
159
+ return None
160
+ document[section] = result.data
161
+ source_records.extend(result.sources)
162
+ return result
163
+
164
+ if "m365" in sources and client_request_id is not None:
165
+ instances = [instance for cloud in clouds for instance in cloud.m365_instances]
166
+ run("m365", "m365", lambda: m365.collect(fetcher, instances, client_request_id, include_ipv6=include_ipv6))
167
+
168
+ if "m365-additional" in sources:
169
+ run(
170
+ "m365_additional",
171
+ "m365_additional",
172
+ lambda: m365_additional.collect(fetcher, include_ipv6=include_ipv6),
173
+ )
174
+
175
+ # Service tags run before the private DNS zones because their region lists expand {regionName} templates.
176
+ regions_by_cloud: dict[str, list[str]] = {}
177
+ if "service-tags" in sources:
178
+ downloads = [download for cloud in clouds for download in cloud.service_tag_downloads]
179
+ result = run(
180
+ "azure_service_tags",
181
+ "azure_service_tags",
182
+ lambda: service_tags.collect(fetcher, downloads, tag_patterns=tag_patterns, include_ipv6=include_ipv6),
183
+ )
184
+ if result is not None:
185
+ regions_by_cloud = {name: data.get("regions", []) for name, data in result.data.items()}
186
+
187
+ if "private-dns" in sources:
188
+ sections = [
189
+ private_dns.PrivateDnsSection(
190
+ label=cloud.private_dns_label,
191
+ section_id=cloud.private_dns_section,
192
+ regions=[
193
+ region
194
+ for tag_cloud, _ in cloud.service_tag_downloads
195
+ for region in regions_by_cloud.get(tag_cloud, [])
196
+ ],
197
+ )
198
+ for cloud in clouds
199
+ ]
200
+ run("private_dns_zones", "private_dns_zones", lambda: private_dns.collect(fetcher, sections))
201
+
202
+ if include_summary:
203
+ document["summary"] = build_summary(document, [cloud.name for cloud in clouds], include_ipv6=include_ipv6)
204
+ return document, errors
205
+
206
+
207
+ def main(argv: list[str] | None = None, *, fetcher: Fetcher | None = None) -> int:
208
+ parser = build_parser()
209
+ args = parser.parse_args(argv)
210
+ logging.basicConfig(
211
+ level=[logging.WARNING, logging.INFO, logging.DEBUG][min(args.verbose, 2)],
212
+ format="%(levelname)s %(name)s: %(message)s",
213
+ stream=sys.stderr,
214
+ )
215
+
216
+ formats = _resolve_formats(args.format, args.output)
217
+ if args.output is None and len(formats) > 1:
218
+ parser.error("--format both requires --output")
219
+ try:
220
+ clouds = resolve_clouds(args.cloud)
221
+ except ValueError as exc:
222
+ parser.error(str(exc))
223
+ sources = [source for source in SOURCES if source in (args.source or SOURCES)]
224
+ client_request_id: str | None = None
225
+ if "m365" in sources:
226
+ try:
227
+ client_request_id = resolve_client_request_id(args.client_request_id)
228
+ except ValueError as exc:
229
+ parser.error(f"invalid ClientRequestId (expected a GUID): {exc}")
230
+
231
+ try:
232
+ document, errors = collect_document(
233
+ fetcher or HttpFetcher(timeout=args.timeout),
234
+ clouds,
235
+ sources,
236
+ client_request_id=client_request_id,
237
+ tag_patterns=args.tag,
238
+ include_ipv6=args.ipv6 is not False,
239
+ include_summary=not args.no_summary,
240
+ keep_going=args.keep_going,
241
+ )
242
+ except Exception as exc:
243
+ log.error("%s: %s", type(exc).__name__, exc)
244
+ log.debug("traceback", exc_info=True)
245
+ return EXIT_ERROR
246
+ document["metadata"]["generated_at"] = utc_now()
247
+
248
+ try:
249
+ written = write_document(document, formats, args.output)
250
+ except (OSError, ValueError) as exc:
251
+ log.error("could not write output: %s", exc)
252
+ return EXIT_ERROR
253
+ for path in written:
254
+ print(f"wrote {path}", file=sys.stderr)
255
+ return EXIT_PARTIAL if errors else EXIT_OK
@@ -0,0 +1,51 @@
1
+ """Mapping of the user-facing cloud names to the identifiers used by each upstream source."""
2
+
3
+ from dataclasses import dataclass
4
+
5
+
6
+ @dataclass(frozen=True, slots=True)
7
+ class Cloud:
8
+ name: str
9
+ # Microsoft 365 IP Address and URL web service instances.
10
+ m365_instances: tuple[str, ...]
11
+ # Azure Service Tags: (cloud label used in the output, Microsoft Download Center details page id).
12
+ service_tag_downloads: tuple[tuple[str, int], ...]
13
+ # Section heading id on the "Azure Private Endpoint private DNS zone values" Learn page.
14
+ private_dns_section: str
15
+ private_dns_label: str
16
+
17
+
18
+ CLOUDS: dict[str, Cloud] = {
19
+ "public": Cloud(
20
+ name="public",
21
+ m365_instances=("Worldwide",),
22
+ service_tag_downloads=(("Public", 56519),),
23
+ private_dns_section="commercial",
24
+ private_dns_label="Commercial",
25
+ ),
26
+ "china": Cloud(
27
+ name="china",
28
+ m365_instances=("China",),
29
+ service_tag_downloads=(("China", 57062),),
30
+ private_dns_section="china",
31
+ private_dns_label="China",
32
+ ),
33
+ "usgov": Cloud(
34
+ name="usgov",
35
+ m365_instances=("USGovDoD", "USGovGCCHigh"),
36
+ service_tag_downloads=(("AzureGovernment", 57063),),
37
+ private_dns_section="government",
38
+ private_dns_label="Government",
39
+ ),
40
+ }
41
+
42
+
43
+ def resolve_clouds(names: list[str] | None) -> list[Cloud]:
44
+ """Return the selected clouds in a stable order; ``all`` selects every cloud, default is ``public``."""
45
+ selected = set(names or ["public"])
46
+ if "all" in selected:
47
+ selected = set(CLOUDS)
48
+ unknown = selected - set(CLOUDS)
49
+ if unknown:
50
+ raise ValueError(f"unknown cloud(s): {', '.join(sorted(unknown))}")
51
+ return [cloud for key, cloud in CLOUDS.items() if key in selected]
@@ -0,0 +1,87 @@
1
+ """Minimal stdlib HTTP client with retries, used by every source module."""
2
+
3
+ import gzip
4
+ import json
5
+ import logging
6
+ import time
7
+ import urllib.error
8
+ import urllib.request
9
+ import zlib
10
+ from typing import Any, Protocol
11
+
12
+ from . import GENERATOR, __version__
13
+
14
+ log = logging.getLogger(__name__)
15
+
16
+ # The Microsoft Download Center returns a reduced page to unknown clients, so present a browser-like agent.
17
+ DEFAULT_USER_AGENT = (
18
+ f"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0 Safari/537.36 "
19
+ f"{GENERATOR}/{__version__}"
20
+ )
21
+ RETRY_STATUS = {429, 500, 502, 503, 504}
22
+
23
+
24
+ class FetchError(RuntimeError):
25
+ pass
26
+
27
+
28
+ class Fetcher(Protocol):
29
+ def get_text(self, url: str) -> str: ...
30
+
31
+ def get_json(self, url: str) -> Any: ...
32
+
33
+
34
+ class HttpFetcher:
35
+ def __init__(
36
+ self,
37
+ *,
38
+ timeout: float = 60.0,
39
+ retries: int = 3,
40
+ backoff: float = 2.0,
41
+ user_agent: str = DEFAULT_USER_AGENT,
42
+ ) -> None:
43
+ self.timeout = timeout
44
+ self.retries = retries
45
+ self.backoff = backoff
46
+ self.user_agent = user_agent
47
+
48
+ def get_bytes(self, url: str) -> bytes:
49
+ request = urllib.request.Request(
50
+ url,
51
+ headers={
52
+ "User-Agent": self.user_agent,
53
+ "Accept-Encoding": "gzip, deflate",
54
+ "Accept-Language": "en-US,en;q=0.9",
55
+ },
56
+ )
57
+ last_error: Exception | None = None
58
+ for attempt in range(1, self.retries + 1):
59
+ try:
60
+ log.debug("GET %s (attempt %d)", url, attempt)
61
+ with urllib.request.urlopen(request, timeout=self.timeout) as response:
62
+ body = response.read()
63
+ encoding = (response.headers.get("Content-Encoding") or "").lower()
64
+ if encoding == "gzip":
65
+ body = gzip.decompress(body)
66
+ elif encoding == "deflate":
67
+ body = zlib.decompress(body)
68
+ return body
69
+ except urllib.error.HTTPError as exc:
70
+ last_error = exc
71
+ if exc.code not in RETRY_STATUS:
72
+ break
73
+ except (urllib.error.URLError, TimeoutError, ConnectionError) as exc:
74
+ last_error = exc
75
+ if attempt < self.retries:
76
+ time.sleep(self.backoff * attempt)
77
+ raise FetchError(f"failed to fetch {url}: {last_error}") from last_error
78
+
79
+ def get_text(self, url: str) -> str:
80
+ return self.get_bytes(url).decode("utf-8-sig")
81
+
82
+ def get_json(self, url: str) -> Any:
83
+ text = self.get_text(url)
84
+ try:
85
+ return json.loads(text)
86
+ except json.JSONDecodeError as exc:
87
+ raise FetchError(f"invalid JSON from {url}: {exc}") from exc
@@ -0,0 +1,160 @@
1
+ """Tiny DOM built on :mod:`html.parser`, with helpers for the Microsoft Learn page layout."""
2
+
3
+ from collections.abc import Iterator
4
+ from html.parser import HTMLParser
5
+
6
+ VOID_TAGS = frozenset(
7
+ {"area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "source", "track", "wbr"}
8
+ )
9
+ # Elements whose text is rendered on its own line.
10
+ BLOCK_TAGS = frozenset({"br", "p", "li", "div", "tr", "pre", "ul", "ol", "h1", "h2", "h3", "h4", "h5", "h6"})
11
+ # Elements that never contribute readable text.
12
+ SKIP_TAGS = frozenset({"script", "style", "template", "noscript"})
13
+
14
+
15
+ class Node:
16
+ __slots__ = ("tag", "attrs", "children", "parent")
17
+
18
+ def __init__(self, tag: str, attrs: dict[str, str] | None = None, parent: "Node | None" = None) -> None:
19
+ self.tag = tag
20
+ self.attrs = attrs or {}
21
+ self.children: list[Node | str] = []
22
+ self.parent = parent
23
+
24
+ def __repr__(self) -> str:
25
+ return f"<Node {self.tag} {self.attrs.get('id', '')}>"
26
+
27
+ @property
28
+ def id(self) -> str | None:
29
+ return self.attrs.get("id")
30
+
31
+ def walk(self) -> Iterator["Node"]:
32
+ """Pre-order (document order) traversal of element nodes, including ``self``."""
33
+ stack: list[Node] = [self]
34
+ while stack:
35
+ node = stack.pop()
36
+ yield node
37
+ stack.extend(child for child in reversed(node.children) if isinstance(child, Node))
38
+
39
+ def find_all(self, tag: str) -> list["Node"]:
40
+ return [node for node in self.walk() if node.tag == tag]
41
+
42
+ def find_id(self, element_id: str) -> "Node | None":
43
+ return next((node for node in self.walk() if node.id == element_id), None)
44
+
45
+ def text(self, *, skip: frozenset[str] = frozenset(), lines: bool = False) -> str:
46
+ """Text content. ``skip`` drops whole elements (e.g. ``sup`` footnote markers); ``lines`` keeps block breaks."""
47
+ parts: list[str] = []
48
+ self._collect(parts, skip | SKIP_TAGS, lines)
49
+ raw = "".join(parts)
50
+ if lines:
51
+ return "\n".join(" ".join(line.split()) for line in raw.splitlines() if line.strip())
52
+ return " ".join(raw.split())
53
+
54
+ def lines(self, *, skip: frozenset[str] = frozenset()) -> list[str]:
55
+ text = self.text(skip=skip, lines=True)
56
+ return text.splitlines() if text else []
57
+
58
+ def _collect(self, parts: list[str], skip: frozenset[str], lines: bool) -> None:
59
+ for child in self.children:
60
+ if isinstance(child, str):
61
+ parts.append(child)
62
+ continue
63
+ if child.tag in skip:
64
+ continue
65
+ separator = ""
66
+ if child.tag in BLOCK_TAGS:
67
+ separator = "\n" if lines else " "
68
+ elif child.tag in ("td", "th"):
69
+ separator = " "
70
+ parts.append(separator)
71
+ child._collect(parts, skip, lines)
72
+ parts.append(separator)
73
+
74
+
75
+ class _TreeBuilder(HTMLParser):
76
+ def __init__(self) -> None:
77
+ super().__init__(convert_charrefs=True)
78
+ self.root = Node("#document")
79
+ self.current = self.root
80
+
81
+ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
82
+ node = Node(tag, {key: value or "" for key, value in attrs}, self.current)
83
+ self.current.children.append(node)
84
+ if tag not in VOID_TAGS:
85
+ self.current = node
86
+
87
+ def handle_startendtag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
88
+ self.current.children.append(Node(tag, {key: value or "" for key, value in attrs}, self.current))
89
+
90
+ def handle_endtag(self, tag: str) -> None:
91
+ if tag in VOID_TAGS:
92
+ return
93
+ node: Node | None = self.current
94
+ while node is not None and node.tag != tag:
95
+ node = node.parent
96
+ # Ignore stray end tags; otherwise close everything up to and including the match.
97
+ if node is not None and node.parent is not None:
98
+ self.current = node.parent
99
+
100
+ def handle_data(self, data: str) -> None:
101
+ self.current.children.append(data)
102
+
103
+
104
+ def parse_html(html: str) -> Node:
105
+ builder = _TreeBuilder()
106
+ builder.feed(html)
107
+ builder.close()
108
+ return builder.root
109
+
110
+
111
+ def heading_level(node: Node) -> int | None:
112
+ if len(node.tag) == 2 and node.tag[0] == "h" and node.tag[1].isdigit():
113
+ return int(node.tag[1])
114
+ return None
115
+
116
+
117
+ def section_nodes(root: Node, section_id: str) -> list[Node]:
118
+ """All element nodes in document order between the heading ``section_id`` and the next heading of equal or
119
+ higher rank."""
120
+ heading = root.find_id(section_id)
121
+ if heading is None or (level := heading_level(heading)) is None:
122
+ raise LookupError(f"section heading #{section_id} not found")
123
+ collected: list[Node] = []
124
+ started = False
125
+ for node in root.walk():
126
+ if node is heading:
127
+ started = True
128
+ continue
129
+ if not started:
130
+ continue
131
+ other_level = heading_level(node)
132
+ if other_level is not None and other_level <= level:
133
+ break
134
+ collected.append(node)
135
+ return collected
136
+
137
+
138
+ def table_rows(table: Node) -> tuple[list[str], list[list[Node]]]:
139
+ """Return ``(header texts, body rows)``; header cells are taken from the first row that contains ``th``."""
140
+ headers: list[str] = []
141
+ rows: list[list[Node]] = []
142
+ for row in table.find_all("tr"):
143
+ cells = [child for child in row.children if isinstance(child, Node) and child.tag in ("th", "td")]
144
+ if not cells:
145
+ continue
146
+ if not headers and all(cell.tag == "th" for cell in cells):
147
+ headers = [cell.text(skip=frozenset({"sup"})) for cell in cells]
148
+ continue
149
+ rows.append(cells)
150
+ return headers, rows
151
+
152
+
153
+ def find_column(headers: list[str], *candidates: str) -> int:
154
+ """Index of the first header that contains any of ``candidates`` (case-insensitive)."""
155
+ lowered = [header.lower() for header in headers]
156
+ for candidate in candidates:
157
+ for index, header in enumerate(lowered):
158
+ if candidate.lower() in header:
159
+ return index
160
+ raise LookupError(f"none of the columns {candidates!r} found in table headers {headers!r}")
@@ -0,0 +1,10 @@
1
+ from dataclasses import dataclass, field
2
+ from typing import Any
3
+
4
+
5
+ @dataclass(slots=True)
6
+ class SourceResult:
7
+ """Data collected from one source plus one metadata record per upstream document that was fetched."""
8
+
9
+ data: dict[str, Any]
10
+ sources: list[dict[str, Any]] = field(default_factory=list)
@@ -0,0 +1,100 @@
1
+ """IP prefix and domain name helpers shared by the source modules and the summary."""
2
+
3
+ import ipaddress
4
+ import re
5
+ from collections.abc import Iterable
6
+ from datetime import UTC, datetime
7
+
8
+ type IPNetwork = ipaddress.IPv4Network | ipaddress.IPv6Network
9
+
10
+ # FQDN or wildcard FQDN, optionally prefixed by a URL scheme and suffixed by a port or path. The TLD must be
11
+ # alphabetic so that dotted IPv4 addresses are never taken for domains.
12
+ DOMAIN_RE = re.compile(
13
+ r"(?<![\w.@/-])(?:https?://)?(?P<domain>(?:\*\.)?(?:[a-z0-9*](?:[a-z0-9*-]{0,61}[a-z0-9])?\.)+[a-z]{2,63})"
14
+ r"(?::\d+)?(?:/\S*)?(?![\w-])",
15
+ re.IGNORECASE,
16
+ )
17
+ # "{regionName}"-style placeholder labels in documented domain templates.
18
+ PLACEHOLDER_RE = re.compile(r"\{(?P<name>[A-Za-z]+)\}")
19
+ IPV4_RE = re.compile(r"(?<![\d.])\d{1,3}(?:\.\d{1,3}){3}(?:/\d{1,2})?(?![\d.])")
20
+ IPV6_RE = re.compile(r"(?<![\w:])(?:[0-9a-f]{0,4}:){2,7}[0-9a-f]{0,4}(?:/\d{1,3})?(?![\w:])", re.IGNORECASE)
21
+
22
+ # Public suffixes with more than one label that occur in Microsoft endpoint lists; everything else is treated as a
23
+ # single-label suffix. This avoids a dependency on the Public Suffix List.
24
+ MULTI_LABEL_SUFFIXES = frozenset(
25
+ {
26
+ "co.uk", "org.uk", "gov.uk", "ac.uk", "com.cn", "net.cn", "gov.cn", "org.cn", "com.au", "net.au",
27
+ "co.jp", "ne.jp", "co.kr", "co.in", "co.nz", "com.br", "com.mx", "com.tw", "com.hk", "com.sg",
28
+ "co.za", "us.com",
29
+ }
30
+ ) # fmt: skip
31
+
32
+
33
+ def utc_now() -> str:
34
+ return datetime.now(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z")
35
+
36
+
37
+ def parse_network(value: str) -> IPNetwork | None:
38
+ try:
39
+ return ipaddress.ip_network(value.strip(), strict=False)
40
+ except ValueError:
41
+ return None
42
+
43
+
44
+ def split_prefixes(values: Iterable[str]) -> tuple[list[str], list[str]]:
45
+ """Normalise prefixes and split them by family, keeping the first-seen order and dropping duplicates."""
46
+ ipv4: dict[str, None] = {}
47
+ ipv6: dict[str, None] = {}
48
+ for value in values:
49
+ network = parse_network(value)
50
+ if network is None:
51
+ continue
52
+ (ipv4 if network.version == 4 else ipv6)[str(network)] = None
53
+ return list(ipv4), list(ipv6)
54
+
55
+
56
+ def collapse(prefixes: Iterable[str]) -> list[str]:
57
+ networks = [network for value in prefixes if (network := parse_network(value)) is not None]
58
+ return [str(network) for network in ipaddress.collapse_addresses(networks)]
59
+
60
+
61
+ def collapse_by_family(prefixes: Iterable[str]) -> tuple[list[str], list[str]]:
62
+ ipv4, ipv6 = split_prefixes(prefixes)
63
+ return collapse(ipv4), collapse(ipv6)
64
+
65
+
66
+ def normalise_domain(value: str) -> str:
67
+ domain = value.strip().lower().rstrip(".")
68
+ for scheme in ("https://", "http://"):
69
+ domain = domain.removeprefix(scheme)
70
+ return domain.split("/", 1)[0].split(":", 1)[0]
71
+
72
+
73
+ def wildcard_suffix(domain: str) -> str | None:
74
+ """``*.foo.com`` -> ``foo.com`` and ``autodiscover.*.onmicrosoft.com`` -> ``onmicrosoft.com``."""
75
+ if "*" not in domain:
76
+ return None
77
+ labels = domain.split(".")
78
+ last_wildcard = max(index for index, label in enumerate(labels) if "*" in label)
79
+ suffix = ".".join(labels[last_wildcard + 1 :])
80
+ return suffix or None
81
+
82
+
83
+ def root_domain(domain: str) -> str:
84
+ labels = domain.lstrip("*.").split(".")
85
+ if len(labels) >= 3 and ".".join(labels[-2:]) in MULTI_LABEL_SUFFIXES:
86
+ return ".".join(labels[-3:])
87
+ return ".".join(labels[-2:])
88
+
89
+
90
+ def extract_domains(text: str) -> list[str]:
91
+ found: dict[str, None] = {}
92
+ for match in DOMAIN_RE.finditer(text):
93
+ found[normalise_domain(match.group("domain"))] = None
94
+ return list(found)
95
+
96
+
97
+ def extract_prefixes(text: str) -> tuple[list[str], list[str]]:
98
+ candidates = [match.group(0) for match in IPV4_RE.finditer(text)]
99
+ candidates += [match.group(0) for match in IPV6_RE.finditer(text) if match.group(0).count(":") >= 2]
100
+ return split_prefixes(candidates)