sendermap 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.
- sendermap/__init__.py +23 -0
- sendermap/catalog.py +510 -0
- sendermap/cli.py +265 -0
- sendermap/data/schema/sendermap-1.json +743 -0
- sendermap/data/vendors.json +5553 -0
- sendermap/data/vendors_pending.json +1405 -0
- sendermap/dns.py +252 -0
- sendermap/engine.py +365 -0
- sendermap/infer.py +58 -0
- sendermap/mcp_server.py +257 -0
- sendermap/model.py +504 -0
- sendermap/proofs.py +89 -0
- sendermap/py.typed +0 -0
- sendermap/reconcile.py +65 -0
- sendermap/render/__init__.py +165 -0
- sendermap/render/markdown.py +89 -0
- sendermap/render/text.py +144 -0
- sendermap/selectors.py +251 -0
- sendermap/setup.py +403 -0
- sendermap/sources/__init__.py +38 -0
- sendermap/sources/bimi.py +16 -0
- sendermap/sources/ct.py +366 -0
- sendermap/sources/dkim.py +529 -0
- sendermap/sources/labels.py +80 -0
- sendermap/sources/mta_sts.py +43 -0
- sendermap/sources/mx.py +72 -0
- sendermap/sources/networks.py +267 -0
- sendermap/sources/reports.py +52 -0
- sendermap/sources/spf.py +393 -0
- sendermap/sources/spf_checks.py +125 -0
- sendermap/sources/spf_eval.py +565 -0
- sendermap/sources/subdomains.py +104 -0
- sendermap/sources/tokens.py +27 -0
- sendermap/sources/web.py +184 -0
- sendermap/story.py +448 -0
- sendermap/testing.py +274 -0
- sendermap/web/__init__.py +0 -0
- sendermap/web/app.py +279 -0
- sendermap/web/static/app.js +488 -0
- sendermap/web/static/index.html +113 -0
- sendermap/web/static/llms.txt +28 -0
- sendermap/web/static/style.css +185 -0
- sendermap-0.1.0.dist-info/METADATA +215 -0
- sendermap-0.1.0.dist-info/RECORD +47 -0
- sendermap-0.1.0.dist-info/WHEEL +4 -0
- sendermap-0.1.0.dist-info/entry_points.txt +2 -0
- sendermap-0.1.0.dist-info/licenses/LICENSE +21 -0
sendermap/__init__.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Sendermap: which services send email as a domain, and the public evidence for each."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from importlib import metadata, resources
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .engine import map_domain
|
|
8
|
+
from .model import Finding, SenderMap, Service, Severity, Tier
|
|
9
|
+
|
|
10
|
+
__all__ = ["Finding", "SenderMap", "Service", "Severity", "Tier", "map_domain", "schema"]
|
|
11
|
+
SCHEMA_FILE = "data/schema/sendermap-1.json"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def schema() -> dict[str, Any]:
|
|
15
|
+
"""The JSON Schema of SenderMap.to_dict() under sendermap/1, which grows by addition only."""
|
|
16
|
+
loaded: dict[str, Any] = json.loads(resources.files(__name__).joinpath(SCHEMA_FILE).read_text())
|
|
17
|
+
return loaded
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
try:
|
|
21
|
+
__version__ = metadata.version(__name__) # set at build time from the git tag
|
|
22
|
+
except metadata.PackageNotFoundError: # running from a source tree that was never installed
|
|
23
|
+
__version__ = "0.0.0"
|
sendermap/catalog.py
ADDED
|
@@ -0,0 +1,510 @@
|
|
|
1
|
+
"""The vendor catalog, and every rule for matching a record to a service.
|
|
2
|
+
|
|
3
|
+
All knowledge about services lives in data/vendors.json, one block per service. Every pattern in
|
|
4
|
+
a block carries a proof: a live domain that publishes it, the record, and the date it was
|
|
5
|
+
checked. Loading refuses a pattern without one. This module checks the proofs, indexes the
|
|
6
|
+
patterns and applies the matching rules, each of which was learned on a live domain.
|
|
7
|
+
|
|
8
|
+
data/vendors_pending.json holds patterns with no proof yet. Nothing here reads it.
|
|
9
|
+
|
|
10
|
+
A block may carry `sending_ip`: one address the service sends from, proven by the service's own
|
|
11
|
+
SPF record publishing a range that holds it, or by a host of its whose A record is the address.
|
|
12
|
+
SPF evaluation asks a domain's record about that address.
|
|
13
|
+
|
|
14
|
+
A block may also carry `aliases`: other names a client calls the service by, with no proof, since
|
|
15
|
+
an alias matches text a person typed and nothing in DNS.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import ipaddress
|
|
21
|
+
import json
|
|
22
|
+
import re
|
|
23
|
+
from collections.abc import Iterable, Iterator
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from functools import cache
|
|
26
|
+
from importlib import resources
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
from . import proofs
|
|
30
|
+
from .dns import norm
|
|
31
|
+
from .model import Need
|
|
32
|
+
from .proofs import Proof
|
|
33
|
+
from .selectors import Rule, build_dictionary, parse_rules
|
|
34
|
+
|
|
35
|
+
_HOST_FIELDS = (
|
|
36
|
+
"spf_includes",
|
|
37
|
+
"mx",
|
|
38
|
+
"dkim_cname",
|
|
39
|
+
"reporting",
|
|
40
|
+
"return_path",
|
|
41
|
+
"tracking",
|
|
42
|
+
"delegation_ns",
|
|
43
|
+
)
|
|
44
|
+
# Every field whose entries are a value and its proof. other_cname entries are a label, a
|
|
45
|
+
# target and a proof.
|
|
46
|
+
PATTERN_FIELDS = (
|
|
47
|
+
*_HOST_FIELDS,
|
|
48
|
+
"verification_txt",
|
|
49
|
+
"networks",
|
|
50
|
+
"sending_ip",
|
|
51
|
+
"website",
|
|
52
|
+
"subprocessor_names",
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True, slots=True)
|
|
57
|
+
class Pattern:
|
|
58
|
+
"""One vendor pattern and the proof that it was seen live. `label` is set only for an
|
|
59
|
+
other_cname pattern, whose value is the CNAME target expected at that label."""
|
|
60
|
+
|
|
61
|
+
vendor: str
|
|
62
|
+
field: str
|
|
63
|
+
value: str
|
|
64
|
+
proof: Proof
|
|
65
|
+
label: str | None = None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# A setup item's record, and the block fields whose values it may name.
|
|
69
|
+
SETUP_RECORDS: dict[str, tuple[str, ...]] = {
|
|
70
|
+
"spf": ("spf_includes",),
|
|
71
|
+
"dkim": ("selectors", "dkim_cname"),
|
|
72
|
+
"return_path": ("return_path",),
|
|
73
|
+
"tracking": ("tracking",),
|
|
74
|
+
"mx": ("mx",),
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True, slots=True)
|
|
79
|
+
class SetupRule:
|
|
80
|
+
"""One record a vendor's documentation says a complete setup publishes: which record, whether
|
|
81
|
+
it is needed, the block's own pattern values the setup publishes (an include host, the
|
|
82
|
+
current selector names, a CNAME target, an MX host), and the documentation page that names
|
|
83
|
+
every one of them."""
|
|
84
|
+
|
|
85
|
+
vendor: str
|
|
86
|
+
record: str
|
|
87
|
+
need: Need
|
|
88
|
+
values: tuple[str, ...]
|
|
89
|
+
proof: Proof
|
|
90
|
+
note: str | None = None
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def selectors(self) -> tuple[str, ...]:
|
|
94
|
+
"""For a DKIM item, the values that are selector names, in order."""
|
|
95
|
+
return tuple(v for v in self.values if "." not in v) if self.record == "dkim" else ()
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass(frozen=True, slots=True)
|
|
99
|
+
class HostTable:
|
|
100
|
+
"""Hostname patterns to services. A pattern matches the host itself or any name under it,
|
|
101
|
+
on a label boundary, and the longest match wins: sendgrid.net.evil.example is not SendGrid,
|
|
102
|
+
and u161779.wl030.sendgrid.net (Klaviyo's own SendGrid account) beats sendgrid.net."""
|
|
103
|
+
|
|
104
|
+
patterns: dict[str, str] = field(default_factory=dict)
|
|
105
|
+
|
|
106
|
+
def match(self, host: str | None) -> str | None:
|
|
107
|
+
if not host:
|
|
108
|
+
return None
|
|
109
|
+
labels = norm(host).split(".")
|
|
110
|
+
for start in range(len(labels)): # longest suffix first
|
|
111
|
+
service = self.patterns.get(".".join(labels[start:]))
|
|
112
|
+
if service:
|
|
113
|
+
return service
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@dataclass(frozen=True, slots=True)
|
|
118
|
+
class Catalog:
|
|
119
|
+
services: dict[str, dict[str, Any]]
|
|
120
|
+
patterns: tuple[Pattern, ...]
|
|
121
|
+
rules: tuple[Rule, ...] # every vendor's DKIM selector rules, in file order
|
|
122
|
+
tables: dict[str, HostTable]
|
|
123
|
+
selectors: dict[str, str] # a selector name to the first rule's vendor that claims it
|
|
124
|
+
tokens: tuple[tuple[re.Pattern[str], str], ...]
|
|
125
|
+
other_cnames: dict[str, HostTable]
|
|
126
|
+
networks: dict[str, str]
|
|
127
|
+
clouds: dict[str, str]
|
|
128
|
+
generic_selectors: frozenset[str]
|
|
129
|
+
short_selectors_allowed: frozenset[str]
|
|
130
|
+
cname_only_selectors: frozenset[str]
|
|
131
|
+
hosted_spf: frozenset[str]
|
|
132
|
+
flattening_spf: frozenset[str]
|
|
133
|
+
cname_labels: tuple[str, ...]
|
|
134
|
+
tracking_labels: frozenset[str]
|
|
135
|
+
sending_labels: tuple[str, ...]
|
|
136
|
+
website: dict[str, str]
|
|
137
|
+
# The names a sending service goes by, as a subprocessor page would list it, to the service.
|
|
138
|
+
# A name that is also an ordinary word ("Kit", "Wix") is simply not listed.
|
|
139
|
+
subprocessor_names: dict[str, str]
|
|
140
|
+
# Each vendor's documented setup, one rule per record, in file order.
|
|
141
|
+
setups: dict[str, tuple[SetupRule, ...]] = field(default_factory=dict)
|
|
142
|
+
# Every vendor's name and aliases, normalised, to the vendor.
|
|
143
|
+
names: dict[str, str] = field(default_factory=dict)
|
|
144
|
+
# The one address each service with a sending_ip sends from, in file order.
|
|
145
|
+
sending_ips: dict[str, str] = field(default_factory=dict)
|
|
146
|
+
# Selector names that credit their vendor only where something else already shows it.
|
|
147
|
+
backed_only_selectors: dict[str, str] = field(default_factory=dict)
|
|
148
|
+
backed_only_proofs: dict[str, Proof] = field(default_factory=dict)
|
|
149
|
+
|
|
150
|
+
# ------------------------------------------------------------ lookups by record
|
|
151
|
+
|
|
152
|
+
def spf(self, host: str) -> str | None:
|
|
153
|
+
return self.tables["spf_includes"].match(host)
|
|
154
|
+
|
|
155
|
+
def mx(self, host: str) -> str | None:
|
|
156
|
+
return self.tables["mx"].match(host)
|
|
157
|
+
|
|
158
|
+
def reporting(self, host: str) -> str | None:
|
|
159
|
+
return self.tables["reporting"].match(host)
|
|
160
|
+
|
|
161
|
+
def delegation(self, host: str) -> str | None:
|
|
162
|
+
"""The service whose nameserver `host` is, when a subdomain is delegated to it."""
|
|
163
|
+
return self.tables["delegation_ns"].match(host)
|
|
164
|
+
|
|
165
|
+
def network(self, asn: str) -> str | None:
|
|
166
|
+
return self.networks.get(asn.upper())
|
|
167
|
+
|
|
168
|
+
def token(self, record: str) -> str | None:
|
|
169
|
+
"""The service whose verification token this apex TXT record is, if any."""
|
|
170
|
+
text = record.strip().lower()
|
|
171
|
+
for pattern, service in self.tokens:
|
|
172
|
+
m = pattern.match(text)
|
|
173
|
+
# A prefix with nothing after it verifies nothing.
|
|
174
|
+
if m and len(text[m.end() :].strip()) >= 4:
|
|
175
|
+
return service
|
|
176
|
+
return None
|
|
177
|
+
|
|
178
|
+
def dkim_cname(self, chain: Iterable[str]) -> str | None:
|
|
179
|
+
"""The first hop of a DKIM alias chain that names a service."""
|
|
180
|
+
return next(filter(None, map(self.tables["dkim_cname"].match, chain)), None)
|
|
181
|
+
|
|
182
|
+
def dkim_key(self, selector: str, chain: tuple[str, ...], backed: str | None) -> str | None:
|
|
183
|
+
"""Who a published DKIM key belongs to.
|
|
184
|
+
|
|
185
|
+
1. A CNAME into the service's zone, always: the service hosts the key.
|
|
186
|
+
2. selector1 and selector2 name nobody without that CNAME (Microsoft 365 publishes them
|
|
187
|
+
only as aliases into its own zone).
|
|
188
|
+
3. A backed only name credits its vendor only when backed names that same vendor, and
|
|
189
|
+
names nobody otherwise. s1 and s2 are plain names many hosts use, so Klaviyo earns
|
|
190
|
+
them only where SPF, MX or an NS delegation already shows Klaviyo.
|
|
191
|
+
4. backed: a service the domain's SPF or MX already shows, whose selector list has this
|
|
192
|
+
name. k1 on a Mailgun domain is Mailgun, not Mailchimp.
|
|
193
|
+
5. The name table, unless the name is generic.
|
|
194
|
+
"""
|
|
195
|
+
if by_cname := self.dkim_cname(chain):
|
|
196
|
+
return by_cname
|
|
197
|
+
name = selector.lower()
|
|
198
|
+
if name in self.cname_only_selectors:
|
|
199
|
+
return None
|
|
200
|
+
if owner := self.backed_only_selectors.get(name):
|
|
201
|
+
return owner if backed == owner else None
|
|
202
|
+
if backed:
|
|
203
|
+
return backed
|
|
204
|
+
if self.is_generic(name):
|
|
205
|
+
return None
|
|
206
|
+
return self.selectors.get(name)
|
|
207
|
+
|
|
208
|
+
def is_generic(self, selector: str) -> bool:
|
|
209
|
+
s = selector.lower()
|
|
210
|
+
return s in self.generic_selectors or (
|
|
211
|
+
len(s) <= 2 and s not in self.short_selectors_allowed
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
def label_cname(self, label: str, target: str) -> tuple[str, str] | None:
|
|
215
|
+
"""(service, "return path" | "tracking" | label) for a CNAME at <label>.<domain>."""
|
|
216
|
+
label = label.lower()
|
|
217
|
+
if label in self.other_cnames:
|
|
218
|
+
service = self.other_cnames[label].match(target)
|
|
219
|
+
return (service, label) if service else None
|
|
220
|
+
order = [("tracking", "tracking"), ("return path", "return_path")]
|
|
221
|
+
if label not in self.tracking_labels:
|
|
222
|
+
order.reverse()
|
|
223
|
+
for kind, table in order:
|
|
224
|
+
if service := self.tables[table].match(target):
|
|
225
|
+
return service, kind
|
|
226
|
+
return None
|
|
227
|
+
|
|
228
|
+
def website_services(self, html: str, loaded: Iterable[str]) -> dict[str, tuple[str, bool]]:
|
|
229
|
+
"""{service: (host, loaded)} for every service whose website host the page loads a
|
|
230
|
+
script or stylesheet from (loaded is True), or otherwise mentions anywhere in its HTML
|
|
231
|
+
(loaded is False). `loaded` is the hosts of the page's script src and link href URLs."""
|
|
232
|
+
hosts = [norm(h) for h in loaded]
|
|
233
|
+
page = html.lower()
|
|
234
|
+
found: dict[str, tuple[str, bool]] = {}
|
|
235
|
+
for pattern, service in self.website.items():
|
|
236
|
+
if found.get(service, ("", False))[1]:
|
|
237
|
+
continue
|
|
238
|
+
if host := next((h for h in hosts if h == pattern or h.endswith("." + pattern)), None):
|
|
239
|
+
found[service] = (host, True)
|
|
240
|
+
elif service not in found and re.search(rf"(?<![a-z0-9-]){re.escape(pattern)}\b", page):
|
|
241
|
+
found[service] = (pattern, False)
|
|
242
|
+
return found
|
|
243
|
+
|
|
244
|
+
def selectors_of(self, service: str) -> frozenset[str]:
|
|
245
|
+
return frozenset(n for r in self.rules if r.vendor == service for n in r.shape.expand())
|
|
246
|
+
|
|
247
|
+
def backs(self, service: str) -> frozenset[str]:
|
|
248
|
+
"""The selector names a claim for `service` backs: its own list, less any backed only
|
|
249
|
+
name another vendor owns, plus the backed only names it owns."""
|
|
250
|
+
own = {n for n, v in self.backed_only_selectors.items() if v == service}
|
|
251
|
+
listed = {n for n in self.selectors_of(service) if n not in self.backed_only_selectors}
|
|
252
|
+
return frozenset(listed | own)
|
|
253
|
+
|
|
254
|
+
@property
|
|
255
|
+
def probe_selectors(self) -> tuple[str, ...]:
|
|
256
|
+
"""The DKIM sweep: every name the selector rules claim, in rule order, then the generic
|
|
257
|
+
names, which credit nobody by name but show a key is there."""
|
|
258
|
+
return tuple(
|
|
259
|
+
dict.fromkeys((*build_dictionary(self.rules), *sorted(self.generic_selectors)))
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
def named(self, text: str) -> str | None:
|
|
263
|
+
"""The vendor a person means by `text`: its name or one of its aliases, exactly, after
|
|
264
|
+
both are normalised. Nothing fuzzier."""
|
|
265
|
+
return self.names.get(normalise(text))
|
|
266
|
+
|
|
267
|
+
def sending_ip_host(self, service: str) -> str:
|
|
268
|
+
"""The record that proves the service's sending address: its SPF record host or the
|
|
269
|
+
host whose address it is. SPF evaluation gives it as the HELO name."""
|
|
270
|
+
return next(
|
|
271
|
+
norm(p.proof.record)
|
|
272
|
+
for p in self.patterns
|
|
273
|
+
if p.vendor == service and p.field == "sending_ip"
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
def category(self, service: str) -> str:
|
|
277
|
+
return str(self.services.get(service, {}).get("category", "Other"))
|
|
278
|
+
|
|
279
|
+
def is_hosted_spf(self, service: str | None) -> bool:
|
|
280
|
+
return service in self.hosted_spf
|
|
281
|
+
|
|
282
|
+
def spf_sources(self) -> dict[str, list[str]]:
|
|
283
|
+
"""{service: its SPF include hosts} for services whose includes are real records,
|
|
284
|
+
not zones that only hold per-customer names."""
|
|
285
|
+
return {
|
|
286
|
+
name: [h for h in block.get("spf_includes", ()) if "." in h]
|
|
287
|
+
for name, block in self.services.items()
|
|
288
|
+
if block.get("spf_includes")
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
def __iter__(self) -> Iterator[str]:
|
|
292
|
+
return iter(self.services)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
@cache
|
|
296
|
+
def load() -> Catalog:
|
|
297
|
+
raw = json.loads(resources.files(__package__).joinpath("data/vendors.json").read_text())
|
|
298
|
+
return build(raw)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def build(raw: dict[str, Any]) -> Catalog:
|
|
302
|
+
"""Index the vendor data. ValueError if any pattern lacks a valid proof."""
|
|
303
|
+
patterns = tuple(p for name, block in raw["vendors"].items() for p in _patterns(name, block))
|
|
304
|
+
selector_rules = parse_rules(raw["vendors"])
|
|
305
|
+
services = {name: _values(block) for name, block in raw["vendors"].items()}
|
|
306
|
+
rules: dict[str, Any] = raw["rules"]
|
|
307
|
+
backed_only = _backed_only(rules.get("backed_only_selectors", {}), raw["vendors"], rules)
|
|
308
|
+
tables = {f: HostTable({}) for f in _HOST_FIELDS}
|
|
309
|
+
selectors = {n: claims[0].vendor for n, claims in build_dictionary(selector_rules).items()}
|
|
310
|
+
tokens: list[tuple[re.Pattern[str], str]] = []
|
|
311
|
+
other: dict[str, HostTable] = {}
|
|
312
|
+
networks: dict[str, str] = {}
|
|
313
|
+
website: dict[str, str] = {}
|
|
314
|
+
named: dict[str, str] = {}
|
|
315
|
+
for name, block in services.items():
|
|
316
|
+
for text in block.get("subprocessor_names", ()):
|
|
317
|
+
named[text] = name
|
|
318
|
+
for host in block.get("website", ()):
|
|
319
|
+
website[host.lower()] = name
|
|
320
|
+
for f in _HOST_FIELDS:
|
|
321
|
+
for pattern in block.get(f, ()):
|
|
322
|
+
tables[f].patterns[norm(pattern)] = name
|
|
323
|
+
for pattern in block.get("verification_txt", ()):
|
|
324
|
+
tokens.append((re.compile(pattern, re.IGNORECASE), name))
|
|
325
|
+
for entry in block.get("other_cname", ()):
|
|
326
|
+
other.setdefault(entry["label"], HostTable({})).patterns[norm(entry["target"])] = name
|
|
327
|
+
for asn in block.get("networks", ()):
|
|
328
|
+
networks[asn.upper()] = name
|
|
329
|
+
return Catalog(
|
|
330
|
+
services=services,
|
|
331
|
+
patterns=patterns,
|
|
332
|
+
rules=selector_rules,
|
|
333
|
+
tables=tables,
|
|
334
|
+
selectors=selectors,
|
|
335
|
+
tokens=tuple(tokens),
|
|
336
|
+
other_cnames=other,
|
|
337
|
+
networks=networks,
|
|
338
|
+
clouds={k.upper(): v for k, v in rules.get("cloud_networks", {}).items()},
|
|
339
|
+
generic_selectors=frozenset(rules["generic_selectors"]),
|
|
340
|
+
short_selectors_allowed=frozenset(rules["short_selectors_allowed"]),
|
|
341
|
+
cname_only_selectors=frozenset(rules["cname_only_selectors"]),
|
|
342
|
+
hosted_spf=frozenset(rules["hosted_spf"]),
|
|
343
|
+
flattening_spf=frozenset(rules.get("flattening_spf", ())),
|
|
344
|
+
cname_labels=tuple(rules["cname_labels"]),
|
|
345
|
+
tracking_labels=frozenset(rules["tracking_labels"]),
|
|
346
|
+
sending_labels=tuple(rules["sending_labels"]),
|
|
347
|
+
website=website,
|
|
348
|
+
subprocessor_names=named,
|
|
349
|
+
setups={
|
|
350
|
+
name: setup
|
|
351
|
+
for name, block in raw["vendors"].items()
|
|
352
|
+
if (setup := _setup(name, block, selector_rules))
|
|
353
|
+
},
|
|
354
|
+
names=_names(raw["vendors"]),
|
|
355
|
+
sending_ips=_sending_ips(services),
|
|
356
|
+
backed_only_selectors={n: v for n, (v, _) in backed_only.items()},
|
|
357
|
+
backed_only_proofs={n: proof for n, (_, proof) in backed_only.items()},
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
# Words a company name may start or end with that do not tell one service from another.
|
|
362
|
+
_LEADING = ("the",)
|
|
363
|
+
_TRAILING = ("inc", "llc", "ltd")
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def normalise(text: str) -> str:
|
|
367
|
+
"""A service name as a person might type it, reduced for exact matching: casefolded,
|
|
368
|
+
spaces and punctuation gone, a leading "the" and a trailing "inc", "llc" or "ltd" dropped
|
|
369
|
+
when other words remain. "The Mailchimp, Inc." and "mailchimp" are one name."""
|
|
370
|
+
words = re.findall(r"[^\W_]+", text.casefold())
|
|
371
|
+
if len(words) > 1 and words[0] in _LEADING:
|
|
372
|
+
words = words[1:]
|
|
373
|
+
if len(words) > 1 and words[-1] in _TRAILING:
|
|
374
|
+
words = words[:-1]
|
|
375
|
+
return "".join(words)
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _names(vendors: dict[str, Any]) -> dict[str, str]:
|
|
379
|
+
"""Each vendor's name and aliases, normalised, to the vendor. ValueError for an alias that
|
|
380
|
+
is not a string, normalises to nothing, or names a vendor already named the same way (its
|
|
381
|
+
own name or another vendor's name or alias)."""
|
|
382
|
+
names: dict[str, str] = {}
|
|
383
|
+
for vendor, block in vendors.items():
|
|
384
|
+
aliases = block.get("aliases", [])
|
|
385
|
+
if not isinstance(aliases, list):
|
|
386
|
+
raise ValueError(f"{vendor} aliases: not a list")
|
|
387
|
+
for text in (vendor, *aliases):
|
|
388
|
+
if not isinstance(text, str) or not (key := normalise(text)):
|
|
389
|
+
raise ValueError(f"{vendor} alias {text!r}: not a name")
|
|
390
|
+
if key in names:
|
|
391
|
+
raise ValueError(f"{vendor} alias {text!r}: already names {names[key]}")
|
|
392
|
+
names[key] = vendor
|
|
393
|
+
return names
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _backed_only(
|
|
397
|
+
raw: dict[str, Any], vendors: dict[str, Any], rules: dict[str, Any]
|
|
398
|
+
) -> dict[str, tuple[str, Proof]]:
|
|
399
|
+
"""{selector: (vendor, proof)} for the backed only names. ValueError for a vendor not in
|
|
400
|
+
the file, a name also cname only, or a proof that is not a key at that name."""
|
|
401
|
+
found: dict[str, tuple[str, Proof]] = {}
|
|
402
|
+
for name, entry in raw.items():
|
|
403
|
+
where = f"backed only selector {name}"
|
|
404
|
+
vendor = entry.get("vendor")
|
|
405
|
+
if vendor not in vendors:
|
|
406
|
+
raise ValueError(f"{where}: vendor {vendor!r} is not in the file")
|
|
407
|
+
if name.lower() != name or name in rules["cname_only_selectors"]:
|
|
408
|
+
raise ValueError(f"{where}: not a lowercase name outside cname_only_selectors")
|
|
409
|
+
if not entry.get("proof"):
|
|
410
|
+
raise ValueError(f"{where}: no proof")
|
|
411
|
+
proof = proofs.parse(entry["proof"])
|
|
412
|
+
proofs.check(where, proof)
|
|
413
|
+
if proofs.normalise(proof.record) != f"{name}._domainkey.{proofs.normalise(proof.domain)}":
|
|
414
|
+
raise ValueError(f"{where}: proof record {proof.record!r} is not {name}._domainkey")
|
|
415
|
+
found[name] = (vendor, proof)
|
|
416
|
+
return found
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _sending_ips(services: dict[str, dict[str, Any]]) -> dict[str, str]:
|
|
420
|
+
"""{service: its one sending address}. ValueError for more than one, or one that is not an
|
|
421
|
+
IP address."""
|
|
422
|
+
found: dict[str, str] = {}
|
|
423
|
+
for name, block in services.items():
|
|
424
|
+
values = block.get("sending_ip", ())
|
|
425
|
+
if len(values) > 1:
|
|
426
|
+
raise ValueError(f"{name} sending_ip: one address, not {len(values)}")
|
|
427
|
+
for value in values:
|
|
428
|
+
try:
|
|
429
|
+
found[name] = str(ipaddress.ip_address(value))
|
|
430
|
+
except ValueError:
|
|
431
|
+
raise ValueError(f"{name} sending_ip {value!r}: not an IP address") from None
|
|
432
|
+
return found
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _setup(vendor: str, block: dict[str, Any], rules: tuple[Rule, ...]) -> tuple[SetupRule, ...]:
|
|
436
|
+
"""The block's setup list, each item checked: a known record, a known need, values that are
|
|
437
|
+
patterns of this block for that record (none when nothing is needed, at least one
|
|
438
|
+
otherwise), at most one item per record, and a dated proof on an https page of the proof's
|
|
439
|
+
domain."""
|
|
440
|
+
found: list[SetupRule] = []
|
|
441
|
+
for raw in block.get("setup", ()):
|
|
442
|
+
record = raw.get("record")
|
|
443
|
+
where = f"{vendor} setup {record}"
|
|
444
|
+
if record not in SETUP_RECORDS:
|
|
445
|
+
raise ValueError(f"{where}: record is not one of {', '.join(SETUP_RECORDS)}")
|
|
446
|
+
if any(rule.record == record for rule in found):
|
|
447
|
+
raise ValueError(f"{where}: listed twice")
|
|
448
|
+
try:
|
|
449
|
+
need = Need(raw.get("need"))
|
|
450
|
+
except ValueError:
|
|
451
|
+
raise ValueError(f"{where}: need is not required, optional or none") from None
|
|
452
|
+
values = tuple(str(v) for v in raw.get("values", ()))
|
|
453
|
+
if need is Need.NONE and values:
|
|
454
|
+
raise ValueError(f"{where}: a record that is not needed names no values")
|
|
455
|
+
if need is not Need.NONE and not values:
|
|
456
|
+
raise ValueError(f"{where}: a needed record names its values")
|
|
457
|
+
own = _own_values(vendor, block, record, rules)
|
|
458
|
+
if strays := [v for v in values if v.lower() not in own]:
|
|
459
|
+
raise ValueError(f"{where}: {', '.join(strays)} is not a pattern of this block")
|
|
460
|
+
if not raw.get("proof"):
|
|
461
|
+
raise ValueError(f"{where}: no proof")
|
|
462
|
+
proof = proofs.parse(raw["proof"])
|
|
463
|
+
proofs.check(where, proof)
|
|
464
|
+
proofs.check_record(where, "setup", proof)
|
|
465
|
+
found.append(SetupRule(vendor, record, need, values, proof, raw.get("note")))
|
|
466
|
+
return tuple(found)
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def _own_values(
|
|
470
|
+
vendor: str, block: dict[str, Any], record: str, rules: tuple[Rule, ...]
|
|
471
|
+
) -> set[str]:
|
|
472
|
+
"""The values this block's patterns hold for one setup record, lowercase."""
|
|
473
|
+
own: set[str] = set()
|
|
474
|
+
for f in SETUP_RECORDS[record]:
|
|
475
|
+
if f == "selectors":
|
|
476
|
+
own |= {n for r in rules if r.vendor == vendor for n in r.shape.expand()}
|
|
477
|
+
else:
|
|
478
|
+
own |= {str(e["value"]).lower() for e in block.get(f, ())}
|
|
479
|
+
return own
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def _patterns(vendor: str, block: dict[str, Any]) -> list[Pattern]:
|
|
483
|
+
"""Every pattern in one vendor block, each with its proof checked."""
|
|
484
|
+
found: list[Pattern] = []
|
|
485
|
+
for kind in (*PATTERN_FIELDS, "other_cname"):
|
|
486
|
+
for entry in block.get(kind, ()):
|
|
487
|
+
if not isinstance(entry, dict) or not entry.get("proof"):
|
|
488
|
+
raise ValueError(f"{vendor} {kind} {entry!r}: no proof")
|
|
489
|
+
label = entry.get("label") if kind == "other_cname" else None
|
|
490
|
+
value = entry["target"] if kind == "other_cname" else entry["value"]
|
|
491
|
+
where = f"{vendor} {kind} {value}"
|
|
492
|
+
proof = proofs.parse(entry["proof"])
|
|
493
|
+
proofs.check(where, proof)
|
|
494
|
+
proofs.check_record(where, kind, proof)
|
|
495
|
+
found.append(Pattern(vendor, kind, value, proof, label))
|
|
496
|
+
return found
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def _values(block: dict[str, Any]) -> dict[str, Any]:
|
|
500
|
+
"""The block with each proofed entry reduced to what matching uses: its value, or for
|
|
501
|
+
other_cname its label and target."""
|
|
502
|
+
plain = dict(block)
|
|
503
|
+
for kind in PATTERN_FIELDS:
|
|
504
|
+
if kind in block:
|
|
505
|
+
plain[kind] = [entry["value"] for entry in block[kind]]
|
|
506
|
+
if "other_cname" in block:
|
|
507
|
+
plain["other_cname"] = [
|
|
508
|
+
{"label": e["label"], "target": e["target"]} for e in block["other_cname"]
|
|
509
|
+
]
|
|
510
|
+
return plain
|