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/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())
|