spftrace 0.1.0__tar.gz
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-0.1.0/LICENSE +21 -0
- spftrace-0.1.0/PKG-INFO +208 -0
- spftrace-0.1.0/README.md +180 -0
- spftrace-0.1.0/pyproject.toml +48 -0
- spftrace-0.1.0/src/spftrace/__init__.py +199 -0
- spftrace-0.1.0/src/spftrace/cli.py +202 -0
- spftrace-0.1.0/src/spftrace/errors.py +28 -0
- spftrace-0.1.0/src/spftrace/evaluator.py +554 -0
- spftrace-0.1.0/src/spftrace/macros.py +223 -0
- spftrace-0.1.0/src/spftrace/parser.py +231 -0
- spftrace-0.1.0/src/spftrace/py.typed +0 -0
- spftrace-0.1.0/src/spftrace/resolver.py +155 -0
- spftrace-0.1.0/src/spftrace/trace.py +96 -0
- spftrace-0.1.0/tests/__init__.py +0 -0
- spftrace-0.1.0/tests/conftest.py +20 -0
- spftrace-0.1.0/tests/corpus.py +128 -0
- spftrace-0.1.0/tests/test_api.py +106 -0
- spftrace-0.1.0/tests/test_rfc7208.py +56 -0
- spftrace-0.1.0/tests/test_smoke.py +106 -0
spftrace-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Scott McKeown
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
spftrace-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: spftrace
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: RFC 7208 SPF evaluator that returns a full evaluation trace
|
|
5
|
+
Project-URL: Homepage, https://github.com/smck83/spftrace
|
|
6
|
+
Project-URL: Source, https://github.com/smck83/spftrace
|
|
7
|
+
Project-URL: Issues, https://github.com/smck83/spftrace/issues
|
|
8
|
+
Author: Scott McKeown
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: authentication,dns,email,rfc7208,spf,trace
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Intended Audience :: System Administrators
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
18
|
+
Classifier: Topic :: Communications :: Email
|
|
19
|
+
Classifier: Topic :: Internet :: Name Service (DNS)
|
|
20
|
+
Classifier: Topic :: Security
|
|
21
|
+
Classifier: Typing :: Typed
|
|
22
|
+
Requires-Python: >=3.10
|
|
23
|
+
Requires-Dist: dnspython>=2.6
|
|
24
|
+
Provides-Extra: test
|
|
25
|
+
Requires-Dist: pytest>=8; extra == 'test'
|
|
26
|
+
Requires-Dist: pyyaml>=6; extra == 'test'
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# spftrace
|
|
30
|
+
|
|
31
|
+
An RFC 7208 SPF evaluator that shows its working.
|
|
32
|
+
|
|
33
|
+
Most SPF libraries answer `pass` or `fail` and throw the reasoning away. spftrace
|
|
34
|
+
returns the reasoning: every DNS query with its rcode and timing, every macro
|
|
35
|
+
expansion before and after, every mechanism with its qualifier and why it did or
|
|
36
|
+
did not match, the running DNS-term and void-lookup counters, and the exact term
|
|
37
|
+
that broke a limit.
|
|
38
|
+
|
|
39
|
+
It is a library. There is no web UI here, and no framework dependency. Build your
|
|
40
|
+
own CLI, API or service on top.
|
|
41
|
+
|
|
42
|
+
- Own implementation of `check_host()`, not a wrapper around another evaluator
|
|
43
|
+
- Passes all 203 cases of the official openspf.org RFC 7208 test suite
|
|
44
|
+
- Async core with a blocking convenience wrapper
|
|
45
|
+
- One runtime dependency: dnspython
|
|
46
|
+
- Reads no environment variables and no system resolver config. Configuration is
|
|
47
|
+
explicit, so the consuming application stays in charge
|
|
48
|
+
|
|
49
|
+
## Install
|
|
50
|
+
|
|
51
|
+
```
|
|
52
|
+
pip install spftrace
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Use
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
import spftrace
|
|
59
|
+
|
|
60
|
+
result = spftrace.check("203.0.113.1", "user@example.com")
|
|
61
|
+
|
|
62
|
+
print(result.verdict) # pass, fail, softfail, neutral, none,
|
|
63
|
+
# permerror or temperror
|
|
64
|
+
print(result.dns_terms_used) # against the RFC limit of 10
|
|
65
|
+
print(result.to_dict()) # JSON-safe, with the full event trace
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
From async code, await the async form instead. Calling `check()` inside a running
|
|
69
|
+
event loop raises `SpfUsageError` telling you so, rather than a confusing asyncio
|
|
70
|
+
error from several frames down.
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
result = await spftrace.acheck("203.0.113.1", "user@example.com")
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### In FastAPI
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
from fastapi import FastAPI
|
|
80
|
+
import spftrace
|
|
81
|
+
|
|
82
|
+
app = FastAPI()
|
|
83
|
+
|
|
84
|
+
@app.get("/check")
|
|
85
|
+
async def check(ip: str, sender: str):
|
|
86
|
+
result = await spftrace.acheck(
|
|
87
|
+
ip, sender, nameservers=["1.1.1.1"], max_queries=75
|
|
88
|
+
)
|
|
89
|
+
return result.to_dict()
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Do not let users pass arbitrary resolver addresses through to `nameservers`. That
|
|
93
|
+
turns your service into an SSRF-ish DNS proxy. Offer a fixed set of resolvers and
|
|
94
|
+
map the user's choice to one server side.
|
|
95
|
+
|
|
96
|
+
### Options
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
await spftrace.acheck(
|
|
100
|
+
ip,
|
|
101
|
+
sender,
|
|
102
|
+
helo="mail.example.net", # defaults to the sender domain
|
|
103
|
+
policy="v=spf1 -all", # evaluate this instead of looking one up
|
|
104
|
+
nameservers=["192.0.2.53"], # defaults to 8.8.8.8
|
|
105
|
+
timeout=5.0, # per-query DNS timeout
|
|
106
|
+
max_queries=75, # hard cap on real lookups
|
|
107
|
+
time_limit=20.0, # overall deadline, checked between terms
|
|
108
|
+
receiver="mta01", # value of the %{r} macro
|
|
109
|
+
audit=False, # see below
|
|
110
|
+
)
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
`policy=` evaluates a record you paste in rather than one published in DNS, which
|
|
114
|
+
is how you test a change before shipping it.
|
|
115
|
+
|
|
116
|
+
### Bring your own resolver
|
|
117
|
+
|
|
118
|
+
Pass a `resolver` instead of `nameservers` to add caching, share a resolver across
|
|
119
|
+
checks, or test with no network at all.
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
from spftrace import Evaluator, Limits, ZoneResolver
|
|
123
|
+
|
|
124
|
+
zone = {"e.com": [("TXT", "v=spf1 ip4:1.2.3.0/24 -all")]}
|
|
125
|
+
result = await Evaluator(ZoneResolver(zone), Limits()).evaluate("1.2.3.4", "a@e.com")
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Subclass `BaseResolver` and implement `async _lookup(name, rtype) -> (rcode, answers)`
|
|
129
|
+
for anything else. Query recording, caching, the void count and the budget are all
|
|
130
|
+
handled in the base class.
|
|
131
|
+
|
|
132
|
+
## Errors are verdicts
|
|
133
|
+
|
|
134
|
+
RFC outcomes are never exceptions. A malformed record, a lookup-limit breach, an
|
|
135
|
+
exhausted query budget and a DNS timeout all come back as a `permerror` or
|
|
136
|
+
`temperror` verdict with the reason in the trace. You do not have to wrap a check
|
|
137
|
+
in `try` just to survive a hostile zone.
|
|
138
|
+
|
|
139
|
+
`SpfUsageError` is the exception you may see, and it always means the calling code
|
|
140
|
+
is wrong: `check()` from inside an event loop, or `resolver` and `nameservers`
|
|
141
|
+
supplied together.
|
|
142
|
+
|
|
143
|
+
## Audit mode
|
|
144
|
+
|
|
145
|
+
The 10-term limit means evaluation stops at the eleventh lookup, so neither a real
|
|
146
|
+
MTA nor a normal check can tell you how many lookups an over-limit record actually
|
|
147
|
+
needs. `audit=True` keeps counting past the limit and reports the true figure.
|
|
148
|
+
|
|
149
|
+
The verdict is still forced to `permerror`. Visibility changes; the answer never
|
|
150
|
+
does. A matching mechanism sitting past the limit does not become a `pass`.
|
|
151
|
+
|
|
152
|
+
## Command line
|
|
153
|
+
|
|
154
|
+
```
|
|
155
|
+
spftrace 203.0.113.1 user@example.com
|
|
156
|
+
spftrace 203.0.113.1 user@example.com --json
|
|
157
|
+
spftrace 203.0.113.1 user@example.com --policy "v=spf1 include:_spf.example.net -all"
|
|
158
|
+
spftrace 203.0.113.1 user@example.com --dns 1.1.1.1 --audit
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
`--dns` also reads `SPFTRACE_DNS`. That environment variable is a CLI convenience
|
|
162
|
+
only; the library itself never reads it.
|
|
163
|
+
|
|
164
|
+
## Result
|
|
165
|
+
|
|
166
|
+
| Attribute | Meaning |
|
|
167
|
+
| --- | --- |
|
|
168
|
+
| `verdict` (alias `result`) | the RFC 7208 result string |
|
|
169
|
+
| `explanation` | expanded `exp=` text, on `fail` only |
|
|
170
|
+
| `trace` | ordered `Event` log with recursion depth |
|
|
171
|
+
| `queries` | every DNS query: name, type, rcode, answers, ms, void, source |
|
|
172
|
+
| `dns_terms_used` | terms consumed against the limit of 10 |
|
|
173
|
+
| `void_lookups_used` | void lookups against the limit of 2 |
|
|
174
|
+
| `elapsed_ms` | wall time for the evaluation |
|
|
175
|
+
| `warnings` | non-fatal notes about the record |
|
|
176
|
+
|
|
177
|
+
`to_dict()` is the stable JSON contract and carries `schema_version`. Additive keys
|
|
178
|
+
will not bump it; a change consumers must notice will.
|
|
179
|
+
|
|
180
|
+
## Limits enforced
|
|
181
|
+
|
|
182
|
+
- 10 DNS terms over `include`, `a`, `mx`, `ptr`, `exists` and `redirect`, not
|
|
183
|
+
`ip4`, `ip6` or `all`
|
|
184
|
+
- 2 void lookups, counted once at the resolver. Counting per term double counts:
|
|
185
|
+
every enclosing `include` re-counts its children, and a single void three
|
|
186
|
+
includes deep became a false `permerror`
|
|
187
|
+
- 10 MX records per `mx`, 10 PTR names per `ptr`
|
|
188
|
+
- `exp` and `%{p}` do DNS but do not count against the term limit
|
|
189
|
+
- A separate hard cap on real queries, 75 by default, because the term limit
|
|
190
|
+
counts terms and not lookups: ten `mx` terms with ten MX records each is 10
|
|
191
|
+
terms but 111 queries
|
|
192
|
+
|
|
193
|
+
## Tests
|
|
194
|
+
|
|
195
|
+
```
|
|
196
|
+
pip install -e ".[test]"
|
|
197
|
+
pytest
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
The RFC 7208 corpus is fetched from a pinned commit and its sha256 is verified, so
|
|
201
|
+
the gate cannot shift underneath you. It is not vendored. `pytest --rfc-strict`
|
|
202
|
+
fails rather than skips when the corpus cannot be fetched; CI uses it.
|
|
203
|
+
`SPFTRACE_RFC_CORPUS=/path/to/rfc7208-tests.yml` runs the suite offline, still
|
|
204
|
+
hash-checked.
|
|
205
|
+
|
|
206
|
+
## Licence
|
|
207
|
+
|
|
208
|
+
MIT.
|
spftrace-0.1.0/README.md
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# spftrace
|
|
2
|
+
|
|
3
|
+
An RFC 7208 SPF evaluator that shows its working.
|
|
4
|
+
|
|
5
|
+
Most SPF libraries answer `pass` or `fail` and throw the reasoning away. spftrace
|
|
6
|
+
returns the reasoning: every DNS query with its rcode and timing, every macro
|
|
7
|
+
expansion before and after, every mechanism with its qualifier and why it did or
|
|
8
|
+
did not match, the running DNS-term and void-lookup counters, and the exact term
|
|
9
|
+
that broke a limit.
|
|
10
|
+
|
|
11
|
+
It is a library. There is no web UI here, and no framework dependency. Build your
|
|
12
|
+
own CLI, API or service on top.
|
|
13
|
+
|
|
14
|
+
- Own implementation of `check_host()`, not a wrapper around another evaluator
|
|
15
|
+
- Passes all 203 cases of the official openspf.org RFC 7208 test suite
|
|
16
|
+
- Async core with a blocking convenience wrapper
|
|
17
|
+
- One runtime dependency: dnspython
|
|
18
|
+
- Reads no environment variables and no system resolver config. Configuration is
|
|
19
|
+
explicit, so the consuming application stays in charge
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
pip install spftrace
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Use
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
import spftrace
|
|
31
|
+
|
|
32
|
+
result = spftrace.check("203.0.113.1", "user@example.com")
|
|
33
|
+
|
|
34
|
+
print(result.verdict) # pass, fail, softfail, neutral, none,
|
|
35
|
+
# permerror or temperror
|
|
36
|
+
print(result.dns_terms_used) # against the RFC limit of 10
|
|
37
|
+
print(result.to_dict()) # JSON-safe, with the full event trace
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
From async code, await the async form instead. Calling `check()` inside a running
|
|
41
|
+
event loop raises `SpfUsageError` telling you so, rather than a confusing asyncio
|
|
42
|
+
error from several frames down.
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
result = await spftrace.acheck("203.0.113.1", "user@example.com")
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### In FastAPI
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
from fastapi import FastAPI
|
|
52
|
+
import spftrace
|
|
53
|
+
|
|
54
|
+
app = FastAPI()
|
|
55
|
+
|
|
56
|
+
@app.get("/check")
|
|
57
|
+
async def check(ip: str, sender: str):
|
|
58
|
+
result = await spftrace.acheck(
|
|
59
|
+
ip, sender, nameservers=["1.1.1.1"], max_queries=75
|
|
60
|
+
)
|
|
61
|
+
return result.to_dict()
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Do not let users pass arbitrary resolver addresses through to `nameservers`. That
|
|
65
|
+
turns your service into an SSRF-ish DNS proxy. Offer a fixed set of resolvers and
|
|
66
|
+
map the user's choice to one server side.
|
|
67
|
+
|
|
68
|
+
### Options
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
await spftrace.acheck(
|
|
72
|
+
ip,
|
|
73
|
+
sender,
|
|
74
|
+
helo="mail.example.net", # defaults to the sender domain
|
|
75
|
+
policy="v=spf1 -all", # evaluate this instead of looking one up
|
|
76
|
+
nameservers=["192.0.2.53"], # defaults to 8.8.8.8
|
|
77
|
+
timeout=5.0, # per-query DNS timeout
|
|
78
|
+
max_queries=75, # hard cap on real lookups
|
|
79
|
+
time_limit=20.0, # overall deadline, checked between terms
|
|
80
|
+
receiver="mta01", # value of the %{r} macro
|
|
81
|
+
audit=False, # see below
|
|
82
|
+
)
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
`policy=` evaluates a record you paste in rather than one published in DNS, which
|
|
86
|
+
is how you test a change before shipping it.
|
|
87
|
+
|
|
88
|
+
### Bring your own resolver
|
|
89
|
+
|
|
90
|
+
Pass a `resolver` instead of `nameservers` to add caching, share a resolver across
|
|
91
|
+
checks, or test with no network at all.
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
from spftrace import Evaluator, Limits, ZoneResolver
|
|
95
|
+
|
|
96
|
+
zone = {"e.com": [("TXT", "v=spf1 ip4:1.2.3.0/24 -all")]}
|
|
97
|
+
result = await Evaluator(ZoneResolver(zone), Limits()).evaluate("1.2.3.4", "a@e.com")
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Subclass `BaseResolver` and implement `async _lookup(name, rtype) -> (rcode, answers)`
|
|
101
|
+
for anything else. Query recording, caching, the void count and the budget are all
|
|
102
|
+
handled in the base class.
|
|
103
|
+
|
|
104
|
+
## Errors are verdicts
|
|
105
|
+
|
|
106
|
+
RFC outcomes are never exceptions. A malformed record, a lookup-limit breach, an
|
|
107
|
+
exhausted query budget and a DNS timeout all come back as a `permerror` or
|
|
108
|
+
`temperror` verdict with the reason in the trace. You do not have to wrap a check
|
|
109
|
+
in `try` just to survive a hostile zone.
|
|
110
|
+
|
|
111
|
+
`SpfUsageError` is the exception you may see, and it always means the calling code
|
|
112
|
+
is wrong: `check()` from inside an event loop, or `resolver` and `nameservers`
|
|
113
|
+
supplied together.
|
|
114
|
+
|
|
115
|
+
## Audit mode
|
|
116
|
+
|
|
117
|
+
The 10-term limit means evaluation stops at the eleventh lookup, so neither a real
|
|
118
|
+
MTA nor a normal check can tell you how many lookups an over-limit record actually
|
|
119
|
+
needs. `audit=True` keeps counting past the limit and reports the true figure.
|
|
120
|
+
|
|
121
|
+
The verdict is still forced to `permerror`. Visibility changes; the answer never
|
|
122
|
+
does. A matching mechanism sitting past the limit does not become a `pass`.
|
|
123
|
+
|
|
124
|
+
## Command line
|
|
125
|
+
|
|
126
|
+
```
|
|
127
|
+
spftrace 203.0.113.1 user@example.com
|
|
128
|
+
spftrace 203.0.113.1 user@example.com --json
|
|
129
|
+
spftrace 203.0.113.1 user@example.com --policy "v=spf1 include:_spf.example.net -all"
|
|
130
|
+
spftrace 203.0.113.1 user@example.com --dns 1.1.1.1 --audit
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
`--dns` also reads `SPFTRACE_DNS`. That environment variable is a CLI convenience
|
|
134
|
+
only; the library itself never reads it.
|
|
135
|
+
|
|
136
|
+
## Result
|
|
137
|
+
|
|
138
|
+
| Attribute | Meaning |
|
|
139
|
+
| --- | --- |
|
|
140
|
+
| `verdict` (alias `result`) | the RFC 7208 result string |
|
|
141
|
+
| `explanation` | expanded `exp=` text, on `fail` only |
|
|
142
|
+
| `trace` | ordered `Event` log with recursion depth |
|
|
143
|
+
| `queries` | every DNS query: name, type, rcode, answers, ms, void, source |
|
|
144
|
+
| `dns_terms_used` | terms consumed against the limit of 10 |
|
|
145
|
+
| `void_lookups_used` | void lookups against the limit of 2 |
|
|
146
|
+
| `elapsed_ms` | wall time for the evaluation |
|
|
147
|
+
| `warnings` | non-fatal notes about the record |
|
|
148
|
+
|
|
149
|
+
`to_dict()` is the stable JSON contract and carries `schema_version`. Additive keys
|
|
150
|
+
will not bump it; a change consumers must notice will.
|
|
151
|
+
|
|
152
|
+
## Limits enforced
|
|
153
|
+
|
|
154
|
+
- 10 DNS terms over `include`, `a`, `mx`, `ptr`, `exists` and `redirect`, not
|
|
155
|
+
`ip4`, `ip6` or `all`
|
|
156
|
+
- 2 void lookups, counted once at the resolver. Counting per term double counts:
|
|
157
|
+
every enclosing `include` re-counts its children, and a single void three
|
|
158
|
+
includes deep became a false `permerror`
|
|
159
|
+
- 10 MX records per `mx`, 10 PTR names per `ptr`
|
|
160
|
+
- `exp` and `%{p}` do DNS but do not count against the term limit
|
|
161
|
+
- A separate hard cap on real queries, 75 by default, because the term limit
|
|
162
|
+
counts terms and not lookups: ten `mx` terms with ten MX records each is 10
|
|
163
|
+
terms but 111 queries
|
|
164
|
+
|
|
165
|
+
## Tests
|
|
166
|
+
|
|
167
|
+
```
|
|
168
|
+
pip install -e ".[test]"
|
|
169
|
+
pytest
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
The RFC 7208 corpus is fetched from a pinned commit and its sha256 is verified, so
|
|
173
|
+
the gate cannot shift underneath you. It is not vendored. `pytest --rfc-strict`
|
|
174
|
+
fails rather than skips when the corpus cannot be fetched; CI uses it.
|
|
175
|
+
`SPFTRACE_RFC_CORPUS=/path/to/rfc7208-tests.yml` runs the suite offline, still
|
|
176
|
+
hash-checked.
|
|
177
|
+
|
|
178
|
+
## Licence
|
|
179
|
+
|
|
180
|
+
MIT.
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.27"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "spftrace"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "RFC 7208 SPF evaluator that returns a full evaluation trace"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "Scott McKeown" }]
|
|
14
|
+
keywords = ["spf", "rfc7208", "email", "dns", "authentication", "trace"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 4 - Beta",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Intended Audience :: System Administrators",
|
|
19
|
+
"Operating System :: OS Independent",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
22
|
+
"Topic :: Communications :: Email",
|
|
23
|
+
"Topic :: Internet :: Name Service (DNS)",
|
|
24
|
+
"Topic :: Security",
|
|
25
|
+
"Typing :: Typed",
|
|
26
|
+
]
|
|
27
|
+
dependencies = ["dnspython>=2.6"]
|
|
28
|
+
|
|
29
|
+
[project.optional-dependencies]
|
|
30
|
+
test = ["pytest>=8", "pyyaml>=6"]
|
|
31
|
+
|
|
32
|
+
[project.scripts]
|
|
33
|
+
spftrace = "spftrace.cli:main"
|
|
34
|
+
|
|
35
|
+
[project.urls]
|
|
36
|
+
Homepage = "https://github.com/smck83/spftrace"
|
|
37
|
+
Source = "https://github.com/smck83/spftrace"
|
|
38
|
+
Issues = "https://github.com/smck83/spftrace/issues"
|
|
39
|
+
|
|
40
|
+
[tool.hatch.build.targets.wheel]
|
|
41
|
+
packages = ["src/spftrace"]
|
|
42
|
+
|
|
43
|
+
[tool.hatch.build.targets.sdist]
|
|
44
|
+
include = ["src/spftrace", "tests", "README.md", "LICENSE"]
|
|
45
|
+
|
|
46
|
+
[tool.pytest.ini_options]
|
|
47
|
+
testpaths = ["tests"]
|
|
48
|
+
addopts = "-q"
|
|
@@ -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
|
+
)
|