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.
Files changed (47) hide show
  1. sendermap/__init__.py +23 -0
  2. sendermap/catalog.py +510 -0
  3. sendermap/cli.py +265 -0
  4. sendermap/data/schema/sendermap-1.json +743 -0
  5. sendermap/data/vendors.json +5553 -0
  6. sendermap/data/vendors_pending.json +1405 -0
  7. sendermap/dns.py +252 -0
  8. sendermap/engine.py +365 -0
  9. sendermap/infer.py +58 -0
  10. sendermap/mcp_server.py +257 -0
  11. sendermap/model.py +504 -0
  12. sendermap/proofs.py +89 -0
  13. sendermap/py.typed +0 -0
  14. sendermap/reconcile.py +65 -0
  15. sendermap/render/__init__.py +165 -0
  16. sendermap/render/markdown.py +89 -0
  17. sendermap/render/text.py +144 -0
  18. sendermap/selectors.py +251 -0
  19. sendermap/setup.py +403 -0
  20. sendermap/sources/__init__.py +38 -0
  21. sendermap/sources/bimi.py +16 -0
  22. sendermap/sources/ct.py +366 -0
  23. sendermap/sources/dkim.py +529 -0
  24. sendermap/sources/labels.py +80 -0
  25. sendermap/sources/mta_sts.py +43 -0
  26. sendermap/sources/mx.py +72 -0
  27. sendermap/sources/networks.py +267 -0
  28. sendermap/sources/reports.py +52 -0
  29. sendermap/sources/spf.py +393 -0
  30. sendermap/sources/spf_checks.py +125 -0
  31. sendermap/sources/spf_eval.py +565 -0
  32. sendermap/sources/subdomains.py +104 -0
  33. sendermap/sources/tokens.py +27 -0
  34. sendermap/sources/web.py +184 -0
  35. sendermap/story.py +448 -0
  36. sendermap/testing.py +274 -0
  37. sendermap/web/__init__.py +0 -0
  38. sendermap/web/app.py +279 -0
  39. sendermap/web/static/app.js +488 -0
  40. sendermap/web/static/index.html +113 -0
  41. sendermap/web/static/llms.txt +28 -0
  42. sendermap/web/static/style.css +185 -0
  43. sendermap-0.1.0.dist-info/METADATA +215 -0
  44. sendermap-0.1.0.dist-info/RECORD +47 -0
  45. sendermap-0.1.0.dist-info/WHEEL +4 -0
  46. sendermap-0.1.0.dist-info/entry_points.txt +2 -0
  47. 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