spftrace
An RFC 7208 SPF evaluator that shows its working.
Most SPF libraries answer pass or fail and throw the reasoning away. spftrace
returns the reasoning: every DNS query with its rcode and timing, every macro
expansion before and after, every mechanism with its qualifier and why it did or
did not match, the running DNS-term and void-lookup counters, and the exact term
that broke a limit.
It is a library. There is no web UI here, and no framework dependency. Build your own CLI, API or service on top.
- Own implementation of
check_host(), not a wrapper around another evaluator - Passes all 203 cases of the official openspf.org RFC 7208 test suite
- Async core with a blocking convenience wrapper
- One runtime dependency: dnspython
- Reads no environment variables and no system resolver config. Configuration is explicit, so the consuming application stays in charge
Install
pip install spftrace
Use
import spftrace
result = spftrace.check("203.0.113.1", "user@example.com")
print(result.verdict) # pass, fail, softfail, neutral, none,
# permerror or temperror
print(result.dns_terms_used) # against the RFC limit of 10
print(result.to_dict()) # JSON-safe, with the full event trace
From async code, await the async form instead. Calling check() inside a running
event loop raises SpfUsageError telling you so, rather than a confusing asyncio
error from several frames down.
result = await spftrace.acheck("203.0.113.1", "user@example.com")
In FastAPI
from fastapi import FastAPI
import spftrace
app = FastAPI()
@app.get("/check")
async def check(ip: str, sender: str):
result = await spftrace.acheck(
ip, sender, nameservers=["1.1.1.1"], max_queries=75
)
return result.to_dict()
Do not let users pass arbitrary resolver addresses through to nameservers. That
turns your service into an SSRF-ish DNS proxy. Offer a fixed set of resolvers and
map the user's choice to one server side.
Options
await spftrace.acheck(
ip,
sender,
helo="mail.example.net", # defaults to the sender domain
policy="v=spf1 -all", # evaluate this instead of looking one up
nameservers=["192.0.2.53"], # defaults to 8.8.8.8
timeout=5.0, # per-query DNS timeout
max_queries=75, # hard cap on real lookups
time_limit=20.0, # deadline, checked between terms and before
# every DNS query
receiver="mta01", # value of the %{r} macro
audit=False, # see below
)
policy= evaluates a record you paste in rather than one published in DNS, which
is how you test a change before shipping it.
Bring your own resolver
Pass a resolver instead of nameservers to point at your own DNS, or to test
with no network at all.
from spftrace import Evaluator, Limits, ZoneResolver
zone = {"e.com": [("TXT", "v=spf1 ip4:1.2.3.0/24 -all")]}
result = await Evaluator(ZoneResolver(zone), Limits()).evaluate("1.2.3.4", "a@e.com")
Subclass BaseResolver and implement
async _lookup(name, rtype) -> (rcode, answers) for anything else. That method is
the whole contract: return the rcode and the answer strings, and let the library
do the rest.
A resolver is transport only. It holds nameservers, a timeout and a socket.
It holds no counters, no cache and no trace. Everything scoped to a single check
lives in an EvaluationSession that evaluate() builds fresh each time, so one
resolver can serve many checks, including concurrent ones, without leaking state
between them.
resolver = LiveResolver(["192.0.2.53"]) # build once, reuse for the process
async def check(ip, sender):
return await spftrace.acheck(ip, sender, resolver=resolver)
This is a change in 0.2.0. Before it, the query budget, the void count, the query
trace and the DNS cache all lived on the resolver, so reusing one carried a
previous check's state into the next and could turn a passing message into a
permerror. See CHANGELOG.md.
The lookup cache is deliberately scoped to one evaluation. Within a check it still removes duplicate lookups, which is where nearly all the benefit is. Across checks it would have no TTL, and serving a stale SPF record is an authentication error rather than a performance detail. A shared TTL-aware cache may arrive later as an explicit opt-in.
Errors are verdicts
RFC outcomes are never exceptions. A malformed record, a lookup-limit breach, an
exhausted query budget and a DNS timeout all come back as a permerror or
temperror verdict with the reason in the trace. You do not have to wrap a check
in try just to survive a hostile zone.
SpfUsageError is the exception you may see, and it always means the calling code
is wrong: check() from inside an event loop, resolver and nameservers
supplied together, or a configuration value out of range such as a non-positive
time_limit.
Audit mode
The 10-term limit means evaluation stops at the eleventh lookup, so neither a real
MTA nor a normal check can tell you how many lookups an over-limit record actually
needs. audit=True keeps counting past the limit and reports the true figure.
The verdict is still forced to permerror. Visibility changes; the answer never
does. A matching mechanism sitting past the limit does not become a pass.
Command line
spftrace 203.0.113.1 user@example.com
spftrace 203.0.113.1 user@example.com --json
spftrace 203.0.113.1 user@example.com --policy "v=spf1 include:_spf.example.net -all"
spftrace 203.0.113.1 user@example.com --dns 1.1.1.1 --audit
--dns also reads SPFTRACE_DNS. That environment variable is a CLI convenience
only; the library itself never reads it.
Result
| Attribute | Meaning |
|---|---|
verdict (alias result) |
the RFC 7208 result string |
explanation |
expanded exp= text, on fail only |
trace |
ordered Event log with recursion depth |
queries |
every DNS query: name, type, rcode, answers, ms, void, source |
dns_terms_used |
terms consumed against the limit of 10 |
void_lookups_used |
void lookups against the limit of 2 |
elapsed_ms |
wall time for the evaluation |
warnings |
non-fatal notes about the record |
to_dict() is the stable JSON contract and carries schema_version. Additive keys
will not bump it; a change consumers must notice will.
Limits enforced
- 10 DNS terms over
include,a,mx,ptr,existsandredirect, notip4,ip6orall - 2 void lookups, counted once per real lookup by the evaluation session, and
enforced the moment the third one returns rather than at the end of the
mechanism. An
mxpointing at a dozen dead exchanges stops after the third, which is the point of the limit. Counting per term instead double counts: every enclosingincludere-counts its children, and a single void three includes deep became a falsepermerror - 10 MX records per
mx, 10 PTR names perptr expand%{p}do DNS but do not count against the term limit- A separate hard cap on real queries, 75 by default, because the term limit
counts terms and not lookups: ten
mxterms with ten MX records each is 10 terms but 111 queries. The cap is per evaluation, so it resets for every message rather than draining over the life of a resolver
Tests
pip install -e ".[test]"
pytest
The RFC 7208 corpus is fetched from a pinned commit and its sha256 is verified, so
the gate cannot shift underneath you. It is not vendored. pytest --rfc-strict
fails rather than skips when the corpus cannot be fetched; CI uses it.
SPFTRACE_RFC_CORPUS=/path/to/rfc7208-tests.yml runs the suite offline, still
hash-checked.
Licence
MIT.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file spftrace-0.2.0.tar.gz.
File metadata
- Download URL: spftrace-0.2.0.tar.gz
- Upload date:
- Size: 30.4 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
f07373bf961f9a68cafff76c064c223b3d223e3a4641d45ae2f0dda2b424775f
|
|
| MD5 |
878d150d37d74282e9fe40ce63a27cc0
|
|
| BLAKE2b-256 |
0c0061202ca6779176ee75d9c484c88c4ffa424c626c0685070dfd2c62e93600
|
Provenance
The following attestation bundles were made for spftrace-0.2.0.tar.gz:
Publisher:
release.yml on smck83/spftrace
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
spftrace-0.2.0.tar.gz -
Subject digest:
f07373bf961f9a68cafff76c064c223b3d223e3a4641d45ae2f0dda2b424775f - Sigstore transparency entry: 2669394859
- Sigstore integration time:
-
Permalink:
smck83/spftrace@60402dcdeb7bc5b36f9c6992790f92f6c2b67d62 -
Branch / Tag:
refs/tags/v0.2.0 - Owner: https://github.com/smck83
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@60402dcdeb7bc5b36f9c6992790f92f6c2b67d62 -
Trigger Event:
push
-
Statement type:
File details
Details for the file spftrace-0.2.0-py3-none-any.whl.
File metadata
- Download URL: spftrace-0.2.0-py3-none-any.whl
- Upload date:
- Size: 28.1 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
8d08d18999a1fce01660c33db4cdbc7b01d976dd37d41a8b675ef83a734b4b2c
|
|
| MD5 |
144f23f6073a4c71c79198380a634a3d
|
|
| BLAKE2b-256 |
a02ab5cdb7a4b12ee257cc45b53a64de5f74a3c9c08e5cc5a02f498e8922e3f7
|
Provenance
The following attestation bundles were made for spftrace-0.2.0-py3-none-any.whl:
Publisher:
release.yml on smck83/spftrace
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
spftrace-0.2.0-py3-none-any.whl -
Subject digest:
8d08d18999a1fce01660c33db4cdbc7b01d976dd37d41a8b675ef83a734b4b2c - Sigstore transparency entry: 2669395013
- Sigstore integration time:
-
Permalink:
smck83/spftrace@60402dcdeb7bc5b36f9c6992790f92f6c2b67d62 -
Branch / Tag:
refs/tags/v0.2.0 - Owner: https://github.com/smck83
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@60402dcdeb7bc5b36f9c6992790f92f6c2b67d62 -
Trigger Event:
push
-
Statement type: