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, # overall deadline, checked between terms
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 add caching, share a resolver across
checks, or 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. Query recording, caching, the void count and the budget are all
handled in the base class.
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, or resolver and nameservers
supplied together.
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 at the resolver. Counting per term double counts:
every enclosing
includere-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
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.1.0.tar.gz.
File metadata
- Download URL: spftrace-0.1.0.tar.gz
- Upload date:
- Size: 24.0 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
6c88093a31052a228c459e0473967d6de2777dbdd1615a6f1c6b970093cbec6b
|
|
| MD5 |
be92bfa2169658f12209d66c7d37eba1
|
|
| BLAKE2b-256 |
36b423d75f1359e64ae4312c06ed86e8729f8e16063b0bacd87b5342fa439dbb
|
Provenance
The following attestation bundles were made for spftrace-0.1.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.1.0.tar.gz -
Subject digest:
6c88093a31052a228c459e0473967d6de2777dbdd1615a6f1c6b970093cbec6b - Sigstore transparency entry: 2568864388
- Sigstore integration time:
-
Permalink:
smck83/spftrace@c831ee30216059ad7b6407fd184c166daf098542 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/smck83
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@c831ee30216059ad7b6407fd184c166daf098542 -
Trigger Event:
push
-
Statement type:
File details
Details for the file spftrace-0.1.0-py3-none-any.whl.
File metadata
- Download URL: spftrace-0.1.0-py3-none-any.whl
- Upload date:
- Size: 24.2 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 |
cc8e9fd21cf99aaead8e651d6df917729dec83d9619a2c1f3e5bde15978a9928
|
|
| MD5 |
b7c4cf2efd43ca5a88bc864f94be18f2
|
|
| BLAKE2b-256 |
ba40f56c8ca3f1d451fc03c43b6bedbc859967c6a038977383aec936a3a1fc81
|
Provenance
The following attestation bundles were made for spftrace-0.1.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.1.0-py3-none-any.whl -
Subject digest:
cc8e9fd21cf99aaead8e651d6df917729dec83d9619a2c1f3e5bde15978a9928 - Sigstore transparency entry: 2568864395
- Sigstore integration time:
-
Permalink:
smck83/spftrace@c831ee30216059ad7b6407fd184c166daf098542 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/smck83
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@c831ee30216059ad7b6407fd184c166daf098542 -
Trigger Event:
push
-
Statement type: