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,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
+ }