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.
- ms_cloud_endpoints/__init__.py +4 -0
- ms_cloud_endpoints/__main__.py +6 -0
- ms_cloud_endpoints/cli.py +255 -0
- ms_cloud_endpoints/clouds.py +51 -0
- ms_cloud_endpoints/fetch.py +87 -0
- ms_cloud_endpoints/html_tables.py +160 -0
- ms_cloud_endpoints/model.py +10 -0
- ms_cloud_endpoints/netutil.py +100 -0
- ms_cloud_endpoints/output.py +77 -0
- ms_cloud_endpoints/py.typed +0 -0
- ms_cloud_endpoints/sources/__init__.py +1 -0
- ms_cloud_endpoints/sources/m365.py +68 -0
- ms_cloud_endpoints/sources/m365_additional.py +105 -0
- ms_cloud_endpoints/sources/private_dns.py +198 -0
- ms_cloud_endpoints/sources/service_tags.py +98 -0
- ms_cloud_endpoints/summary.py +82 -0
- ms_cloud_endpoints-0.1.0.dist-info/METADATA +281 -0
- ms_cloud_endpoints-0.1.0.dist-info/RECORD +21 -0
- ms_cloud_endpoints-0.1.0.dist-info/WHEEL +4 -0
- ms_cloud_endpoints-0.1.0.dist-info/entry_points.txt +2 -0
- ms_cloud_endpoints-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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)
|