emailsec 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.
- emailsec-0.1.0/.gitignore +10 -0
- emailsec-0.1.0/.python-version +1 -0
- emailsec-0.1.0/CHANGELOG +0 -0
- emailsec-0.1.0/LICENSE +21 -0
- emailsec-0.1.0/PKG-INFO +51 -0
- emailsec-0.1.0/README.md +37 -0
- emailsec-0.1.0/pyproject.toml +38 -0
- emailsec-0.1.0/src/emailsec/__init__.py +2 -0
- emailsec-0.1.0/src/emailsec/dns_resolver.py +48 -0
- emailsec-0.1.0/src/emailsec/errors.py +10 -0
- emailsec-0.1.0/src/emailsec/py.typed +0 -0
- emailsec-0.1.0/src/emailsec/spf/checker.py +236 -0
- emailsec-0.1.0/src/emailsec/spf/expander.py +98 -0
- emailsec-0.1.0/src/emailsec/spf/parser.py +289 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.13
|
emailsec-0.1.0/CHANGELOG
ADDED
|
File without changes
|
emailsec-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Thomas Sileo
|
|
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.
|
emailsec-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: emailsec
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: SPF (Sender Policy Framework) and DKIM parser and checker.
|
|
5
|
+
Author-email: Thomas Sileo <thomas.sileo@sent.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Programming Language :: Python
|
|
10
|
+
Requires-Python: >=3.10
|
|
11
|
+
Requires-Dist: aiodns>=3.2.0
|
|
12
|
+
Requires-Dist: pyparsing>=3.2.3
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# emailsec
|
|
16
|
+
|
|
17
|
+
`emailsec` is a Python library that provides tools to parse and verify SPF (Sender Policy Framework) and DKIM (DomainKeys Identified Mail) records.
|
|
18
|
+
|
|
19
|
+
**This project is still in early development.**
|
|
20
|
+
|
|
21
|
+
## Sender Policy Framework
|
|
22
|
+
|
|
23
|
+
[RFC 7208](https://datatracker.ietf.org/doc/html/rfc7208)-compliant parser and checker.
|
|
24
|
+
|
|
25
|
+
### Parser
|
|
26
|
+
|
|
27
|
+
```
|
|
28
|
+
>>> from emailsec.spf.parser import parse_record
|
|
29
|
+
>>> parse_record("v=spf1 +a mx/30 mx:example.org/30 -all")
|
|
30
|
+
[A(qualifier=<Qualifier.PASS: '+'>, domain_spec=None, cidr=None),
|
|
31
|
+
MX(qualifier=<Qualifier.PASS: '+'>, domain_spec=None, cidr='/30'),
|
|
32
|
+
MX(qualifier=<Qualifier.PASS: '+'>, domain_spec='example.org', cidr='/30'),
|
|
33
|
+
All(qualifier=<Qualifier.FAIL: '-'>)]
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
### Checker
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
>>> import asyncio
|
|
40
|
+
>>> from emailsec.spf.checker import check_host
|
|
41
|
+
>>> asyncio.run(check_host(ip="192.0.2.10", sender="hello@example.com"))
|
|
42
|
+
(<Result.PASS: 'pass'>, '')
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Contribution
|
|
46
|
+
|
|
47
|
+
Contributions are welcome but please open an issue to start a discussion before starting something consequent.
|
|
48
|
+
|
|
49
|
+
## License
|
|
50
|
+
|
|
51
|
+
Copyright (c) 2025 Thomas Sileo and contributors. Released under the MIT license.
|
emailsec-0.1.0/README.md
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# emailsec
|
|
2
|
+
|
|
3
|
+
`emailsec` is a Python library that provides tools to parse and verify SPF (Sender Policy Framework) and DKIM (DomainKeys Identified Mail) records.
|
|
4
|
+
|
|
5
|
+
**This project is still in early development.**
|
|
6
|
+
|
|
7
|
+
## Sender Policy Framework
|
|
8
|
+
|
|
9
|
+
[RFC 7208](https://datatracker.ietf.org/doc/html/rfc7208)-compliant parser and checker.
|
|
10
|
+
|
|
11
|
+
### Parser
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
>>> from emailsec.spf.parser import parse_record
|
|
15
|
+
>>> parse_record("v=spf1 +a mx/30 mx:example.org/30 -all")
|
|
16
|
+
[A(qualifier=<Qualifier.PASS: '+'>, domain_spec=None, cidr=None),
|
|
17
|
+
MX(qualifier=<Qualifier.PASS: '+'>, domain_spec=None, cidr='/30'),
|
|
18
|
+
MX(qualifier=<Qualifier.PASS: '+'>, domain_spec='example.org', cidr='/30'),
|
|
19
|
+
All(qualifier=<Qualifier.FAIL: '-'>)]
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
### Checker
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
>>> import asyncio
|
|
26
|
+
>>> from emailsec.spf.checker import check_host
|
|
27
|
+
>>> asyncio.run(check_host(ip="192.0.2.10", sender="hello@example.com"))
|
|
28
|
+
(<Result.PASS: 'pass'>, '')
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Contribution
|
|
32
|
+
|
|
33
|
+
Contributions are welcome but please open an issue to start a discussion before starting something consequent.
|
|
34
|
+
|
|
35
|
+
## License
|
|
36
|
+
|
|
37
|
+
Copyright (c) 2025 Thomas Sileo and contributors. Released under the MIT license.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "emailsec"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "SPF (Sender Policy Framework) and DKIM parser and checker."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
authors = [
|
|
9
|
+
{ name = "Thomas Sileo", email = "thomas.sileo@sent.com" }
|
|
10
|
+
]
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
dependencies = [
|
|
13
|
+
"aiodns>=3.2.0",
|
|
14
|
+
"pyparsing>=3.2.3",
|
|
15
|
+
]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 3 - Alpha",
|
|
18
|
+
"Programming Language :: Python"
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
[build-system]
|
|
22
|
+
requires = ["hatchling"]
|
|
23
|
+
build-backend = "hatchling.build"
|
|
24
|
+
|
|
25
|
+
[dependency-groups]
|
|
26
|
+
dev = [
|
|
27
|
+
"mypy>=1.15.0",
|
|
28
|
+
"pytest>=8.3.5",
|
|
29
|
+
"pytest-asyncio>=0.26.0",
|
|
30
|
+
"pyyaml>=6.0.2",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
[tool.hatch.build.targets.sdist]
|
|
34
|
+
exclude = [
|
|
35
|
+
".build.yml",
|
|
36
|
+
"uv.lock",
|
|
37
|
+
"tests/",
|
|
38
|
+
]
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import typing
|
|
3
|
+
|
|
4
|
+
from emailsec import errors
|
|
5
|
+
|
|
6
|
+
import aiodns
|
|
7
|
+
import pycares
|
|
8
|
+
|
|
9
|
+
_MAX_LOOKUPS = 10
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class DNSResolver:
|
|
13
|
+
def __init__(self) -> None:
|
|
14
|
+
self.__lookups_counter = 0
|
|
15
|
+
self.__resolver = aiodns.DNSResolver(loop=asyncio.get_event_loop())
|
|
16
|
+
|
|
17
|
+
async def _query(self, name: str, query_type: str) -> typing.Any:
|
|
18
|
+
self.__lookups_counter += 1
|
|
19
|
+
if self.__lookups_counter > _MAX_LOOKUPS:
|
|
20
|
+
raise errors.Permerror("Max DNS lookups exceeded")
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
return await self.__resolver.query(name, query_type)
|
|
24
|
+
except aiodns.error.DNSError as dns_error:
|
|
25
|
+
print(dns_error)
|
|
26
|
+
error_msg = dns_error.args[1]
|
|
27
|
+
match dns_error.args[0]:
|
|
28
|
+
case aiodns.error.ARES_ENOTFOUND: # type: ignore
|
|
29
|
+
return None
|
|
30
|
+
case aiodns.error.ARES_EBADQUERY: # type: ignore
|
|
31
|
+
raise errors.Permerror(f"DNS Error: {error_msg}")
|
|
32
|
+
case _:
|
|
33
|
+
raise errors.Temperror(f"DNS error: {error_msg}")
|
|
34
|
+
|
|
35
|
+
async def txt(self, name: str) -> list[pycares.ares_query_txt_result] | None:
|
|
36
|
+
return await self._query(name, "TXT")
|
|
37
|
+
|
|
38
|
+
async def mx(self, name: str) -> list[pycares.ares_query_mx_result] | None:
|
|
39
|
+
return await self._query(name, "MX")
|
|
40
|
+
|
|
41
|
+
async def a(self, name: str) -> list[pycares.ares_query_a_result] | None:
|
|
42
|
+
return await self._query(name, "A")
|
|
43
|
+
|
|
44
|
+
async def aaaa(self, name: str) -> list[pycares.ares_query_aaaa_result] | None:
|
|
45
|
+
return await self._query(name, "AAAA")
|
|
46
|
+
|
|
47
|
+
async def ptr(self, name: str) -> pycares.ares_query_ptr_result | None:
|
|
48
|
+
return await self._query(name, "PTR")
|
|
File without changes
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import typing
|
|
2
|
+
import enum
|
|
3
|
+
from pycares import ares_query_a_result, ares_query_aaaa_result
|
|
4
|
+
import ipaddress
|
|
5
|
+
from emailsec.spf import parser
|
|
6
|
+
from emailsec.spf.parser import parse_record, Qualifier
|
|
7
|
+
from emailsec.spf.expander import Expander
|
|
8
|
+
from emailsec import errors
|
|
9
|
+
from emailsec.dns_resolver import DNSResolver
|
|
10
|
+
|
|
11
|
+
IPVersion = typing.Literal[4, 6]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Result(enum.StrEnum):
|
|
15
|
+
NONE = "none"
|
|
16
|
+
NEUTRAL = "neutral"
|
|
17
|
+
PASS = "pass"
|
|
18
|
+
FAIL = "fail"
|
|
19
|
+
SOFTFAIL = "softfail"
|
|
20
|
+
TEMPERROR = "temperror"
|
|
21
|
+
PERMERROR = "permerror"
|
|
22
|
+
|
|
23
|
+
@classmethod
|
|
24
|
+
def from_qualifier(cls, q: Qualifier) -> "Result":
|
|
25
|
+
match q:
|
|
26
|
+
case Qualifier.PASS:
|
|
27
|
+
return cls.PASS
|
|
28
|
+
case Qualifier.FAIL:
|
|
29
|
+
return cls.FAIL
|
|
30
|
+
case Qualifier.SOFTFAIL:
|
|
31
|
+
return cls.SOFTFAIL
|
|
32
|
+
case Qualifier.NEUTRAL:
|
|
33
|
+
return cls.NEUTRAL
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
async def _ip_networks_from_a_records(
|
|
37
|
+
dns_resolver: DNSResolver, name: str, ip_version: IPVersion, cidr: str | None = None
|
|
38
|
+
) -> list[ipaddress.IPv4Network | ipaddress.IPv6Network]:
|
|
39
|
+
ip_networks = []
|
|
40
|
+
|
|
41
|
+
query_results: list[ares_query_a_result] | list[ares_query_aaaa_result] | None
|
|
42
|
+
if ip_version == 4:
|
|
43
|
+
query_results = await dns_resolver.a(name)
|
|
44
|
+
elif ip_version == 6:
|
|
45
|
+
query_results = await dns_resolver.aaaa(name)
|
|
46
|
+
|
|
47
|
+
if not query_results:
|
|
48
|
+
return []
|
|
49
|
+
|
|
50
|
+
for qres in query_results:
|
|
51
|
+
print(f"{qres.host}/{cidr=}")
|
|
52
|
+
ip_networks.append(ipaddress.ip_network(qres.host + (cidr or ""), False))
|
|
53
|
+
|
|
54
|
+
return ip_networks
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
async def check_host(ip: str, sender: str) -> tuple[Result, str]:
|
|
58
|
+
parsed_ip = ipaddress.ip_address(ip)
|
|
59
|
+
if "@" not in sender:
|
|
60
|
+
sender = f"postmaster@{sender}"
|
|
61
|
+
domain = sender.split("@", maxsplit=1)[-1]
|
|
62
|
+
|
|
63
|
+
dns_resolver = DNSResolver()
|
|
64
|
+
|
|
65
|
+
try:
|
|
66
|
+
res, explanation = await _rec_check_host(
|
|
67
|
+
dns_resolver, parsed_ip, sender, domain
|
|
68
|
+
)
|
|
69
|
+
except errors.Permerror as error:
|
|
70
|
+
return Result.PERMERROR, error.args[0]
|
|
71
|
+
except errors.Temperror as error:
|
|
72
|
+
return Result.TEMPERROR, error.args[0]
|
|
73
|
+
else:
|
|
74
|
+
return res, explanation
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
async def _rec_check_host(
|
|
78
|
+
dns_resolver: DNSResolver,
|
|
79
|
+
parsed_ip: ipaddress.IPv4Address | ipaddress.IPv6Address,
|
|
80
|
+
sender: str,
|
|
81
|
+
domain: str,
|
|
82
|
+
recursion=0,
|
|
83
|
+
) -> tuple[Result, str]:
|
|
84
|
+
if recursion > 10:
|
|
85
|
+
raise errors.Permerror("Too much recursion")
|
|
86
|
+
|
|
87
|
+
end_result = None
|
|
88
|
+
|
|
89
|
+
expander = Expander(str(parsed_ip), sender, domain=domain)
|
|
90
|
+
|
|
91
|
+
spfs = []
|
|
92
|
+
txt_records = await dns_resolver.txt(domain)
|
|
93
|
+
if not txt_records:
|
|
94
|
+
return Result.NONE, ""
|
|
95
|
+
for txt_record in txt_records:
|
|
96
|
+
if txt_record.text.lower().startswith("v=spf1 "):
|
|
97
|
+
spfs.append(txt_record.text)
|
|
98
|
+
print(spfs)
|
|
99
|
+
if len(spfs) > 1:
|
|
100
|
+
raise errors.Permerror("Too many SPF records")
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
parsed_spf = parse_record(spfs[0])
|
|
104
|
+
except ValueError:
|
|
105
|
+
raise errors.Permerror(f"Failed to parse SPF record {spfs[0]!r}")
|
|
106
|
+
|
|
107
|
+
modifiers = {}
|
|
108
|
+
for mechanism in parsed_spf:
|
|
109
|
+
match mechanism:
|
|
110
|
+
case parser.Modifier(name=name, value=value):
|
|
111
|
+
modifiers[name] = value
|
|
112
|
+
|
|
113
|
+
case parser.All(qualifier=qualifier):
|
|
114
|
+
end_result = Result.from_qualifier(qualifier)
|
|
115
|
+
break
|
|
116
|
+
|
|
117
|
+
case parser.Include():
|
|
118
|
+
target_name = expander.expand(mechanism.domain_spec)
|
|
119
|
+
# Recursive evaluation
|
|
120
|
+
# https://datatracker.ietf.org/doc/html/rfc7208#section-5.2
|
|
121
|
+
rec_result, _ = await _rec_check_host(
|
|
122
|
+
dns_resolver=dns_resolver,
|
|
123
|
+
parsed_ip=parsed_ip,
|
|
124
|
+
sender=sender,
|
|
125
|
+
domain=target_name,
|
|
126
|
+
recursion=recursion + 1,
|
|
127
|
+
)
|
|
128
|
+
match rec_result:
|
|
129
|
+
case Result.PASS:
|
|
130
|
+
end_result = rec_result
|
|
131
|
+
break
|
|
132
|
+
case Result.FAIL | Result.SOFTFAIL | Result.NEUTRAL:
|
|
133
|
+
continue
|
|
134
|
+
case Result.TEMPERROR | Result.PERMERROR | Result.NONE:
|
|
135
|
+
end_result = Result.PERMERROR
|
|
136
|
+
break
|
|
137
|
+
|
|
138
|
+
case parser.Exists():
|
|
139
|
+
target_name = expander.expand(mechanism.domain_spec)
|
|
140
|
+
if await dns_resolver.a(target_name):
|
|
141
|
+
end_result = Result.from_qualifier(mechanism.qualifier)
|
|
142
|
+
break
|
|
143
|
+
|
|
144
|
+
case parser.A():
|
|
145
|
+
target_name = (
|
|
146
|
+
expander.expand(mechanism.domain_spec)
|
|
147
|
+
if mechanism.domain_spec
|
|
148
|
+
else domain
|
|
149
|
+
)
|
|
150
|
+
ip_networks = await _ip_networks_from_a_records(
|
|
151
|
+
dns_resolver, target_name, parsed_ip.version, cidr=mechanism.cidr
|
|
152
|
+
)
|
|
153
|
+
if any(parsed_ip in ip_network for ip_network in ip_networks):
|
|
154
|
+
end_result = Result.from_qualifier(mechanism.qualifier)
|
|
155
|
+
break
|
|
156
|
+
|
|
157
|
+
case parser.IP4() | parser.IP6():
|
|
158
|
+
if parsed_ip in mechanism.ip_network:
|
|
159
|
+
end_result = Result.from_qualifier(mechanism.qualifier)
|
|
160
|
+
break
|
|
161
|
+
|
|
162
|
+
case parser.MX():
|
|
163
|
+
target_name = (
|
|
164
|
+
expander.expand(mechanism.domain_spec)
|
|
165
|
+
if mechanism.domain_spec
|
|
166
|
+
else domain
|
|
167
|
+
)
|
|
168
|
+
for mx_record in (await dns_resolver.mx(target_name)) or []:
|
|
169
|
+
ip_networks = await _ip_networks_from_a_records(
|
|
170
|
+
dns_resolver,
|
|
171
|
+
mx_record.host,
|
|
172
|
+
parsed_ip.version,
|
|
173
|
+
cidr=mechanism.cidr,
|
|
174
|
+
)
|
|
175
|
+
print(
|
|
176
|
+
f"MX {parsed_spf=} {mx_record=}/{target_name=}/{ip_networks=}/{parsed_ip=}"
|
|
177
|
+
)
|
|
178
|
+
if any(parsed_ip in ip_network for ip_network in ip_networks):
|
|
179
|
+
end_result = Result.from_qualifier(mechanism.qualifier)
|
|
180
|
+
break
|
|
181
|
+
|
|
182
|
+
if end_result:
|
|
183
|
+
break
|
|
184
|
+
|
|
185
|
+
case parser.PTR():
|
|
186
|
+
target_name = (
|
|
187
|
+
expander.expand(mechanism.domain_spec)
|
|
188
|
+
if mechanism.domain_spec
|
|
189
|
+
else domain
|
|
190
|
+
)
|
|
191
|
+
ptr_record = await dns_resolver.ptr(parsed_ip.reverse_pointer)
|
|
192
|
+
if not ptr_record:
|
|
193
|
+
continue
|
|
194
|
+
validated_domains = []
|
|
195
|
+
for alias in ptr_record.aliases[:10]:
|
|
196
|
+
ip_networks = await _ip_networks_from_a_records(
|
|
197
|
+
dns_resolver, alias, parsed_ip.version
|
|
198
|
+
)
|
|
199
|
+
if any(parsed_ip in ip_network for ip_network in ip_networks):
|
|
200
|
+
validated_domains.append(alias)
|
|
201
|
+
|
|
202
|
+
if target_name in validated_domains or any(
|
|
203
|
+
target_name.endswith(validated_domain)
|
|
204
|
+
for validated_domain in validated_domains
|
|
205
|
+
):
|
|
206
|
+
end_result = Result.from_qualifier(mechanism.qualifier)
|
|
207
|
+
break
|
|
208
|
+
|
|
209
|
+
if end_result is None and "redirect" in modifiers:
|
|
210
|
+
# At this point, there's no "all" mechanism or it would have returned
|
|
211
|
+
# while processing it
|
|
212
|
+
rec_result, _ = await _rec_check_host(
|
|
213
|
+
dns_resolver=dns_resolver,
|
|
214
|
+
parsed_ip=parsed_ip,
|
|
215
|
+
sender=sender,
|
|
216
|
+
domain=expander.expand(modifiers["redirect"]),
|
|
217
|
+
recursion=recursion + 1,
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
explanation = ""
|
|
221
|
+
if end_result is Result.FAIL and "exp" in modifiers:
|
|
222
|
+
target_name = expander.expand(modifiers["exp"])
|
|
223
|
+
try:
|
|
224
|
+
txt_records = await dns_resolver.txt(target_name)
|
|
225
|
+
except Exception:
|
|
226
|
+
pass
|
|
227
|
+
else:
|
|
228
|
+
if txt_records:
|
|
229
|
+
explanation = "".join(txt_record.text for txt_record in txt_records)
|
|
230
|
+
|
|
231
|
+
# TODO: if result is "fail" look for the exp modifier
|
|
232
|
+
|
|
233
|
+
if end_result:
|
|
234
|
+
return end_result, explanation
|
|
235
|
+
else:
|
|
236
|
+
return Result.NEUTRAL, ""
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Macros related helpers as defined in RFC7208:
|
|
2
|
+
https://datatracker.ietf.org/doc/html/rfc7208#section-7"""
|
|
3
|
+
|
|
4
|
+
import time
|
|
5
|
+
import ipaddress
|
|
6
|
+
import re
|
|
7
|
+
|
|
8
|
+
macro_string_regex = re.compile(
|
|
9
|
+
(
|
|
10
|
+
r"%{(?P<letter>[slodipvhcrt])"
|
|
11
|
+
r"(?P<count>(\d+))?"
|
|
12
|
+
r"(?P<reverse>(r))?"
|
|
13
|
+
r"(?P<delimiter>([\.\-\+,\/_=]))?}"
|
|
14
|
+
),
|
|
15
|
+
re.IGNORECASE,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Expander:
|
|
20
|
+
def __init__(
|
|
21
|
+
self, ip: str, sender: str, domain: str | None = None, exp_mode: bool = False
|
|
22
|
+
) -> None:
|
|
23
|
+
self.ip = ip
|
|
24
|
+
self.parsed_ip = ipaddress.ip_address(ip)
|
|
25
|
+
self.domain = domain if domain else sender.split("@", maxsplit=1)[-1]
|
|
26
|
+
self.sender = sender
|
|
27
|
+
self.exp_mode = exp_mode
|
|
28
|
+
|
|
29
|
+
def expand(self, value: str) -> str:
|
|
30
|
+
if "%" not in value:
|
|
31
|
+
return value
|
|
32
|
+
|
|
33
|
+
# Handle special cases first
|
|
34
|
+
value = value.replace("%_", " ").replace("%-", "%20").replace("%%", "%")
|
|
35
|
+
|
|
36
|
+
# Then look for regular macro strings
|
|
37
|
+
for match in re.finditer(macro_string_regex, value):
|
|
38
|
+
gd = match.groupdict()
|
|
39
|
+
res = self.expand_letter(gd["letter"])
|
|
40
|
+
if gd["reverse"] or gd["count"] or gd["delimiter"]:
|
|
41
|
+
delimiter = gd["delimiter"] or "."
|
|
42
|
+
parts = res.split(delimiter)
|
|
43
|
+
|
|
44
|
+
if gd["reverse"]:
|
|
45
|
+
parts = parts[::-1]
|
|
46
|
+
|
|
47
|
+
count = int(gd["count"]) if gd["count"] else len(parts)
|
|
48
|
+
res = ".".join(parts[-count:])
|
|
49
|
+
|
|
50
|
+
value = value.replace(match.group(), res, 1)
|
|
51
|
+
|
|
52
|
+
return value
|
|
53
|
+
|
|
54
|
+
def expand_letter(self, macro: str) -> str:
|
|
55
|
+
match macro.lower():
|
|
56
|
+
case "s":
|
|
57
|
+
return self.sender
|
|
58
|
+
case "l":
|
|
59
|
+
return self.sender.split("@", maxsplit=1)[0]
|
|
60
|
+
case "o":
|
|
61
|
+
return self.sender.split("@", maxsplit=1)[1]
|
|
62
|
+
case "d":
|
|
63
|
+
return self.domain
|
|
64
|
+
case "i":
|
|
65
|
+
if self.parsed_ip.version == 4:
|
|
66
|
+
return self.ip
|
|
67
|
+
else:
|
|
68
|
+
return self.parsed_ip.reverse_pointer.removesuffix(".ip6.arpa")[
|
|
69
|
+
::-1
|
|
70
|
+
]
|
|
71
|
+
case "p":
|
|
72
|
+
# p = the validated domain name of <ip> (do not use)
|
|
73
|
+
raise NotImplementedError()
|
|
74
|
+
case "v":
|
|
75
|
+
# v = the string "in-addr" if <ip> is ipv4, or "ip6" if <ip> is ipv6
|
|
76
|
+
if self.parsed_ip.version == 4:
|
|
77
|
+
return "in-addr"
|
|
78
|
+
else:
|
|
79
|
+
return "ip6"
|
|
80
|
+
case "h":
|
|
81
|
+
# h = HELO/EHLO domain
|
|
82
|
+
# TODO: implement
|
|
83
|
+
raise NotImplementedError()
|
|
84
|
+
case "c":
|
|
85
|
+
if not self.exp_mode:
|
|
86
|
+
raise ValueError("c only allowed in exp mode")
|
|
87
|
+
return self.ip
|
|
88
|
+
case "r":
|
|
89
|
+
# The "r" macro expands to the name of the receiving MTA.
|
|
90
|
+
# TODO: implement
|
|
91
|
+
return "unknown"
|
|
92
|
+
case "t":
|
|
93
|
+
if not self.exp_mode:
|
|
94
|
+
raise ValueError("c only allowed in exp mode")
|
|
95
|
+
# Timestamp
|
|
96
|
+
return str(int(time.time()))
|
|
97
|
+
|
|
98
|
+
raise RuntimeError()
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import typing
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
import re
|
|
4
|
+
import pyparsing
|
|
5
|
+
import enum
|
|
6
|
+
from pyparsing import (
|
|
7
|
+
CaselessLiteral,
|
|
8
|
+
Combine,
|
|
9
|
+
Optional,
|
|
10
|
+
Literal,
|
|
11
|
+
Regex,
|
|
12
|
+
Word,
|
|
13
|
+
ZeroOrMore,
|
|
14
|
+
alphanums,
|
|
15
|
+
alphas,
|
|
16
|
+
nums,
|
|
17
|
+
printables,
|
|
18
|
+
)
|
|
19
|
+
from pyparsing import pyparsing_common
|
|
20
|
+
import ipaddress
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Qualifier(enum.StrEnum):
|
|
24
|
+
PASS = "+"
|
|
25
|
+
FAIL = "-"
|
|
26
|
+
SOFTFAIL = "~"
|
|
27
|
+
NEUTRAL = "?"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class Modifier:
|
|
32
|
+
name: str
|
|
33
|
+
value: str
|
|
34
|
+
|
|
35
|
+
@classmethod
|
|
36
|
+
def from_parse_results(cls, parse_result: pyparsing.ParseResults) -> typing.Self:
|
|
37
|
+
data = parse_result.as_dict()
|
|
38
|
+
return cls(
|
|
39
|
+
name=data["name"],
|
|
40
|
+
value=data["value"],
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(kw_only=True)
|
|
45
|
+
class _BaseMechanism:
|
|
46
|
+
qualifier: Qualifier = Qualifier.PASS
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class All(_BaseMechanism):
|
|
51
|
+
@classmethod
|
|
52
|
+
def from_parse_results(cls, parse_result: pyparsing.ParseResults) -> typing.Self:
|
|
53
|
+
return cls()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class MX(_BaseMechanism):
|
|
58
|
+
domain_spec: str | None
|
|
59
|
+
cidr: str | None
|
|
60
|
+
|
|
61
|
+
@classmethod
|
|
62
|
+
def from_parse_results(cls, parse_result: pyparsing.ParseResults) -> typing.Self:
|
|
63
|
+
data = parse_result[0].as_dict()
|
|
64
|
+
return cls(
|
|
65
|
+
domain_spec=data.get("domain_spec"),
|
|
66
|
+
cidr=data.get("cidr"),
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass
|
|
71
|
+
class A(_BaseMechanism):
|
|
72
|
+
domain_spec: str | None
|
|
73
|
+
cidr: str | None
|
|
74
|
+
|
|
75
|
+
@classmethod
|
|
76
|
+
def from_parse_results(cls, parse_result: pyparsing.ParseResults) -> typing.Self:
|
|
77
|
+
data = parse_result[0].as_dict()
|
|
78
|
+
return cls(
|
|
79
|
+
domain_spec=data.get("domain_spec"),
|
|
80
|
+
cidr=data.get("cidr"),
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass
|
|
85
|
+
class _BaseIP(_BaseMechanism):
|
|
86
|
+
ip_network: ipaddress.IPv4Network | ipaddress.IPv6Network
|
|
87
|
+
|
|
88
|
+
@classmethod
|
|
89
|
+
def from_parse_results(cls, parse_result: pyparsing.ParseResults) -> typing.Self:
|
|
90
|
+
data = parse_result.as_dict()
|
|
91
|
+
try:
|
|
92
|
+
return cls(
|
|
93
|
+
ip_network=ipaddress.ip_network(
|
|
94
|
+
data["ip_address"] + data.get("cidr", "")
|
|
95
|
+
)
|
|
96
|
+
)
|
|
97
|
+
except (ipaddress.AddressValueError, ipaddress.NetmaskValueError):
|
|
98
|
+
raise pyparsing.ParseException(f"Invalid IP network {data=}")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass
|
|
102
|
+
class IP4(_BaseIP):
|
|
103
|
+
pass
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass
|
|
107
|
+
class IP6(_BaseIP):
|
|
108
|
+
pass
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@dataclass
|
|
112
|
+
class _MechanismWithRequiredDomainSpec(_BaseMechanism):
|
|
113
|
+
domain_spec: str
|
|
114
|
+
|
|
115
|
+
@classmethod
|
|
116
|
+
def from_parse_results(cls, parse_result: pyparsing.ParseResults) -> typing.Self:
|
|
117
|
+
data = parse_result.as_dict()
|
|
118
|
+
return cls(
|
|
119
|
+
domain_spec=data["domain_spec"],
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@dataclass
|
|
124
|
+
class Include(_MechanismWithRequiredDomainSpec):
|
|
125
|
+
pass
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@dataclass
|
|
129
|
+
class Exists(_MechanismWithRequiredDomainSpec):
|
|
130
|
+
pass
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@dataclass
|
|
134
|
+
class PTR(_BaseMechanism):
|
|
135
|
+
domain_spec: str | None
|
|
136
|
+
|
|
137
|
+
@classmethod
|
|
138
|
+
def from_parse_results(cls, parse_result: pyparsing.ParseResults) -> typing.Self:
|
|
139
|
+
data = parse_result.as_dict()
|
|
140
|
+
return cls(
|
|
141
|
+
domain_spec=data.get("domain_spec"),
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
macro_string_regex = re.compile(
|
|
146
|
+
(
|
|
147
|
+
r"%{(?P<letter>[slodipvhcrt])"
|
|
148
|
+
r"(?P<count>(\d+))?"
|
|
149
|
+
r"(?P<reverse>(r))?"
|
|
150
|
+
r"(?P<delimiter>([\.\-\+,\/_=]))?}"
|
|
151
|
+
),
|
|
152
|
+
re.IGNORECASE,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
version = CaselessLiteral("v=spf1")
|
|
157
|
+
qualifier = Word("+-?~", exact=1)
|
|
158
|
+
name = Word(alphas, alphanums + "-_.")
|
|
159
|
+
|
|
160
|
+
dual_cidr_length = Combine(Literal("/") + Word(nums))
|
|
161
|
+
|
|
162
|
+
macro_literal = Word(printables, exact=1, exclude_chars="%")
|
|
163
|
+
macro_expand = Regex(macro_string_regex) | Literal("%%") | Literal("%_") | Literal("%-")
|
|
164
|
+
|
|
165
|
+
domain_end = (
|
|
166
|
+
Combine(Literal(".") + Word(alphanums + "-") + Optional(Literal(".")))
|
|
167
|
+
| macro_expand
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
macro_string = Combine(
|
|
171
|
+
ZeroOrMore(macro_expand | macro_literal, stop_on=dual_cidr_length)
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _check_domain_end(toks: pyparsing.ParseResults) -> None:
|
|
176
|
+
domain_end = toks[0].removesuffix(".")
|
|
177
|
+
if "." in domain_end:
|
|
178
|
+
top_label = domain_end.split(".")[-1]
|
|
179
|
+
else:
|
|
180
|
+
top_label = domain_end
|
|
181
|
+
|
|
182
|
+
if not top_label:
|
|
183
|
+
raise pyparsing.ParseException("empty label")
|
|
184
|
+
|
|
185
|
+
try:
|
|
186
|
+
# Is the top label a macro expand
|
|
187
|
+
macro_expand.parse_string(top_label, parse_all=True)
|
|
188
|
+
except pyparsing.ParseException:
|
|
189
|
+
# Or a valid RFC1035/1123 DNS label
|
|
190
|
+
if not re.match(r"^(?!-)[a-zA-Z0-9-_]{1,63}(?<!-)$", top_label):
|
|
191
|
+
raise pyparsing.ParseException("invalid top label")
|
|
192
|
+
|
|
193
|
+
return None
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
domain_spec = macro_string.set_parse_action(_check_domain_end)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
all_ = CaselessLiteral("all").set_parse_action(All.from_parse_results)
|
|
200
|
+
|
|
201
|
+
include = (
|
|
202
|
+
CaselessLiteral("include:").suppress() + domain_spec.set_results_name("domain_spec")
|
|
203
|
+
).set_parse_action(Include.from_parse_results)
|
|
204
|
+
|
|
205
|
+
ip4_cidr_length = Combine(Literal("/") + Regex("3[0-2]|2[0-9]|1[0-9]|[1-9]"))
|
|
206
|
+
ip4 = (
|
|
207
|
+
CaselessLiteral("ip4:").suppress()
|
|
208
|
+
+ pyparsing_common.ipv4_address.set_results_name("ip_address")
|
|
209
|
+
+ Optional(ip4_cidr_length).set_results_name("cidr")
|
|
210
|
+
).set_parse_action(IP4.from_parse_results)
|
|
211
|
+
|
|
212
|
+
ip6_cidr_length = Literal("/") + Word(nums)
|
|
213
|
+
ip6 = (
|
|
214
|
+
CaselessLiteral("ip6:").suppress()
|
|
215
|
+
+ pyparsing_common.ipv6_address.set_results_name("ip_address")
|
|
216
|
+
+ Optional(ip6_cidr_length).set_results_name("cidr")
|
|
217
|
+
).set_parse_action(IP6.from_parse_results)
|
|
218
|
+
|
|
219
|
+
a = pyparsing.Group(
|
|
220
|
+
CaselessLiteral("a").suppress()
|
|
221
|
+
+ Optional(
|
|
222
|
+
pyparsing.ungroup(Literal(":").suppress() + domain_spec)
|
|
223
|
+
).set_results_name("domain_spec")
|
|
224
|
+
+ Optional(dual_cidr_length).set_results_name("cidr")
|
|
225
|
+
).set_parse_action(A.from_parse_results)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
mx = pyparsing.Group(
|
|
229
|
+
CaselessLiteral("mx").suppress()
|
|
230
|
+
+ Optional(
|
|
231
|
+
pyparsing.ungroup(Literal(":").suppress() + domain_spec)
|
|
232
|
+
).set_results_name("domain_spec")
|
|
233
|
+
+ Optional(dual_cidr_length).set_results_name("cidr")
|
|
234
|
+
).set_parse_action(MX.from_parse_results)
|
|
235
|
+
|
|
236
|
+
ptr = (
|
|
237
|
+
CaselessLiteral("ptr").suppress()
|
|
238
|
+
+ Optional(
|
|
239
|
+
pyparsing.ungroup(Literal(":").suppress() + domain_spec)
|
|
240
|
+
).set_results_name("domain_spec")
|
|
241
|
+
).set_parse_action(PTR.from_parse_results)
|
|
242
|
+
|
|
243
|
+
exists = (
|
|
244
|
+
CaselessLiteral("exists:").suppress() + domain_spec.set_results_name("domain_spec")
|
|
245
|
+
).set_parse_action(lambda toks: Exists(toks))
|
|
246
|
+
|
|
247
|
+
unknown_modifier = (
|
|
248
|
+
name.set_results_name("name")
|
|
249
|
+
+ Literal("=").suppress()
|
|
250
|
+
+ name.set_results_name("value")
|
|
251
|
+
)
|
|
252
|
+
redirect = (
|
|
253
|
+
CaselessLiteral("redirect").set_results_name("name")
|
|
254
|
+
+ Literal("=").suppress()
|
|
255
|
+
+ domain_spec.set_results_name("value")
|
|
256
|
+
)
|
|
257
|
+
exp = (
|
|
258
|
+
CaselessLiteral("exp").set_results_name("name")
|
|
259
|
+
+ Literal("=").suppress()
|
|
260
|
+
+ domain_spec.set_results_name("value")
|
|
261
|
+
)
|
|
262
|
+
modifiers = (redirect | exp | unknown_modifier).set_parse_action(
|
|
263
|
+
Modifier.from_parse_results
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _merge_qualifier(toks):
|
|
268
|
+
group = toks[0]
|
|
269
|
+
if len(group) == 2:
|
|
270
|
+
group[1].qualifier = Qualifier(group[0])
|
|
271
|
+
return group[1]
|
|
272
|
+
return toks
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
mechanism = pyparsing.ungroup(
|
|
276
|
+
pyparsing.Group(
|
|
277
|
+
Optional(qualifier) + (all_ | include | ip4 | ip6 | a | mx | ptr | exists)
|
|
278
|
+
).set_parse_action(_merge_qualifier)
|
|
279
|
+
)
|
|
280
|
+
record = version.suppress() + ZeroOrMore(mechanism | modifiers)
|
|
281
|
+
|
|
282
|
+
Mechanism = typing.TypeVar("Mechanism", bound=_BaseMechanism)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def parse_record(rec: str) -> list[Mechanism | Modifier]:
|
|
286
|
+
try:
|
|
287
|
+
return record.parse_string(rec, parse_all=True).as_list()
|
|
288
|
+
except pyparsing.ParseException as parse_error:
|
|
289
|
+
raise ValueError(f"Invalid record {rec!r}") from parse_error
|