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,77 @@
|
|
|
1
|
+
"""JSON and YAML serialisation of the collected document."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
import tempfile
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Literal, TextIO
|
|
9
|
+
|
|
10
|
+
import yaml
|
|
11
|
+
|
|
12
|
+
type Format = Literal["json", "yaml"]
|
|
13
|
+
|
|
14
|
+
EXTENSIONS: dict[Format, str] = {"json": ".json", "yaml": ".yaml"}
|
|
15
|
+
DEFAULT_BASENAME = "ms-cloud-endpoints"
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
_YamlDumper: type[yaml.SafeDumper] = yaml.CSafeDumper
|
|
19
|
+
except AttributeError: # PyYAML built without libyaml
|
|
20
|
+
_YamlDumper = yaml.SafeDumper
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def dump_json(document: dict[str, Any], stream: TextIO) -> None:
|
|
24
|
+
json.dump(document, stream, indent=2, ensure_ascii=False)
|
|
25
|
+
stream.write("\n")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def dump_yaml(document: dict[str, Any], stream: TextIO) -> None:
|
|
29
|
+
yaml.dump(
|
|
30
|
+
document,
|
|
31
|
+
stream,
|
|
32
|
+
Dumper=_YamlDumper,
|
|
33
|
+
sort_keys=False,
|
|
34
|
+
allow_unicode=True,
|
|
35
|
+
default_flow_style=False,
|
|
36
|
+
width=120,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
DUMPERS = {"json": dump_json, "yaml": dump_yaml}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def output_paths(output: str | Path, formats: list[Format]) -> dict[Format, Path]:
|
|
44
|
+
"""Directory -> ``<dir>/ms-cloud-endpoints.<ext>``; file path -> that path (suffix replaced per format when
|
|
45
|
+
more than one format is written). A trailing separator marks a directory that does not exist yet."""
|
|
46
|
+
raw = os.fspath(output)
|
|
47
|
+
path = Path(raw)
|
|
48
|
+
if path.is_dir() or raw.endswith(("/", os.sep)):
|
|
49
|
+
return {fmt: path / f"{DEFAULT_BASENAME}{EXTENSIONS[fmt]}" for fmt in formats}
|
|
50
|
+
if len(formats) == 1:
|
|
51
|
+
return {formats[0]: path}
|
|
52
|
+
return {fmt: path.with_suffix(EXTENSIONS[fmt]) for fmt in formats}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _atomic_write(path: Path, fmt: Format, document: dict[str, Any]) -> None:
|
|
56
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
57
|
+
fd, tmp_name = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=path.parent)
|
|
58
|
+
try:
|
|
59
|
+
os.fchmod(fd, 0o644)
|
|
60
|
+
with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as stream:
|
|
61
|
+
DUMPERS[fmt](document, stream)
|
|
62
|
+
os.replace(tmp_name, path)
|
|
63
|
+
except BaseException:
|
|
64
|
+
Path(tmp_name).unlink(missing_ok=True)
|
|
65
|
+
raise
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def write_document(document: dict[str, Any], formats: list[Format], output: str | Path | None) -> list[Path]:
|
|
69
|
+
if output is None:
|
|
70
|
+
if len(formats) != 1:
|
|
71
|
+
raise ValueError("writing more than one format requires --output")
|
|
72
|
+
DUMPERS[formats[0]](document, sys.stdout)
|
|
73
|
+
return []
|
|
74
|
+
paths = output_paths(output, formats)
|
|
75
|
+
for fmt, path in paths.items():
|
|
76
|
+
_atomic_write(path, fmt, document)
|
|
77
|
+
return list(paths.values())
|
|
File without changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Collectors for each documented Microsoft endpoint source."""
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Microsoft 365 IP Address and URL web service (https://endpoints.office.com)."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Any
|
|
5
|
+
from urllib.parse import quote, urlencode
|
|
6
|
+
|
|
7
|
+
from ..fetch import Fetcher
|
|
8
|
+
from ..model import SourceResult
|
|
9
|
+
from ..netutil import normalise_domain, split_prefixes, utc_now
|
|
10
|
+
|
|
11
|
+
log = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
BASE_URL = "https://endpoints.office.com"
|
|
14
|
+
DOCS_URL = "https://learn.microsoft.com/en-us/microsoft-365/enterprise/microsoft-365-ip-web-service"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _url(method: str, instance: str, client_request_id: str) -> str:
|
|
18
|
+
return f"{BASE_URL}/{method}/{quote(instance)}?{urlencode({'ClientRequestId': client_request_id})}"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def normalise_endpoint_set(record: dict[str, Any], *, include_ipv6: bool = True) -> dict[str, Any]:
|
|
22
|
+
ipv4, ipv6 = split_prefixes(record.get("ips", []))
|
|
23
|
+
return {
|
|
24
|
+
"id": record.get("id"),
|
|
25
|
+
"service_area": record.get("serviceArea"),
|
|
26
|
+
"service_area_display_name": record.get("serviceAreaDisplayName"),
|
|
27
|
+
"category": record.get("category"),
|
|
28
|
+
"required": record.get("required"),
|
|
29
|
+
"express_route": record.get("expressRoute"),
|
|
30
|
+
"urls": [normalise_domain(url) for url in record.get("urls", [])],
|
|
31
|
+
"ipv4": ipv4,
|
|
32
|
+
"ipv6": ipv6 if include_ipv6 else [],
|
|
33
|
+
"tcp_ports": record.get("tcpPorts"),
|
|
34
|
+
"udp_ports": record.get("udpPorts"),
|
|
35
|
+
"notes": record.get("notes"),
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def collect(
|
|
40
|
+
fetcher: Fetcher, instances: list[str], client_request_id: str, *, include_ipv6: bool = True
|
|
41
|
+
) -> SourceResult:
|
|
42
|
+
data: dict[str, Any] = {}
|
|
43
|
+
sources: list[dict[str, Any]] = []
|
|
44
|
+
for instance in instances:
|
|
45
|
+
version_url = _url("version", instance, client_request_id)
|
|
46
|
+
endpoints_url = _url("endpoints", instance, client_request_id)
|
|
47
|
+
log.info("Fetching Microsoft 365 endpoints for %s", instance)
|
|
48
|
+
version = fetcher.get_json(version_url)
|
|
49
|
+
endpoint_sets = fetcher.get_json(endpoints_url)
|
|
50
|
+
if not isinstance(endpoint_sets, list):
|
|
51
|
+
raise ValueError(f"unexpected Microsoft 365 endpoints response for {instance}: {type(endpoint_sets)}")
|
|
52
|
+
latest = version.get("latest") if isinstance(version, dict) else None
|
|
53
|
+
data[instance] = {
|
|
54
|
+
"version": latest,
|
|
55
|
+
"endpoint_sets": [normalise_endpoint_set(record, include_ipv6=include_ipv6) for record in endpoint_sets],
|
|
56
|
+
}
|
|
57
|
+
sources.append(
|
|
58
|
+
{
|
|
59
|
+
"source": "m365",
|
|
60
|
+
"scope": instance,
|
|
61
|
+
"url": endpoints_url,
|
|
62
|
+
"documentation": DOCS_URL,
|
|
63
|
+
"version": latest,
|
|
64
|
+
"fetched_at": utc_now(),
|
|
65
|
+
"status": "ok",
|
|
66
|
+
}
|
|
67
|
+
)
|
|
68
|
+
return SourceResult(data, sources)
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Heuristic parser for "Other endpoints not included in the Microsoft 365 IP Address and URL web service"."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import re
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from ..fetch import Fetcher
|
|
8
|
+
from ..html_tables import Node, find_column, parse_html, table_rows
|
|
9
|
+
from ..model import SourceResult
|
|
10
|
+
from ..netutil import extract_domains, extract_prefixes, utc_now
|
|
11
|
+
|
|
12
|
+
log = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
URL = "https://learn.microsoft.com/en-us/microsoft-365/enterprise/additional-office365-ip-addresses-and-urls"
|
|
15
|
+
|
|
16
|
+
# Labels used inside the Destination column to scope the following values to one cloud.
|
|
17
|
+
CLOUD_LABELS: tuple[tuple[str, re.Pattern[str]], ...] = (
|
|
18
|
+
("china", re.compile(r"^\s*(?:Microsoft Cloud China[^:]*|[^:]*21\s*Vianet[^:]*):", re.IGNORECASE)),
|
|
19
|
+
("usgov", re.compile(r"^\s*(?:Microsoft Cloud for US Government|GCC High[^:]*|DoD[^:]*):", re.IGNORECASE)),
|
|
20
|
+
("public", re.compile(r"^\s*(?:Public Cloud|Worldwide[^:]*):", re.IGNORECASE)),
|
|
21
|
+
)
|
|
22
|
+
CHINA_PURPOSE_RE = re.compile(r"21\s*Via\s*net|\bChina\b", re.IGNORECASE)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _segments(lines: list[str], default_cloud: str) -> dict[str, list[str]]:
|
|
26
|
+
"""Group destination lines by the cloud label that precedes them."""
|
|
27
|
+
segments: dict[str, list[str]] = {}
|
|
28
|
+
cloud = default_cloud
|
|
29
|
+
for line in lines:
|
|
30
|
+
for label, pattern in CLOUD_LABELS:
|
|
31
|
+
if match := pattern.match(line):
|
|
32
|
+
cloud = label
|
|
33
|
+
line = line[match.end() :]
|
|
34
|
+
break
|
|
35
|
+
segments.setdefault(cloud, []).append(line)
|
|
36
|
+
return segments
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _values(text: str) -> dict[str, list[str]]:
|
|
40
|
+
ipv4, ipv6 = extract_prefixes(text)
|
|
41
|
+
return {"domains": extract_domains(text), "ipv4": ipv4, "ipv6": ipv6}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def parse_additional_endpoints(html: str) -> list[dict[str, Any]]:
|
|
45
|
+
root = parse_html(html)
|
|
46
|
+
for table in root.find_all("table"):
|
|
47
|
+
headers, rows = table_rows(table)
|
|
48
|
+
try:
|
|
49
|
+
row_col = find_column(headers, "row")
|
|
50
|
+
purpose_col = find_column(headers, "purpose")
|
|
51
|
+
destination_col = find_column(headers, "destination")
|
|
52
|
+
except LookupError:
|
|
53
|
+
continue
|
|
54
|
+
try:
|
|
55
|
+
type_col: int | None = find_column(headers, "type")
|
|
56
|
+
except LookupError:
|
|
57
|
+
type_col = None
|
|
58
|
+
try:
|
|
59
|
+
source_col: int | None = find_column(headers, "source")
|
|
60
|
+
except LookupError:
|
|
61
|
+
source_col = None
|
|
62
|
+
|
|
63
|
+
entries: list[dict[str, Any]] = []
|
|
64
|
+
for cells in rows:
|
|
65
|
+
|
|
66
|
+
def cell_lines(index: int | None, cells: list[Node] = cells) -> list[str]:
|
|
67
|
+
return cells[index].lines() if index is not None and index < len(cells) else []
|
|
68
|
+
|
|
69
|
+
purpose_lines = cell_lines(purpose_col)
|
|
70
|
+
destination_lines = cell_lines(destination_col)
|
|
71
|
+
purpose = "\n".join(purpose_lines)
|
|
72
|
+
default_cloud = "china" if CHINA_PURPOSE_RE.search(purpose) else "public"
|
|
73
|
+
# Some "Trusted Sites" rows only name their FQDNs in the purpose text.
|
|
74
|
+
scan_lines = destination_lines or purpose_lines
|
|
75
|
+
by_cloud = {
|
|
76
|
+
cloud: _values("\n".join(lines)) for cloud, lines in _segments(scan_lines, default_cloud).items()
|
|
77
|
+
}
|
|
78
|
+
row_text = cell_lines(row_col)
|
|
79
|
+
entries.append(
|
|
80
|
+
{
|
|
81
|
+
"row": row_text[0] if row_text else None,
|
|
82
|
+
"purpose": purpose,
|
|
83
|
+
"source": "\n".join(cell_lines(source_col)) or None,
|
|
84
|
+
"destination_text": "\n".join(destination_lines),
|
|
85
|
+
"type": "\n".join(cell_lines(type_col)) or None,
|
|
86
|
+
**_values("\n".join(scan_lines)),
|
|
87
|
+
"by_cloud": by_cloud,
|
|
88
|
+
}
|
|
89
|
+
)
|
|
90
|
+
if entries:
|
|
91
|
+
return entries
|
|
92
|
+
raise LookupError("no table with Row/Purpose/Destination columns found on the additional endpoints page")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def collect(fetcher: Fetcher, *, include_ipv6: bool = True) -> SourceResult:
|
|
96
|
+
log.info("Fetching Microsoft 365 additional endpoints page")
|
|
97
|
+
rows = parse_additional_endpoints(fetcher.get_text(URL))
|
|
98
|
+
if not include_ipv6:
|
|
99
|
+
for row in rows:
|
|
100
|
+
row["ipv6"] = []
|
|
101
|
+
for values in row["by_cloud"].values():
|
|
102
|
+
values["ipv6"] = []
|
|
103
|
+
source = {"source": "m365_additional", "scope": None, "url": URL, "version": None,
|
|
104
|
+
"fetched_at": utc_now(), "status": "ok"} # fmt: skip
|
|
105
|
+
return SourceResult({"source_url": URL, "rows": rows}, [source])
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""Azure Private Endpoint private DNS zone values and the Azure Backup geo-code mapping (Microsoft Learn pages)."""
|
|
2
|
+
|
|
3
|
+
import itertools
|
|
4
|
+
import logging
|
|
5
|
+
import re
|
|
6
|
+
import xml.etree.ElementTree as ET
|
|
7
|
+
from collections.abc import Mapping, Sequence
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from ..fetch import Fetcher
|
|
12
|
+
from ..html_tables import Node, find_column, parse_html, section_nodes, table_rows
|
|
13
|
+
from ..model import SourceResult
|
|
14
|
+
from ..netutil import PLACEHOLDER_RE, utc_now
|
|
15
|
+
|
|
16
|
+
log = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
PRIVATE_DNS_URL = "https://learn.microsoft.com/en-us/azure/private-link/private-endpoint-dns"
|
|
19
|
+
GEO_CODES_URL = "https://learn.microsoft.com/en-us/azure/backup/scripts/geo-code-list"
|
|
20
|
+
|
|
21
|
+
TEMPLATE_RE = re.compile(r"(?:[A-Za-z0-9*-]|\{[A-Za-z]+\})+(?:\.(?:[A-Za-z0-9*-]|\{[A-Za-z]+\})+)*\.[A-Za-z]{2,63}")
|
|
22
|
+
# Placeholders that can be expanded, mapped to the value list they are expanded from.
|
|
23
|
+
REGION_PLACEHOLDERS = frozenset({"regionname", "region"})
|
|
24
|
+
GEO_CODE_PLACEHOLDERS = frozenset({"regioncode"})
|
|
25
|
+
SUP = frozenset({"sup"})
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True, slots=True)
|
|
29
|
+
class PrivateDnsSection:
|
|
30
|
+
label: str
|
|
31
|
+
section_id: str
|
|
32
|
+
regions: Sequence[str] = ()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def parse_geo_codes(html: str) -> list[dict[str, str]]:
|
|
36
|
+
"""Extract ``GeoCodeRegionNameMap`` entries from the XML sample on the geo-code Learn page."""
|
|
37
|
+
root = parse_html(html)
|
|
38
|
+
for code in root.find_all("code") + root.find_all("pre"):
|
|
39
|
+
text = code.text(lines=True)
|
|
40
|
+
if "GeoCodeList" not in text:
|
|
41
|
+
continue
|
|
42
|
+
start = text.find("<GeoCodeList")
|
|
43
|
+
end = text.find("</GeoCodeList>")
|
|
44
|
+
if start < 0 or end < 0:
|
|
45
|
+
continue
|
|
46
|
+
xml_root = ET.fromstring(text[start : end + len("</GeoCodeList>")])
|
|
47
|
+
return [
|
|
48
|
+
{
|
|
49
|
+
"geo_code": element.attrib["GeoCode"],
|
|
50
|
+
"region_name": element.attrib["RegionName"],
|
|
51
|
+
"region": element.attrib["RegionName"].replace(" ", "").lower(),
|
|
52
|
+
}
|
|
53
|
+
for element in xml_root.iter("GeoCodeRegionNameMap")
|
|
54
|
+
if "GeoCode" in element.attrib and "RegionName" in element.attrib
|
|
55
|
+
]
|
|
56
|
+
raise LookupError("GeoCodeList XML block not found on the geo-code page")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _footnote_texts(nodes: list[Node]) -> dict[str, str]:
|
|
60
|
+
"""Footnotes are paragraphs that start with ``<sup>N</sup>``."""
|
|
61
|
+
footnotes: dict[str, str] = {}
|
|
62
|
+
for node in nodes:
|
|
63
|
+
if node.tag != "p":
|
|
64
|
+
continue
|
|
65
|
+
first = next((child for child in node.children if not (isinstance(child, str) and not child.strip())), None)
|
|
66
|
+
if isinstance(first, Node) and first.tag == "sup":
|
|
67
|
+
number = first.text()
|
|
68
|
+
text = node.text(skip=SUP)
|
|
69
|
+
if number and text:
|
|
70
|
+
footnotes[number] = text
|
|
71
|
+
return footnotes
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _cell_values(cell: Node) -> list[str]:
|
|
75
|
+
values: list[str] = []
|
|
76
|
+
for line in cell.lines(skip=SUP):
|
|
77
|
+
values.extend(part for part in line.split() if part)
|
|
78
|
+
return values
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _template_values(cell: Node) -> list[str]:
|
|
82
|
+
"""Domain templates in a cell, lower-cased except for ``{placeholder}`` names; prose is dropped."""
|
|
83
|
+
templates: list[str] = []
|
|
84
|
+
for value in _cell_values(cell):
|
|
85
|
+
value = value.strip().rstrip(".,;")
|
|
86
|
+
if not TEMPLATE_RE.fullmatch(value):
|
|
87
|
+
continue
|
|
88
|
+
parts = PLACEHOLDER_RE.split(value)
|
|
89
|
+
# re.split with one group yields [text, name, text, name, ...]; rebuild with lower-cased text parts.
|
|
90
|
+
templates.append("".join(part.lower() if index % 2 == 0 else f"{{{part}}}" for index, part in enumerate(parts)))
|
|
91
|
+
return templates
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def placeholders(template: str) -> set[str]:
|
|
95
|
+
return {match.group("name") for match in PLACEHOLDER_RE.finditer(template)}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def expand_template(template: str, values: Mapping[str, Sequence[str]]) -> tuple[list[str], set[str]]:
|
|
99
|
+
"""Expand a zone template. Returns ``(concrete names, unresolved placeholder names)``."""
|
|
100
|
+
names = sorted(placeholders(template))
|
|
101
|
+
if not names:
|
|
102
|
+
return [template], set()
|
|
103
|
+
unresolved = {name for name in names if not values.get(name.lower())}
|
|
104
|
+
if unresolved:
|
|
105
|
+
return [], unresolved
|
|
106
|
+
expanded: list[str] = []
|
|
107
|
+
for combination in itertools.product(*(values[name.lower()] for name in names)):
|
|
108
|
+
result = template
|
|
109
|
+
for name, value in zip(names, combination, strict=True):
|
|
110
|
+
result = result.replace(f"{{{name}}}", value)
|
|
111
|
+
expanded.append(result)
|
|
112
|
+
return expanded, set()
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _expand_all(templates: list[str], values: Mapping[str, Sequence[str]]) -> tuple[list[str], set[str]]:
|
|
116
|
+
expanded: dict[str, None] = {}
|
|
117
|
+
unresolved: set[str] = set()
|
|
118
|
+
for template in templates:
|
|
119
|
+
names, missing = expand_template(template, values)
|
|
120
|
+
expanded.update(dict.fromkeys(names))
|
|
121
|
+
unresolved |= missing
|
|
122
|
+
return list(expanded), unresolved
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def parse_private_dns_section(
|
|
126
|
+
html_root: Node, section: PrivateDnsSection, geo_codes: Sequence[str]
|
|
127
|
+
) -> list[dict[str, Any]]:
|
|
128
|
+
nodes = section_nodes(html_root, section.section_id)
|
|
129
|
+
footnotes = _footnote_texts(nodes)
|
|
130
|
+
values: dict[str, Sequence[str]] = {}
|
|
131
|
+
if section.regions:
|
|
132
|
+
values |= dict.fromkeys(REGION_PLACEHOLDERS, list(section.regions))
|
|
133
|
+
if geo_codes:
|
|
134
|
+
values |= dict.fromkeys(GEO_CODE_PLACEHOLDERS, list(geo_codes))
|
|
135
|
+
|
|
136
|
+
entries: list[dict[str, Any]] = []
|
|
137
|
+
category: str | None = None
|
|
138
|
+
for node in nodes:
|
|
139
|
+
if node.tag in ("h3", "h4"):
|
|
140
|
+
category = node.text()
|
|
141
|
+
continue
|
|
142
|
+
if node.tag != "table":
|
|
143
|
+
continue
|
|
144
|
+
headers, rows = table_rows(node)
|
|
145
|
+
try:
|
|
146
|
+
resource_col = find_column(headers, "resource type")
|
|
147
|
+
subresource_col = find_column(headers, "subresource")
|
|
148
|
+
zone_col = find_column(headers, "private dns zone")
|
|
149
|
+
forwarder_col = find_column(headers, "forwarder")
|
|
150
|
+
except LookupError:
|
|
151
|
+
log.debug("Skipping table with headers %s in section %s", headers, section.section_id)
|
|
152
|
+
continue
|
|
153
|
+
for cells in rows:
|
|
154
|
+
if len(cells) <= max(resource_col, subresource_col, zone_col, forwarder_col):
|
|
155
|
+
continue
|
|
156
|
+
zone_templates = _template_values(cells[zone_col])
|
|
157
|
+
forwarder_templates = _template_values(cells[forwarder_col])
|
|
158
|
+
zones, unresolved_zones = _expand_all(zone_templates, values)
|
|
159
|
+
forwarders, unresolved_forwarders = _expand_all(forwarder_templates, values)
|
|
160
|
+
row_footnotes = sorted({sup.text() for cell in cells for sup in cell.find_all("sup")})
|
|
161
|
+
entries.append(
|
|
162
|
+
{
|
|
163
|
+
"category": category,
|
|
164
|
+
"resource_type": cells[resource_col].text(skip=SUP),
|
|
165
|
+
"subresources": _cell_values(cells[subresource_col]),
|
|
166
|
+
"zone_templates": zone_templates,
|
|
167
|
+
"zones": zones,
|
|
168
|
+
"forwarder_templates": forwarder_templates,
|
|
169
|
+
"forwarders": forwarders,
|
|
170
|
+
"unresolved_placeholders": sorted(unresolved_zones | unresolved_forwarders),
|
|
171
|
+
"footnotes": [footnotes[number] for number in row_footnotes if number in footnotes],
|
|
172
|
+
}
|
|
173
|
+
)
|
|
174
|
+
if not entries:
|
|
175
|
+
raise LookupError(f"no private DNS zone tables found in section #{section.section_id}")
|
|
176
|
+
return entries
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def collect(fetcher: Fetcher, sections: list[PrivateDnsSection]) -> SourceResult:
|
|
180
|
+
sources: list[dict[str, Any]] = []
|
|
181
|
+
log.info("Fetching Azure Backup geo-codes")
|
|
182
|
+
geo_codes = parse_geo_codes(fetcher.get_text(GEO_CODES_URL))
|
|
183
|
+
sources.append(
|
|
184
|
+
{"source": "backup_geo_codes", "scope": None, "url": GEO_CODES_URL, "version": None,
|
|
185
|
+
"fetched_at": utc_now(), "status": "ok"}
|
|
186
|
+
) # fmt: skip
|
|
187
|
+
|
|
188
|
+
log.info("Fetching Azure Private Endpoint DNS zone values")
|
|
189
|
+
html_root = parse_html(fetcher.get_text(PRIVATE_DNS_URL))
|
|
190
|
+
codes = [entry["geo_code"].lower() for entry in geo_codes]
|
|
191
|
+
data: dict[str, Any] = {"backup_geo_codes": geo_codes}
|
|
192
|
+
for section in sections:
|
|
193
|
+
data[section.label] = parse_private_dns_section(html_root, section, codes)
|
|
194
|
+
sources.append(
|
|
195
|
+
{"source": "private_dns_zones", "scope": section.label, "url": f"{PRIVATE_DNS_URL}#{section.section_id}",
|
|
196
|
+
"version": None, "fetched_at": utc_now(), "status": "ok"}
|
|
197
|
+
) # fmt: skip
|
|
198
|
+
return SourceResult(data, sources)
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Azure IP Ranges and Service Tags, discovered from the Microsoft Download Center details pages."""
|
|
2
|
+
|
|
3
|
+
import fnmatch
|
|
4
|
+
import logging
|
|
5
|
+
import re
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from ..fetch import Fetcher
|
|
9
|
+
from ..model import SourceResult
|
|
10
|
+
from ..netutil import split_prefixes, utc_now
|
|
11
|
+
|
|
12
|
+
log = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
DETAILS_URL = "https://www.microsoft.com/en-us/download/details.aspx?id={download_id}"
|
|
15
|
+
JSON_LINK_RE = re.compile(
|
|
16
|
+
r"https://download\.microsoft\.com/download/[^\"'\s<>]+?/ServiceTags_(?P<cloud>[A-Za-z]+)_(?P<date>\d{8})\.json"
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def discover_json_url(details_html: str, cloud: str) -> str:
|
|
21
|
+
"""Pick the newest ``ServiceTags_<cloud>_YYYYMMDD.json`` link from a download details page."""
|
|
22
|
+
candidates = [match for match in JSON_LINK_RE.finditer(details_html) if match.group("cloud") == cloud]
|
|
23
|
+
if not candidates:
|
|
24
|
+
raise LookupError(f"no ServiceTags_{cloud}_*.json link found on the download page")
|
|
25
|
+
return max(candidates, key=lambda match: match.group("date")).group(0)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def tag_matches(name: str, patterns: list[str] | None) -> bool:
|
|
29
|
+
if not patterns:
|
|
30
|
+
return True
|
|
31
|
+
lowered = name.lower()
|
|
32
|
+
return any(fnmatch.fnmatchcase(lowered, pattern.lower()) for pattern in patterns)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def normalise_tag(value: dict[str, Any], *, include_ipv6: bool = True) -> dict[str, Any]:
|
|
36
|
+
properties = value.get("properties", {})
|
|
37
|
+
ipv4, ipv6 = split_prefixes(properties.get("addressPrefixes", []))
|
|
38
|
+
return {
|
|
39
|
+
"name": value.get("name"),
|
|
40
|
+
"id": value.get("id"),
|
|
41
|
+
"region": properties.get("region") or None,
|
|
42
|
+
"region_id": properties.get("regionId"),
|
|
43
|
+
"system_service": properties.get("systemService") or None,
|
|
44
|
+
"platform": properties.get("platform"),
|
|
45
|
+
"network_features": properties.get("networkFeatures") or [],
|
|
46
|
+
"change_number": properties.get("changeNumber"),
|
|
47
|
+
"ipv4": ipv4,
|
|
48
|
+
"ipv6": ipv6 if include_ipv6 else [],
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def parse_service_tags(
|
|
53
|
+
document: dict[str, Any], *, tag_patterns: list[str] | None = None, include_ipv6: bool = True
|
|
54
|
+
) -> dict[str, Any]:
|
|
55
|
+
values = document.get("values")
|
|
56
|
+
if not isinstance(values, list):
|
|
57
|
+
raise ValueError("service tag document has no 'values' list")
|
|
58
|
+
return {
|
|
59
|
+
"cloud": document.get("cloud"),
|
|
60
|
+
"change_number": document.get("changeNumber"),
|
|
61
|
+
"regions": sorted({region for value in values if (region := value.get("properties", {}).get("region"))}),
|
|
62
|
+
"tags": [
|
|
63
|
+
normalise_tag(value, include_ipv6=include_ipv6)
|
|
64
|
+
for value in values
|
|
65
|
+
if tag_matches(value.get("name", ""), tag_patterns)
|
|
66
|
+
],
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def collect(
|
|
71
|
+
fetcher: Fetcher,
|
|
72
|
+
downloads: list[tuple[str, int]],
|
|
73
|
+
*,
|
|
74
|
+
tag_patterns: list[str] | None = None,
|
|
75
|
+
include_ipv6: bool = True,
|
|
76
|
+
) -> SourceResult:
|
|
77
|
+
data: dict[str, Any] = {}
|
|
78
|
+
sources: list[dict[str, Any]] = []
|
|
79
|
+
for cloud, download_id in downloads:
|
|
80
|
+
details_url = DETAILS_URL.format(download_id=download_id)
|
|
81
|
+
log.info("Discovering Azure service tags download for %s", cloud)
|
|
82
|
+
json_url = discover_json_url(fetcher.get_text(details_url), cloud)
|
|
83
|
+
log.info("Fetching %s", json_url)
|
|
84
|
+
parsed = parse_service_tags(fetcher.get_json(json_url), tag_patterns=tag_patterns, include_ipv6=include_ipv6)
|
|
85
|
+
parsed["source_url"] = json_url
|
|
86
|
+
data[cloud] = parsed
|
|
87
|
+
sources.append(
|
|
88
|
+
{
|
|
89
|
+
"source": "azure_service_tags",
|
|
90
|
+
"scope": cloud,
|
|
91
|
+
"url": json_url,
|
|
92
|
+
"documentation": details_url,
|
|
93
|
+
"version": parsed["change_number"],
|
|
94
|
+
"fetched_at": utc_now(),
|
|
95
|
+
"status": "ok",
|
|
96
|
+
}
|
|
97
|
+
)
|
|
98
|
+
return SourceResult(data, sources)
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Aggregate every collected source into deduplicated domain lists and collapsed IP prefix lists."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterable
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from .netutil import PLACEHOLDER_RE, collapse, root_domain, wildcard_suffix
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class _Accumulator:
|
|
10
|
+
def __init__(self) -> None:
|
|
11
|
+
self.exact: set[str] = set()
|
|
12
|
+
self.wildcard: set[str] = set()
|
|
13
|
+
self.ipv4: list[str] = []
|
|
14
|
+
self.ipv6: list[str] = []
|
|
15
|
+
|
|
16
|
+
def add_domains(self, domains: Iterable[str]) -> None:
|
|
17
|
+
for domain in domains:
|
|
18
|
+
domain = domain.strip().lower()
|
|
19
|
+
if not domain:
|
|
20
|
+
continue
|
|
21
|
+
(self.wildcard if "*" in domain else self.exact).add(domain)
|
|
22
|
+
|
|
23
|
+
def add_templates(self, templates: Iterable[str]) -> None:
|
|
24
|
+
"""Domain templates with ``{placeholder}`` labels are treated like wildcards."""
|
|
25
|
+
self.add_domains(PLACEHOLDER_RE.sub("*", template) for template in templates)
|
|
26
|
+
|
|
27
|
+
def add_prefixes(self, ipv4: Iterable[str], ipv6: Iterable[str]) -> None:
|
|
28
|
+
self.ipv4.extend(ipv4)
|
|
29
|
+
self.ipv6.extend(ipv6)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def build_summary(document: dict[str, Any], clouds: list[str], *, include_ipv6: bool = True) -> dict[str, Any]:
|
|
33
|
+
acc = _Accumulator()
|
|
34
|
+
|
|
35
|
+
for instance in document.get("m365", {}).values():
|
|
36
|
+
for endpoint_set in instance.get("endpoint_sets", []):
|
|
37
|
+
acc.add_domains(endpoint_set.get("urls", []))
|
|
38
|
+
acc.add_prefixes(endpoint_set.get("ipv4", []), endpoint_set.get("ipv6", []))
|
|
39
|
+
|
|
40
|
+
for row in document.get("m365_additional", {}).get("rows", []):
|
|
41
|
+
for cloud, values in row.get("by_cloud", {}).items():
|
|
42
|
+
if cloud in clouds:
|
|
43
|
+
acc.add_domains(values.get("domains", []))
|
|
44
|
+
acc.add_prefixes(values.get("ipv4", []), values.get("ipv6", []))
|
|
45
|
+
|
|
46
|
+
for cloud in document.get("azure_service_tags", {}).values():
|
|
47
|
+
for tag in cloud.get("tags", []):
|
|
48
|
+
acc.add_prefixes(tag.get("ipv4", []), tag.get("ipv6", []))
|
|
49
|
+
|
|
50
|
+
for label, entries in document.get("private_dns_zones", {}).items():
|
|
51
|
+
if label == "backup_geo_codes":
|
|
52
|
+
continue
|
|
53
|
+
for entry in entries:
|
|
54
|
+
acc.add_domains(entry.get("zones", []))
|
|
55
|
+
acc.add_domains(entry.get("forwarders", []))
|
|
56
|
+
acc.add_templates(entry.get("zone_templates", []))
|
|
57
|
+
acc.add_templates(entry.get("forwarder_templates", []))
|
|
58
|
+
|
|
59
|
+
suffixes = {suffix for domain in acc.wildcard if (suffix := wildcard_suffix(domain))}
|
|
60
|
+
roots = {root_domain(domain) for domain in acc.exact | suffixes}
|
|
61
|
+
ipv4 = collapse(acc.ipv4)
|
|
62
|
+
ipv6 = collapse(acc.ipv6) if include_ipv6 else []
|
|
63
|
+
return {
|
|
64
|
+
"domains": {
|
|
65
|
+
"exact": sorted(acc.exact),
|
|
66
|
+
"wildcard": sorted(acc.wildcard),
|
|
67
|
+
"wildcard_suffixes": sorted(suffixes),
|
|
68
|
+
"root_domains": sorted(roots),
|
|
69
|
+
},
|
|
70
|
+
"ipv4": ipv4,
|
|
71
|
+
"ipv6": ipv6,
|
|
72
|
+
"counts": {
|
|
73
|
+
"exact_domains": len(acc.exact),
|
|
74
|
+
"wildcard_domains": len(acc.wildcard),
|
|
75
|
+
"wildcard_suffixes": len(suffixes),
|
|
76
|
+
"root_domains": len(roots),
|
|
77
|
+
"ipv4_prefixes_input": len(set(acc.ipv4)),
|
|
78
|
+
"ipv4_prefixes_collapsed": len(ipv4),
|
|
79
|
+
"ipv6_prefixes_input": len(set(acc.ipv6)) if include_ipv6 else 0,
|
|
80
|
+
"ipv6_prefixes_collapsed": len(ipv6),
|
|
81
|
+
},
|
|
82
|
+
}
|