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/cli.py ADDED
@@ -0,0 +1,265 @@
1
+ """sendermap example.com
2
+
3
+ Exit codes, for scripts and agents: 0 the map was built, 2 bad input, 3 the domain's own records
4
+ could not be read, so there is no map to show.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import asyncio
11
+ import contextlib
12
+ import json
13
+ import sys
14
+ from collections.abc import Iterator
15
+ from datetime import UTC, datetime
16
+ from pathlib import Path
17
+ from typing import TextIO
18
+
19
+ import httpx
20
+ from rich.console import Console
21
+
22
+ from . import __version__
23
+ from .dns import DohResolver, Resolver, Status, SystemResolver, doh_client
24
+ from .engine import map_domain
25
+ from .model import SenderMap
26
+ from .reconcile import names, split
27
+ from .render import markdown, text
28
+ from .sources.ct import default_cache
29
+
30
+ MAPPED = 0
31
+ BAD_INPUT = 2
32
+ UNREADABLE = 3
33
+
34
+
35
+ def main(argv: list[str] | None = None) -> int:
36
+ argv = sys.argv[1:] if argv is None else argv
37
+ # No domain is called mcp, since a domain has a dot.
38
+ return _mcp(argv[1:]) if argv[:1] == ["mcp"] else _mapped(argv)
39
+
40
+
41
+ def _mapped(argv: list[str]) -> int:
42
+ args = _parser().parse_args(argv) # argparse exits with 2 on a bad flag
43
+ try:
44
+ args.claimed = _claimed(args)
45
+ except OSError as e:
46
+ print(f"sendermap: cannot read {args.claimed_file}: {e.strerror}", file=sys.stderr)
47
+ return BAD_INPUT
48
+ try:
49
+ result = asyncio.run(_map(args))
50
+ except ValueError as e:
51
+ print(f"sendermap: {e}", file=sys.stderr)
52
+ return BAD_INPUT
53
+ except KeyboardInterrupt:
54
+ return 130
55
+ if unread := unreadable(result):
56
+ print(
57
+ f"sendermap: no map: {result.domain}'s own records could not be read ({unread})",
58
+ file=sys.stderr,
59
+ )
60
+ return UNREADABLE
61
+ try:
62
+ with _destination(args.output) as out:
63
+ _write(result, args, out)
64
+ except OSError as e:
65
+ print(f"sendermap: cannot write {args.output}: {e.strerror}", file=sys.stderr)
66
+ return BAD_INPUT
67
+ return MAPPED
68
+
69
+
70
+ def _mcp(argv: list[str]) -> int:
71
+ """`sendermap mcp`: the MCP server over stdio, until the client closes it."""
72
+ argparse.ArgumentParser(
73
+ prog="sendermap mcp",
74
+ description="Serve Sendermap's tools over MCP on stdio. Needs the mcp extra.",
75
+ ).parse_args(argv)
76
+ try:
77
+ from .mcp_server import serve # noqa: PLC0415 (only this command needs fastmcp)
78
+ except ImportError:
79
+ print(
80
+ "sendermap: the mcp command needs the mcp extra: uvx --from 'sendermap[mcp]' "
81
+ "sendermap mcp",
82
+ file=sys.stderr,
83
+ )
84
+ return BAD_INPUT
85
+ serve()
86
+ return MAPPED
87
+
88
+
89
+ def unreadable(result: SenderMap) -> str:
90
+ """Why the domain's own records could not be read, or "" when they could: its TXT (where
91
+ SPF lives) and its MX both failed to look up, so nothing in the map can be told apart from a
92
+ resolver that is down."""
93
+ own = {f"SPF: TXT at {result.domain}", f"mail servers: MX at {result.domain}"}
94
+ failed = [g for g in result.gaps if g.asked in own and g.happened == Status.FAILED.value]
95
+ return "; ".join(f"{g.asked}: {g.happened}" for g in failed) if len(failed) == len(own) else ""
96
+
97
+
98
+ async def _map(args: argparse.Namespace) -> SenderMap:
99
+ console = Console(stderr=True)
100
+ async with httpx.AsyncClient() as http, doh_client() as dns_http:
101
+ resolver: Resolver = DohResolver(dns_http) if args.doh else SystemResolver()
102
+ # The spinner goes to stderr, and only to a terminal: a pipe or a log gets nothing.
103
+ spinner = console.status(f"Mapping {args.domain}...", spinner="dots")
104
+ with spinner if console.is_terminal else contextlib.nullcontext():
105
+
106
+ async def progress(step: str) -> None:
107
+ if console.is_terminal:
108
+ spinner.update(f"{args.domain}: {step}...")
109
+
110
+ return await map_domain(
111
+ args.domain,
112
+ checked_at=datetime.now(UTC),
113
+ resolver=resolver,
114
+ http=http,
115
+ use_web=not args.no_web,
116
+ use_subdomains=not args.no_subdomains,
117
+ use_ct=args.ct,
118
+ ct_cache=default_cache(),
119
+ progress=progress,
120
+ claimed=args.claimed,
121
+ )
122
+
123
+
124
+ @contextlib.contextmanager
125
+ def _destination(path: str | None) -> Iterator[TextIO]:
126
+ if path is None:
127
+ yield sys.stdout
128
+ return
129
+ with Path(path).open("w", encoding="utf-8") as file:
130
+ yield file
131
+
132
+
133
+ def _write(result: SenderMap, args: argparse.Namespace, out: TextIO) -> None:
134
+ if args.json:
135
+ out.write(json.dumps(result.to_dict(), indent=2, default=str) + "\n")
136
+ elif args.markdown:
137
+ out.write(markdown.render(result, evidence=args.evidence))
138
+ elif out is sys.stdout:
139
+ text.render(result, Console(), evidence=args.evidence)
140
+ else: # a file gets the text view without colour or terminal codes
141
+ text.render(result, Console(file=out, color_system=None, width=100), evidence=args.evidence)
142
+
143
+
144
+ def _claimed(args: argparse.Namespace) -> list[str]:
145
+ """The names given with --claimed and the lines of --claimed-file, in that order."""
146
+ given = split(args.claimed or "")
147
+ if args.claimed_file:
148
+ given += Path(args.claimed_file).read_text(encoding="utf-8").splitlines()
149
+ return names(given)
150
+
151
+
152
+ EPILOG = """\
153
+ commands:
154
+ sendermap mcp serve the map and vendor tools over MCP on stdio (needs the mcp extra).
155
+
156
+ exit codes:
157
+ 0 the map was built and written.
158
+ 2 bad input: a malformed domain, an unknown flag, a --claimed-file that cannot be read, or an
159
+ --output that cannot be written. The message on stderr says which.
160
+ 3 no map: both the TXT and the MX lookups at the domain itself failed, so the resolver, not
161
+ the domain, decided the answer. Try again, or try --doh.
162
+
163
+ Progress shows on stderr while the map is built, and only when stderr is a terminal, so stdout
164
+ and --output hold the map alone. The JSON keeps the sendermap/1 contract (sendermap.schema()):
165
+ it grows by addition only, so ignore fields and values you do not know."""
166
+
167
+
168
+ def _parser() -> argparse.ArgumentParser:
169
+ p = argparse.ArgumentParser(
170
+ prog="sendermap",
171
+ description=(
172
+ "See every service that sends email as a domain, with the public record behind each "
173
+ "one. Everything comes from public sources: DNS, certificate logs and the domain's "
174
+ "own website."
175
+ ),
176
+ epilog=EPILOG,
177
+ formatter_class=argparse.RawDescriptionHelpFormatter,
178
+ )
179
+ p.add_argument(
180
+ "domain",
181
+ help=(
182
+ "The domain to map. A URL or an email address works too: the domain is taken from it."
183
+ ),
184
+ )
185
+ out = p.add_mutually_exclusive_group()
186
+ out.add_argument(
187
+ "--json",
188
+ action="store_true",
189
+ help=(
190
+ "Write the whole map as JSON, schema sendermap/1, for scripts and agents. Every claim, "
191
+ "gap and setup item is in it, with its evidence."
192
+ ),
193
+ )
194
+ out.add_argument(
195
+ "--markdown",
196
+ action="store_true",
197
+ help="Write a Markdown summary, for pasting into a ticket or a report.",
198
+ )
199
+ p.add_argument(
200
+ "--output",
201
+ metavar="PATH",
202
+ help=(
203
+ "Write the map to PATH instead of stdout, in the format chosen (text without colour "
204
+ "when neither --json nor --markdown is given). The file is replaced if it exists."
205
+ ),
206
+ )
207
+ p.add_argument(
208
+ "--evidence",
209
+ action="store_true",
210
+ help=(
211
+ "Show every piece of evidence under each service, not just the one that decided its "
212
+ "tier. Applies to the text and Markdown views; JSON always has all of it."
213
+ ),
214
+ )
215
+ p.add_argument(
216
+ "--no-web",
217
+ action="store_true",
218
+ help=(
219
+ "Do not read the domain's homepage or subprocessor page. The map then says the "
220
+ "website was not checked."
221
+ ),
222
+ )
223
+ p.add_argument(
224
+ "--no-subdomains",
225
+ action="store_true",
226
+ help=(
227
+ "Map the domain itself only, without searching for sending subdomains such as email. "
228
+ "or mg. The map says they were not searched."
229
+ ),
230
+ )
231
+ p.add_argument(
232
+ "--ct",
233
+ action="store_true",
234
+ help=(
235
+ "Also read crt.sh, which holds expired certificates too, and resolve every name the "
236
+ "certificate logs hold now. Slow; each log is cached for a day."
237
+ ),
238
+ )
239
+ p.add_argument(
240
+ "--claimed",
241
+ metavar="NAMES",
242
+ help=(
243
+ "The services the domain's owner says they use, comma separated. The map holds them "
244
+ "against what it found: confirmed, unclaimed, unfound and unknown."
245
+ ),
246
+ )
247
+ p.add_argument(
248
+ "--claimed-file",
249
+ metavar="FILE",
250
+ help="The same as --claimed, one name per line, read from FILE.",
251
+ )
252
+ p.add_argument(
253
+ "--doh",
254
+ action="store_true",
255
+ help=(
256
+ "Look up DNS over HTTPS through Cloudflare instead of this machine's resolver. Use it "
257
+ "where the local resolver cannot be trusted."
258
+ ),
259
+ )
260
+ p.add_argument("--version", action="version", version=f"sendermap {__version__}")
261
+ return p
262
+
263
+
264
+ if __name__ == "__main__":
265
+ raise SystemExit(main())