spftrace 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.
spftrace/__init__.py ADDED
@@ -0,0 +1,199 @@
1
+ """spftrace: an RFC 7208 SPF evaluator that shows its working.
2
+
3
+ The trace is the product, not a side effect. Every DNS query, macro expansion,
4
+ mechanism evaluation and limit decision is recorded and returned.
5
+
6
+ Quick use:
7
+
8
+ import spftrace
9
+ r = spftrace.check("203.0.113.1", "user@example.com")
10
+ print(r.verdict) # pass | fail | softfail | neutral | none |
11
+ # permerror | temperror
12
+ print(r.to_dict()) # stable JSON-safe structure
13
+
14
+ Inside an async application (FastAPI and friends), await the async form:
15
+
16
+ r = await spftrace.acheck("203.0.113.1", "user@example.com")
17
+
18
+ For full control over DNS, build the resolver and evaluator yourself:
19
+
20
+ from spftrace import Evaluator, Limits, LiveResolver
21
+ resolver = LiveResolver(["192.0.2.53"], timeout=3.0, max_queries=75)
22
+ result = await Evaluator(resolver, Limits()).evaluate(ip, sender, helo)
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import asyncio
27
+ from typing import Iterable
28
+
29
+ from .errors import (
30
+ SpfError,
31
+ SpfNoneError,
32
+ SpfPermError,
33
+ SpfTempError,
34
+ SpfUsageError,
35
+ )
36
+ from .evaluator import (
37
+ DEFAULT_TIME_LIMIT,
38
+ MAX_DNS_TERMS,
39
+ MAX_MX_RECORDS,
40
+ MAX_PTR_NAMES,
41
+ MAX_VOID_LOOKUPS,
42
+ Evaluator,
43
+ Limits,
44
+ )
45
+ from .parser import Term, parse
46
+ from .resolver import BaseResolver, DnsError, LiveResolver, ZoneResolver
47
+ from .trace import DnsQueryRecord, Event, Result, Trace
48
+
49
+ __version__ = "0.1.0"
50
+
51
+ #: Used only when no resolver and no nameservers are supplied. Callers that care
52
+ #: which resolver answers should pass their own; this library never reads the
53
+ #: system resolver configuration or any environment variable.
54
+ DEFAULT_NAMESERVERS: tuple[str, ...] = ("8.8.8.8",)
55
+
56
+ #: Hard cap on real DNS queries per evaluation. This is not the RFC's 10-term
57
+ #: limit: that counts terms, not lookups, and ten `mx` terms with ten MX records
58
+ #: each is 10 terms but 111 queries. This cap stops a hostile zone from turning
59
+ #: one check into unbounded traffic.
60
+ DEFAULT_MAX_QUERIES = 75
61
+
62
+ __all__ = [
63
+ "__version__",
64
+ "acheck",
65
+ "check",
66
+ "BaseResolver",
67
+ "DEFAULT_MAX_QUERIES",
68
+ "DEFAULT_NAMESERVERS",
69
+ "DEFAULT_TIME_LIMIT",
70
+ "DnsError",
71
+ "DnsQueryRecord",
72
+ "Evaluator",
73
+ "Event",
74
+ "Limits",
75
+ "LiveResolver",
76
+ "MAX_DNS_TERMS",
77
+ "MAX_MX_RECORDS",
78
+ "MAX_PTR_NAMES",
79
+ "MAX_VOID_LOOKUPS",
80
+ "Result",
81
+ "SpfError",
82
+ "SpfNoneError",
83
+ "SpfPermError",
84
+ "SpfTempError",
85
+ "SpfUsageError",
86
+ "Term",
87
+ "Trace",
88
+ "ZoneResolver",
89
+ "parse",
90
+ ]
91
+
92
+
93
+ async def acheck(
94
+ ip: str,
95
+ sender: str,
96
+ helo: str = "",
97
+ *,
98
+ policy: str | None = None,
99
+ resolver: BaseResolver | None = None,
100
+ nameservers: Iterable[str] | None = None,
101
+ timeout: float = 5.0,
102
+ max_queries: int | None = DEFAULT_MAX_QUERIES,
103
+ time_limit: float = DEFAULT_TIME_LIMIT,
104
+ receiver: str = "spftrace",
105
+ audit: bool = False,
106
+ ) -> Result:
107
+ """Evaluate SPF for `ip` sending as `sender`, returning a traced Result.
108
+
109
+ RFC outcomes are never exceptions. A malformed record, a blown lookup limit
110
+ or an exhausted query budget all come back as a `permerror` verdict with the
111
+ reason in the trace, so a caller never has to wrap this in try/except just to
112
+ survive a hostile zone. Exceptions are reserved for caller mistakes.
113
+
114
+ Args:
115
+ ip: the connecting IP. IPv4-mapped IPv6 is normalised to IPv4.
116
+ sender: MAIL FROM address. A bare domain is treated as postmaster@domain.
117
+ helo: HELO/EHLO name. Defaults to the sender domain.
118
+ policy: evaluate this record instead of looking one up in DNS. Useful for
119
+ testing a record you have not published yet.
120
+ resolver: a ready-made resolver. Mutually exclusive with `nameservers`.
121
+ Supply your own to add caching, or a ZoneResolver to test offline.
122
+ nameservers: resolver addresses to query. Defaults to DEFAULT_NAMESERVERS.
123
+ timeout: per-query DNS timeout in seconds.
124
+ max_queries: hard cap on real DNS queries, or None for no cap.
125
+ time_limit: overall deadline in seconds, checked between terms.
126
+ receiver: value of the %{r} macro.
127
+ audit: keep counting past the 10-term limit to report what a record
128
+ really needs. The verdict is still forced to permerror, so this
129
+ changes visibility and never the answer.
130
+
131
+ Raises:
132
+ SpfUsageError: both `resolver` and `nameservers` were supplied.
133
+ """
134
+ if resolver is not None and nameservers is not None:
135
+ raise SpfUsageError(
136
+ "pass either resolver or nameservers, not both: a supplied resolver "
137
+ "already carries its own nameservers, timeout and query budget"
138
+ )
139
+ if resolver is None:
140
+ resolver = LiveResolver(
141
+ nameservers if nameservers is not None else DEFAULT_NAMESERVERS,
142
+ timeout=timeout,
143
+ max_queries=max_queries,
144
+ )
145
+ evaluator = Evaluator(
146
+ resolver,
147
+ Limits(time_limit=time_limit, audit=audit),
148
+ receiver=receiver,
149
+ policy_override=policy,
150
+ )
151
+ return await evaluator.evaluate(ip, sender, helo)
152
+
153
+
154
+ def check(
155
+ ip: str,
156
+ sender: str,
157
+ helo: str = "",
158
+ *,
159
+ policy: str | None = None,
160
+ resolver: BaseResolver | None = None,
161
+ nameservers: Iterable[str] | None = None,
162
+ timeout: float = 5.0,
163
+ max_queries: int | None = DEFAULT_MAX_QUERIES,
164
+ time_limit: float = DEFAULT_TIME_LIMIT,
165
+ receiver: str = "spftrace",
166
+ audit: bool = False,
167
+ ) -> Result:
168
+ """Blocking form of `acheck`, for scripts and sync code.
169
+
170
+ Raises:
171
+ SpfUsageError: called from inside a running event loop. The evaluator is
172
+ async underneath; from async code, await `acheck` instead. Without
173
+ this check asyncio raises a confusing "cannot be called from a
174
+ running event loop" from several frames down.
175
+ """
176
+ try:
177
+ asyncio.get_running_loop()
178
+ except RuntimeError:
179
+ pass
180
+ else:
181
+ raise SpfUsageError(
182
+ "spftrace.check() cannot be called from a running event loop. "
183
+ "Use 'await spftrace.acheck(...)' instead."
184
+ )
185
+ return asyncio.run(
186
+ acheck(
187
+ ip,
188
+ sender,
189
+ helo,
190
+ policy=policy,
191
+ resolver=resolver,
192
+ nameservers=nameservers,
193
+ timeout=timeout,
194
+ max_queries=max_queries,
195
+ time_limit=time_limit,
196
+ receiver=receiver,
197
+ audit=audit,
198
+ )
199
+ )
spftrace/cli.py ADDED
@@ -0,0 +1,202 @@
1
+ """Command line front end. Renders the trace in the ORF-style step format."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import asyncio
6
+ import json
7
+ import os
8
+ import sys
9
+
10
+ from . import DEFAULT_MAX_QUERIES, DEFAULT_NAMESERVERS, __version__
11
+ from .evaluator import DEFAULT_TIME_LIMIT, Evaluator, Limits
12
+ from .resolver import LiveResolver
13
+ from .trace import Result
14
+
15
+ VERDICT = {
16
+ "pass": "The policy designates the argument IP as a permitted sender.",
17
+ "fail": "The policy does NOT designate the argument IP as a permitted sender.",
18
+ "softfail": "The policy does NOT designate the argument IP as permitted sender, "
19
+ "but it's not quite confident about it.",
20
+ "neutral": "The policy makes no assertion about the argument IP.",
21
+ "none": "No SPF policy was found for the domain.",
22
+ "permerror": "The policy could not be evaluated: permanent error.",
23
+ "temperror": "The policy could not be evaluated: transient error, retry later.",
24
+ }
25
+
26
+
27
+ def render_text(result: Result, dns_server: str = "") -> str:
28
+ lines: list[str] = []
29
+ if dns_server:
30
+ lines.append("PARAMETERS")
31
+ lines.append(f"DNS server: {dns_server}")
32
+ lines.append(f"Evaluation time limit: {int(DEFAULT_TIME_LIMIT)} seconds "
33
+ "(RFC 7208 Section 4.6.4)")
34
+ lines.append("Maximum number of void DNS lookups: 2 "
35
+ "(RFC 7208 Section 4.6.4)")
36
+ lines.append("Standards compliance: RFC 7208 (April 2014)")
37
+ lines.append("")
38
+
39
+ for ev in result.trace.to_list():
40
+ indent = " " * ev["depth"]
41
+ stamp = f"+{ev['at_ms']:.0f} ms"
42
+ kind = ev["kind"]
43
+ head = f"{stamp:>10} {indent}"
44
+ if kind == "check_start":
45
+ lines.append(f"{head}SPF check starting.")
46
+ lines.append(f"{head} IP: {ev['ip']}")
47
+ lines.append(f"{head} Sender: {ev['sender']}")
48
+ lines.append(f"{head} Domain: {ev['domain']}")
49
+ lines.append(f"{head} EHLO/HELO domain: {ev['helo']}")
50
+ elif kind == "txt_lookup":
51
+ lines.append(f"{head}Retrieving DNS TXT record for \"{ev['domain']}\".")
52
+ if ev["records"]:
53
+ for i, rec in enumerate(ev["records"], 1):
54
+ lines.append(f"{head} Line #{i}: \"{rec}\"")
55
+ else:
56
+ lines.append(f"{head} No TXT records ({ev['rcode']}).")
57
+ elif kind == "policy_override":
58
+ lines.append(f"{head}Policy supplied by user for \"{ev['domain']}\" "
59
+ "(no DNS lookup, no DNS term consumed).")
60
+ lines.append(f"{head} Policy: \"{ev['policy']}\"")
61
+ elif kind == "policy_parsed":
62
+ lines.append(f"{head}The policy passed syntax validation.")
63
+ lines.append(f"{head}Evaluating SPF mechanisms.")
64
+ elif kind == "parse_error":
65
+ lines.append(f"{head}Syntax error: {ev['message']}")
66
+ elif kind == "mech_start":
67
+ lines.append(f"{head}Evaluating mechanism \"{ev['mechanism']}\".")
68
+ lines.append(f"{head} Qualifier: \"{ev['qualifier']}\"")
69
+ if ev.get("arg"):
70
+ lines.append(f"{head} Argument: \"{ev['arg']}\"")
71
+ if ev.get("cidr4") is not None:
72
+ lines.append(f"{head} CIDR length (IPv4): {ev['cidr4']}")
73
+ if ev.get("cidr6") is not None:
74
+ lines.append(f"{head} CIDR length (IPv6): {ev['cidr6']}")
75
+ lines.append(
76
+ f"{head} DNS limits status: DNS terms {ev['dns_terms_used']} of 10 "
77
+ f"allowed. Void lookups {ev['void_used']} of 2 allowed."
78
+ )
79
+ elif kind == "macro_expand" and ev.get("before"):
80
+ lines.append(
81
+ f"{head}Domain argument after macro expansion: \"{ev['after']}\"."
82
+ )
83
+ elif kind == "limit_exceeded":
84
+ lines.append(
85
+ f"{head}DNS lookup limit exceeded at \"{ev['term']}\" "
86
+ f"({ev['used']} of {ev['allowed']}). {ev['note']}"
87
+ )
88
+ elif kind == "void_lookup":
89
+ lines.append(
90
+ f"{head}A void DNS lookup was encountered "
91
+ f"({ev['used']} of {ev['allowed']} allowed) at \"{ev['term']}\"."
92
+ )
93
+ elif kind == "family_skip":
94
+ lines.append(
95
+ f"{head}The \"{ev['mechanism']}\" mechanism was {ev['note']}."
96
+ )
97
+ elif kind == "exists_miss":
98
+ lines.append(
99
+ f"{head}\"{ev['target']}\" returned {ev['rcode']}; {ev['note']}."
100
+ )
101
+ elif kind == "audit_override":
102
+ lines.append(
103
+ f"{head}Audit mode: evaluation reached \"{ev['observed']}\" but the "
104
+ f"record needs {ev['dns_terms']} lookups of {ev['allowed']} allowed. "
105
+ f"Returning \"permerror\"."
106
+ )
107
+ elif kind == "recurse_in":
108
+ lines.append(f"{head}Entering recursive evaluation ({ev['via']}).")
109
+ elif kind == "recurse_out":
110
+ lines.append(
111
+ f"{head}Returned from recursive evaluation with \"{ev['result']}\"."
112
+ )
113
+ elif kind == "mech_match":
114
+ lines.append(
115
+ f"{head}The mechanism matched with the \"{ev['result']}\" qualifier."
116
+ )
117
+ elif kind == "mech_nomatch":
118
+ lines.append(f"{head}The mechanism did not match.")
119
+ elif kind == "target_invalid":
120
+ lines.append(f"{head}Target \"{ev['target']}\" is not a valid domain; "
121
+ "mechanism cannot match.")
122
+ elif kind == "domain_invalid":
123
+ lines.append(f"{head}Malformed domain \"{ev['domain']}\".")
124
+ elif kind == "ptr_validated":
125
+ lines.append(f"{head}Forward-confirmed PTR name \"{ev['name']}\".")
126
+ elif kind == "mx_match":
127
+ lines.append(f"{head}Matched via MX host \"{ev['exchange']}\".")
128
+ elif kind == "eval_done":
129
+ lines.append(
130
+ f"{head}Policy evaluation for \"{ev['domain']}\" finished with "
131
+ f"SPF \"{ev['result']}\"."
132
+ )
133
+ elif kind == "exp":
134
+ lines.append(f"{head}Explanation from \"{ev['domain']}\": {ev['text']}")
135
+ elif kind in ("exit", "error", "txt_error"):
136
+ lines.append(f"{head}{ev.get('reason') or ev.get('message')}")
137
+ elif kind == "result":
138
+ pass
139
+
140
+ lines.append("")
141
+ lines.append("TEST SUMMARY")
142
+ lines.append(
143
+ f"The evaluation completed in {result.elapsed_ms:.0f} ms. "
144
+ f"DNS terms used: {result.dns_terms_used} of 10. "
145
+ f"Void lookups: {result.void_lookups_used} of 2."
146
+ )
147
+ lines.append(f"Result: SPF {result.result}")
148
+ lines.append(VERDICT.get(result.result, ""))
149
+ if result.explanation:
150
+ lines.append(f"Explanation: {result.explanation}")
151
+ for warning in result.warnings:
152
+ lines.append(f"Warning: {warning}")
153
+ return "\n".join(lines)
154
+
155
+
156
+ async def run(args: argparse.Namespace) -> int:
157
+ resolver = LiveResolver(
158
+ [args.dns], timeout=args.timeout, max_queries=args.max_queries
159
+ )
160
+ ev = Evaluator(
161
+ resolver,
162
+ Limits(time_limit=args.time_limit, audit=args.audit),
163
+ receiver=args.receiver,
164
+ policy_override=args.policy,
165
+ )
166
+ result = await ev.evaluate(args.ip, args.sender, args.helo or "")
167
+ if args.json:
168
+ print(json.dumps(result.to_dict(), indent=2))
169
+ else:
170
+ print(render_text(result, dns_server=args.dns))
171
+ return 0 if result.result in ("pass", "neutral", "softfail", "fail", "none") else 1
172
+
173
+
174
+ def main() -> int:
175
+ p = argparse.ArgumentParser(prog="spftrace", description="SPF evaluator with trace")
176
+ p.add_argument("ip", help="connecting IP address")
177
+ p.add_argument("sender", help="MAIL FROM address (or domain)")
178
+ p.add_argument("--helo", help="HELO/EHLO domain")
179
+ p.add_argument("--policy", help="override the sender domain's SPF record")
180
+ p.add_argument(
181
+ "--dns",
182
+ default=os.environ.get("SPFTRACE_DNS", DEFAULT_NAMESERVERS[0]),
183
+ help="resolver IP (or set SPFTRACE_DNS)",
184
+ )
185
+ p.add_argument(
186
+ "--audit",
187
+ action="store_true",
188
+ help="keep counting lookups past the 10 limit; result stays permerror",
189
+ )
190
+ p.add_argument(
191
+ "--max-queries", type=int, default=DEFAULT_MAX_QUERIES, dest="max_queries"
192
+ )
193
+ p.add_argument("--version", action="version", version=f"spftrace {__version__}")
194
+ p.add_argument("--receiver", default="spftrace", help="value for the %%{r} macro")
195
+ p.add_argument("--timeout", type=float, default=5.0, help="per-query timeout")
196
+ p.add_argument("--time-limit", type=float, default=DEFAULT_TIME_LIMIT)
197
+ p.add_argument("--json", action="store_true", help="emit the raw trace as JSON")
198
+ return asyncio.run(run(p.parse_args()))
199
+
200
+
201
+ if __name__ == "__main__":
202
+ sys.exit(main())
spftrace/errors.py ADDED
@@ -0,0 +1,28 @@
1
+ """SPF evaluation errors, mapped to RFC 7208 result codes."""
2
+ from __future__ import annotations
3
+
4
+
5
+ class SpfError(Exception):
6
+ result = "permerror"
7
+
8
+
9
+ class SpfPermError(SpfError):
10
+ result = "permerror"
11
+
12
+
13
+ class SpfTempError(SpfError):
14
+ result = "temperror"
15
+
16
+
17
+ class SpfNoneError(SpfError):
18
+ """Not an error as such: check_host() exits with 'none'."""
19
+
20
+ result = "none"
21
+
22
+
23
+ class SpfUsageError(RuntimeError):
24
+ """Caller error, not an RFC outcome.
25
+
26
+ Raised for mistakes in how spftrace is called, never for anything a remote
27
+ domain's DNS can cause. RFC results always come back as a verdict on Result.
28
+ """