eml-parser 4.0.0__tar.gz → 4.2.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.
- {eml_parser-4.0.0 → eml_parser-4.2.0}/.gitignore +1 -0
- eml_parser-4.2.0/PKG-INFO +272 -0
- eml_parser-4.2.0/README.md +239 -0
- eml_parser-4.2.0/eml_parser/anomalies.py +68 -0
- eml_parser-4.2.0/eml_parser/constants.py +45 -0
- {eml_parser-4.0.0 → eml_parser-4.2.0}/eml_parser/decode.py +151 -34
- {eml_parser-4.0.0 → eml_parser-4.2.0}/eml_parser/parser.py +385 -142
- eml_parser-4.2.0/eml_parser/regexes.py +206 -0
- eml_parser-4.2.0/eml_parser/routing.py +216 -0
- {eml_parser-4.0.0 → eml_parser-4.2.0}/pyproject.toml +4 -3
- eml_parser-4.2.0/tests/test_attachment_anomalies.py +46 -0
- eml_parser-4.2.0/tests/test_attachment_counter.py +51 -0
- eml_parser-4.2.0/tests/test_authentication_results.py +44 -0
- eml_parser-4.2.0/tests/test_body_charset.py +60 -0
- eml_parser-4.2.0/tests/test_broken_multipart.py +72 -0
- eml_parser-4.2.0/tests/test_date_defaults.py +31 -0
- eml_parser-4.2.0/tests/test_date_offset.py +23 -0
- {eml_parser-4.0.0 → eml_parser-4.2.0}/tests/test_decode.py +20 -0
- eml_parser-4.2.0/tests/test_decode_robustness.py +144 -0
- eml_parser-4.2.0/tests/test_defects.py +18 -0
- eml_parser-4.2.0/tests/test_hashed_noscheme.py +30 -0
- eml_parser-4.2.0/tests/test_header_order.py +43 -0
- eml_parser-4.2.0/tests/test_header_size.py +44 -0
- eml_parser-4.2.0/tests/test_host_cache.py +14 -0
- eml_parser-4.2.0/tests/test_html_attribute_urls.py +67 -0
- eml_parser-4.2.0/tests/test_ignore_bad_start.py +61 -0
- eml_parser-4.2.0/tests/test_ioc_boundaries.py +63 -0
- eml_parser-4.2.0/tests/test_obfuscated_ip.py +40 -0
- eml_parser-4.2.0/tests/test_remote_shares.py +83 -0
- {eml_parser-4.0.0 → eml_parser-4.2.0}/tests/test_routing.py +4 -17
- eml_parser-4.2.0/tests/test_routing_structure.py +219 -0
- eml_parser-4.2.0/tests/test_sliding_window.py +55 -0
- eml_parser-4.2.0/tests/test_text_attachments.py +64 -0
- eml_parser-4.2.0/tests/test_xmlns_declarations.py +38 -0
- eml_parser-4.0.0/PKG-INFO +0 -157
- eml_parser-4.0.0/README.md +0 -124
- eml_parser-4.0.0/eml_parser/regexes.py +0 -107
- eml_parser-4.0.0/eml_parser/routing.py +0 -171
- {eml_parser-4.0.0 → eml_parser-4.2.0}/LICENSE +0 -0
- {eml_parser-4.0.0 → eml_parser-4.2.0}/eml_parser/__init__.py +0 -0
- {eml_parser-4.0.0 → eml_parser-4.2.0}/eml_parser/py.typed +0 -0
- {eml_parser-4.0.0 → eml_parser-4.2.0}/examples/recursively_extract_attachments.py +0 -0
- {eml_parser-4.0.0 → eml_parser-4.2.0}/examples/simple_test.py +0 -0
- {eml_parser-4.0.0 → eml_parser-4.2.0}/tests/__init__.py +0 -0
- {eml_parser-4.0.0 → eml_parser-4.2.0}/tests/test_emlparser.py +0 -0
- {eml_parser-4.0.0 → eml_parser-4.2.0}/tests/test_mimebomb.py +0 -0
- {eml_parser-4.0.0 → eml_parser-4.2.0}/tests/test_pathological_headers.py +0 -0
- {eml_parser-4.0.0 → eml_parser-4.2.0}/tests/test_regexes.py +0 -0
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: eml_parser
|
|
3
|
+
Version: 4.2.0
|
|
4
|
+
Summary: Python EML parser library
|
|
5
|
+
Project-URL: Homepage, https://github.com/GOVCERT-LU/eml_parser
|
|
6
|
+
Project-URL: Documentation, https://eml-parser.readthedocs.io/
|
|
7
|
+
Project-URL: Source, https://github.com/GOVCERT-LU/eml_parser
|
|
8
|
+
Project-URL: Tracker, https://github.com/GOVCERT-LU/eml_parser/issues
|
|
9
|
+
Project-URL: Changelog, https://github.com/GOVCERT-LU/eml_parser/blob/master/CHANGELOG.md
|
|
10
|
+
Author-email: Georges Toth <georges.toth@govcert.etat.lu>
|
|
11
|
+
License: AGPLv3+
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Keywords: email
|
|
14
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.15
|
|
24
|
+
Classifier: Topic :: Communications :: Email
|
|
25
|
+
Requires-Python: >=3.11
|
|
26
|
+
Requires-Dist: charset-normalizer
|
|
27
|
+
Requires-Dist: publicsuffixlist
|
|
28
|
+
Requires-Dist: python-dateutil
|
|
29
|
+
Requires-Dist: regex>=2026
|
|
30
|
+
Provides-Extra: filemagic
|
|
31
|
+
Requires-Dist: file-magic>=0.4.0; extra == 'filemagic'
|
|
32
|
+
Description-Content-Type: text/markdown
|
|
33
|
+
|
|
34
|
+
# eml_parser
|
|
35
|
+
|
|
36
|
+
[](http://eml-parser.readthedocs.io)
|
|
37
|
+
[](https://badge.fury.io/py/eml-parser)
|
|
38
|
+
[](https://pypi.python.org/pypi/eml-parser/)
|
|
39
|
+
|
|
40
|
+
**Turn any e-mail into structured, indicator-rich JSON, even the broken and the malicious ones.**
|
|
41
|
+
|
|
42
|
+
eml_parser is a Python library for parsing `.eml` files. It returns everything you would otherwise dig out of a
|
|
43
|
+
message by hand: headers, the mail route, bodies, attachments and the indicators of compromise (IOCs) hiding in them.
|
|
44
|
+
It is built at [GOVCERT.LU](https://github.com/GOVCERT-LU) for incident response, phishing triage, threat intelligence
|
|
45
|
+
and mail analysis pipelines, where messages are rarely well formed and are sometimes crafted to break parsers.
|
|
46
|
+
|
|
47
|
+
## Why eml_parser?
|
|
48
|
+
|
|
49
|
+
- **Indicators, not just fields.** URLs, domains, IP addresses, e-mail addresses and remote file shares are extracted
|
|
50
|
+
from the text, the HTML (including `href`, `src`, form actions, frames and scripts), and from attachments which
|
|
51
|
+
carry links (SVG, scripts, shortcuts, calendar invitations, ...).
|
|
52
|
+
- **Phishing tricks are spotted.** Obfuscated IPs (`http://3232235777/`, `http://0xC0A80101/`), UNC and WebDAV paths
|
|
53
|
+
(`\\host\share`, `file://`, `smb://`) and disguised attachments (`invoice.pdf.exe`, right-to-left override
|
|
54
|
+
characters, executables posing as documents) are reported.
|
|
55
|
+
- **Sender authentication at a glance.** SPF, DKIM, DMARC and ARC results from `Authentication-Results` and
|
|
56
|
+
`Received-SPF` are collected in `header.authentication`.
|
|
57
|
+
- **Robust against broken mail.** Missing or wrong multipart boundaries, bad charsets, invalid dates, garbage in front
|
|
58
|
+
of the headers and other defects do not make the content disappear; defects are reported instead.
|
|
59
|
+
- **Hardened for hostile input.** Parsing time stays linear for pathological headers, deeply nested structures and
|
|
60
|
+
huge bodies, and crafted headers can not crash the parser.
|
|
61
|
+
- **Deterministic results.** The same message gives the same output on every run, with unique indicators in order of
|
|
62
|
+
first appearance.
|
|
63
|
+
- **Safe to share.** By default URLs, domains, IPs and addresses are reported as SHA-256 hashes; plain values are
|
|
64
|
+
opt-in with `include_raw_body=True`.
|
|
65
|
+
- **Fast.** Built on the linear-time [`regex`](https://pypi.org/project/regex/) engine, and creating a parser is cheap
|
|
66
|
+
enough to do it for every message.
|
|
67
|
+
|
|
68
|
+
## Installation
|
|
69
|
+
|
|
70
|
+
```shell
|
|
71
|
+
pip install eml_parser[filemagic]
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Python 3.11 or newer is required.
|
|
75
|
+
|
|
76
|
+
> :warning: **Note:** The `filemagic` extra installs [file-magic](https://pypi.org/project/file-magic/) to determine the
|
|
77
|
+
> mime types of attachments. [python-magic](https://pypi.org/project/python-magic/) is a drop-in replacement, so if
|
|
78
|
+
> either of the two is installed, you are fine. Only if neither is available no mime types are determined.
|
|
79
|
+
> If you don't want to / cannot use the extra, install via:
|
|
80
|
+
>
|
|
81
|
+
> ```shell
|
|
82
|
+
> pip install eml_parser
|
|
83
|
+
> ```
|
|
84
|
+
|
|
85
|
+
### Known Issues
|
|
86
|
+
|
|
87
|
+
#### **OSX** users
|
|
88
|
+
|
|
89
|
+
Make sure to install libmagic, else eml_parser will not work.
|
|
90
|
+
|
|
91
|
+
## Quick start
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
import eml_parser
|
|
95
|
+
|
|
96
|
+
ep = eml_parser.EmlParser()
|
|
97
|
+
|
|
98
|
+
with open('sample.eml', 'rb') as fhdl:
|
|
99
|
+
parsed_eml = ep.decode_email_bytes(fhdl.read())
|
|
100
|
+
|
|
101
|
+
print(parsed_eml['header']['subject'])
|
|
102
|
+
print(parsed_eml['header'].get('authentication')) # only present if the message has such headers
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
`decode_email('sample.eml')` reads a file by path, `decode_email_bytes()` takes the raw message. The result is a plain
|
|
106
|
+
`dict`; `eml_parser.decode.export_to_json()` turns it into a JSON string (dates, hashes and attachment data included).
|
|
107
|
+
|
|
108
|
+
The parser can be tuned with a few options, for example:
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
ep = eml_parser.EmlParser(
|
|
112
|
+
include_raw_body=True, # report plain URLs, domains, IPs, ... instead of hashes, and keep the body text
|
|
113
|
+
include_attachment_data=True, # keep the (base64 encoded) content of the attachments
|
|
114
|
+
parse_attachments=True, # hash and inspect attachments
|
|
115
|
+
domain_force_tld=True, # only accept domains with a valid TLD (public suffix list)
|
|
116
|
+
ip_force_routable=True, # ignore private and reserved IP addresses
|
|
117
|
+
email_force_tld=True, # only accept e-mail addresses with a TLD
|
|
118
|
+
pconf={'whiteip': ['192.168.1.1'], 'whitefor': ['postmaster@example.com']},
|
|
119
|
+
)
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
See the [API documentation](http://eml-parser.readthedocs.io) for all options. Ready-to-run scripts are in
|
|
123
|
+
[examples/](examples/).
|
|
124
|
+
|
|
125
|
+
## What you get
|
|
126
|
+
|
|
127
|
+
Fields are only present when there is something to report (for example `authentication`, `defect` and `anomaly`).
|
|
128
|
+
|
|
129
|
+
| Where | Field | Content |
|
|
130
|
+
| --- | --- | --- |
|
|
131
|
+
| `header` | `from`, `to`, `cc`, `subject`, `date` | Decoded addresses and subject, `date` as an ISO 8601 string |
|
|
132
|
+
| | `received` | The hops of the mail route: `from`, `by`, `with`, `for`, `date`, plus the raw line in `src` |
|
|
133
|
+
| | `received_domain`, `received_ip`, `received_foremail` | Domains, IPs and recipients found along the route |
|
|
134
|
+
| | `authentication` | SPF, DKIM, DMARC, ARC, ... results, e.g. `{"spf": ["pass"]}` |
|
|
135
|
+
| | `header` | All raw header fields, in order of appearance |
|
|
136
|
+
| | `defect` | Structural problems found in the message, e.g. `NoBoundaryInMultipartDefect` |
|
|
137
|
+
| `body[]` | `content_type`, `hash`, `boundary` | One entry per text part, with the SHA-256 of its content |
|
|
138
|
+
| | `uri`, `uri_noscheme`, `unc`, `email`, `domain`, `ip` | Indicators (with `include_raw_body=True`) |
|
|
139
|
+
| | `uri_hash`, `uri_noscheme_hash`, `unc_hash`, `email_hash`, `domain_hash`, `ip_hash` | The same indicators as SHA-256 (default) |
|
|
140
|
+
| `attachment[]` | `filename`, `extension`, `size` | Name, extension and size |
|
|
141
|
+
| | `hash` | MD5, SHA-1, SHA-256 and SHA-512 |
|
|
142
|
+
| | `mime_type`, `mime_type_short` | As detected by libmagic |
|
|
143
|
+
| | `anomaly` | Tricks to disguise the type, see below |
|
|
144
|
+
|
|
145
|
+
### Attachment anomalies
|
|
146
|
+
|
|
147
|
+
`anomaly` lists `filename-bidi-control`, `filename-control-chars`, `filename-double-extension`,
|
|
148
|
+
`filename-trailing-dot-or-space`, `filename-padding` and `extension-content-mismatch` when they apply.
|
|
149
|
+
|
|
150
|
+
### Example
|
|
151
|
+
|
|
152
|
+
This is the (abridged) result for a small phishing message with failing authentication, an obfuscated IP in a link, a
|
|
153
|
+
network share and a double-extension executable ([samples/sample_phishing.eml](samples/sample_phishing.eml); the raw
|
|
154
|
+
headers and the content headers of the parts are left out):
|
|
155
|
+
|
|
156
|
+
```json
|
|
157
|
+
{
|
|
158
|
+
"attachment": [
|
|
159
|
+
{
|
|
160
|
+
"filename": "invoice.pdf.exe",
|
|
161
|
+
"size": 256,
|
|
162
|
+
"extension": "exe",
|
|
163
|
+
"hash": {
|
|
164
|
+
"md5": "00a2126b03824e2eaa5cc87ca2cbda1d",
|
|
165
|
+
"sha1": "8836fcadc9faa02c011534401b5112f4cdf0b5d4",
|
|
166
|
+
"sha256": "eef52d1656582cb18a4f2deba3aed5256f754e45998dc718845b013bccb44186",
|
|
167
|
+
"sha512": "621d591638d18f6b7c44d14dae8953f5cc8ad7952e8235b10d9c886a4fe7b07fb1c4302bbaca1d5e0076772800db757c040359204b51bd528ed6d86611a4dd38"
|
|
168
|
+
},
|
|
169
|
+
"anomaly": [
|
|
170
|
+
"filename-double-extension"
|
|
171
|
+
],
|
|
172
|
+
"mime_type": "PE Unknown PE signature 0 (Control Panel Item), for MS Windows",
|
|
173
|
+
"mime_type_short": "application/vnd.microsoft.portable-executable"
|
|
174
|
+
}
|
|
175
|
+
],
|
|
176
|
+
"body": [
|
|
177
|
+
{
|
|
178
|
+
"uri_hash": [
|
|
179
|
+
"35ccde66a6e0749bb26440355987c3bf73b351c3d0e7cb5a3cc21c648ccb73d8",
|
|
180
|
+
"9d7dc809de40ea7fb9fe30883a2dfc2351b90b9bc08316d7a39bcf3d3282de41"
|
|
181
|
+
],
|
|
182
|
+
"unc_hash": [
|
|
183
|
+
"201e3c9d007c6c6255482794cac37a235dc8083554f06b65e2c96d56e4aaf2e9"
|
|
184
|
+
],
|
|
185
|
+
"domain_hash": [
|
|
186
|
+
"25349124a554706674aebacad6d82390be22a3d44d2939b5805a41b022a33d53",
|
|
187
|
+
"c9a1f844464610c0a2d7489e46e76cb8b51478a0a307818fbf9f677aa26522c7"
|
|
188
|
+
],
|
|
189
|
+
"ip_hash": [
|
|
190
|
+
"c5eb5a4cc76a5cdb16e79864b9ccd26c3553f0c396d0a21bafb7be71c1efcd8c"
|
|
191
|
+
],
|
|
192
|
+
"content_type": "text/html",
|
|
193
|
+
"hash": "aac2579a09161cb15583d1d628b11613a14a69e1589f2e72ab907cba1c6d7395",
|
|
194
|
+
"boundary": "BOUND"
|
|
195
|
+
}
|
|
196
|
+
],
|
|
197
|
+
"header": {
|
|
198
|
+
"subject": "Invoice 4711 - action required",
|
|
199
|
+
"from": "billing@pay-secure.example",
|
|
200
|
+
"to": [
|
|
201
|
+
"victim@example.org"
|
|
202
|
+
],
|
|
203
|
+
"date": "2026-10-05T07:12:40+00:00",
|
|
204
|
+
"received": [
|
|
205
|
+
{
|
|
206
|
+
"src": "from mail.badhost.example (mail.badhost.example [203.0.113.7]) by mx.example.org (postfix) with esmtps id 4f2a31 for <victim@example.org>; mon, 05 oct 2026 09:12:44 +0200",
|
|
207
|
+
"from": [
|
|
208
|
+
"203.0.113.7",
|
|
209
|
+
"mail.badhost.example"
|
|
210
|
+
],
|
|
211
|
+
"by": [
|
|
212
|
+
"mx.example.org"
|
|
213
|
+
],
|
|
214
|
+
"with": "esmtps id 4f2a31",
|
|
215
|
+
"for": [
|
|
216
|
+
"victim@example.org"
|
|
217
|
+
],
|
|
218
|
+
"date": "2026-10-05T07:12:44+00:00"
|
|
219
|
+
}
|
|
220
|
+
],
|
|
221
|
+
"received_domain": [
|
|
222
|
+
"mx.example.org",
|
|
223
|
+
"mail.badhost.example"
|
|
224
|
+
],
|
|
225
|
+
"received_ip": [
|
|
226
|
+
"203.0.113.7"
|
|
227
|
+
],
|
|
228
|
+
"received_foremail": [
|
|
229
|
+
"victim@example.org"
|
|
230
|
+
],
|
|
231
|
+
"authentication": {
|
|
232
|
+
"spf": [
|
|
233
|
+
"fail"
|
|
234
|
+
],
|
|
235
|
+
"dkim": [
|
|
236
|
+
"none"
|
|
237
|
+
],
|
|
238
|
+
"dmarc": [
|
|
239
|
+
"fail"
|
|
240
|
+
]
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
With `include_raw_body=True` the indicators of the body are reported in clear text instead of hashes. Note that the
|
|
247
|
+
obfuscated IP in the link is resolved and the share host is added to the domains:
|
|
248
|
+
|
|
249
|
+
```json
|
|
250
|
+
{
|
|
251
|
+
"uri": [
|
|
252
|
+
"http://3232235777/login",
|
|
253
|
+
"https://pay-secure.example/collect"
|
|
254
|
+
],
|
|
255
|
+
"unc": [
|
|
256
|
+
"\\\\files.evil.example\\share\\invoice.lnk"
|
|
257
|
+
],
|
|
258
|
+
"domain": [
|
|
259
|
+
"pay-secure.example",
|
|
260
|
+
"files.evil.example"
|
|
261
|
+
],
|
|
262
|
+
"ip": [
|
|
263
|
+
"192.168.1.1"
|
|
264
|
+
]
|
|
265
|
+
}
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
## Contributing
|
|
269
|
+
|
|
270
|
+
Please feel free to send me your comments / pull requests.
|
|
271
|
+
|
|
272
|
+
For the changelog, please see [CHANGELOG.md](CHANGELOG.md).
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
# eml_parser
|
|
2
|
+
|
|
3
|
+
[](http://eml-parser.readthedocs.io)
|
|
4
|
+
[](https://badge.fury.io/py/eml-parser)
|
|
5
|
+
[](https://pypi.python.org/pypi/eml-parser/)
|
|
6
|
+
|
|
7
|
+
**Turn any e-mail into structured, indicator-rich JSON, even the broken and the malicious ones.**
|
|
8
|
+
|
|
9
|
+
eml_parser is a Python library for parsing `.eml` files. It returns everything you would otherwise dig out of a
|
|
10
|
+
message by hand: headers, the mail route, bodies, attachments and the indicators of compromise (IOCs) hiding in them.
|
|
11
|
+
It is built at [GOVCERT.LU](https://github.com/GOVCERT-LU) for incident response, phishing triage, threat intelligence
|
|
12
|
+
and mail analysis pipelines, where messages are rarely well formed and are sometimes crafted to break parsers.
|
|
13
|
+
|
|
14
|
+
## Why eml_parser?
|
|
15
|
+
|
|
16
|
+
- **Indicators, not just fields.** URLs, domains, IP addresses, e-mail addresses and remote file shares are extracted
|
|
17
|
+
from the text, the HTML (including `href`, `src`, form actions, frames and scripts), and from attachments which
|
|
18
|
+
carry links (SVG, scripts, shortcuts, calendar invitations, ...).
|
|
19
|
+
- **Phishing tricks are spotted.** Obfuscated IPs (`http://3232235777/`, `http://0xC0A80101/`), UNC and WebDAV paths
|
|
20
|
+
(`\\host\share`, `file://`, `smb://`) and disguised attachments (`invoice.pdf.exe`, right-to-left override
|
|
21
|
+
characters, executables posing as documents) are reported.
|
|
22
|
+
- **Sender authentication at a glance.** SPF, DKIM, DMARC and ARC results from `Authentication-Results` and
|
|
23
|
+
`Received-SPF` are collected in `header.authentication`.
|
|
24
|
+
- **Robust against broken mail.** Missing or wrong multipart boundaries, bad charsets, invalid dates, garbage in front
|
|
25
|
+
of the headers and other defects do not make the content disappear; defects are reported instead.
|
|
26
|
+
- **Hardened for hostile input.** Parsing time stays linear for pathological headers, deeply nested structures and
|
|
27
|
+
huge bodies, and crafted headers can not crash the parser.
|
|
28
|
+
- **Deterministic results.** The same message gives the same output on every run, with unique indicators in order of
|
|
29
|
+
first appearance.
|
|
30
|
+
- **Safe to share.** By default URLs, domains, IPs and addresses are reported as SHA-256 hashes; plain values are
|
|
31
|
+
opt-in with `include_raw_body=True`.
|
|
32
|
+
- **Fast.** Built on the linear-time [`regex`](https://pypi.org/project/regex/) engine, and creating a parser is cheap
|
|
33
|
+
enough to do it for every message.
|
|
34
|
+
|
|
35
|
+
## Installation
|
|
36
|
+
|
|
37
|
+
```shell
|
|
38
|
+
pip install eml_parser[filemagic]
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Python 3.11 or newer is required.
|
|
42
|
+
|
|
43
|
+
> :warning: **Note:** The `filemagic` extra installs [file-magic](https://pypi.org/project/file-magic/) to determine the
|
|
44
|
+
> mime types of attachments. [python-magic](https://pypi.org/project/python-magic/) is a drop-in replacement, so if
|
|
45
|
+
> either of the two is installed, you are fine. Only if neither is available no mime types are determined.
|
|
46
|
+
> If you don't want to / cannot use the extra, install via:
|
|
47
|
+
>
|
|
48
|
+
> ```shell
|
|
49
|
+
> pip install eml_parser
|
|
50
|
+
> ```
|
|
51
|
+
|
|
52
|
+
### Known Issues
|
|
53
|
+
|
|
54
|
+
#### **OSX** users
|
|
55
|
+
|
|
56
|
+
Make sure to install libmagic, else eml_parser will not work.
|
|
57
|
+
|
|
58
|
+
## Quick start
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
import eml_parser
|
|
62
|
+
|
|
63
|
+
ep = eml_parser.EmlParser()
|
|
64
|
+
|
|
65
|
+
with open('sample.eml', 'rb') as fhdl:
|
|
66
|
+
parsed_eml = ep.decode_email_bytes(fhdl.read())
|
|
67
|
+
|
|
68
|
+
print(parsed_eml['header']['subject'])
|
|
69
|
+
print(parsed_eml['header'].get('authentication')) # only present if the message has such headers
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
`decode_email('sample.eml')` reads a file by path, `decode_email_bytes()` takes the raw message. The result is a plain
|
|
73
|
+
`dict`; `eml_parser.decode.export_to_json()` turns it into a JSON string (dates, hashes and attachment data included).
|
|
74
|
+
|
|
75
|
+
The parser can be tuned with a few options, for example:
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
ep = eml_parser.EmlParser(
|
|
79
|
+
include_raw_body=True, # report plain URLs, domains, IPs, ... instead of hashes, and keep the body text
|
|
80
|
+
include_attachment_data=True, # keep the (base64 encoded) content of the attachments
|
|
81
|
+
parse_attachments=True, # hash and inspect attachments
|
|
82
|
+
domain_force_tld=True, # only accept domains with a valid TLD (public suffix list)
|
|
83
|
+
ip_force_routable=True, # ignore private and reserved IP addresses
|
|
84
|
+
email_force_tld=True, # only accept e-mail addresses with a TLD
|
|
85
|
+
pconf={'whiteip': ['192.168.1.1'], 'whitefor': ['postmaster@example.com']},
|
|
86
|
+
)
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
See the [API documentation](http://eml-parser.readthedocs.io) for all options. Ready-to-run scripts are in
|
|
90
|
+
[examples/](examples/).
|
|
91
|
+
|
|
92
|
+
## What you get
|
|
93
|
+
|
|
94
|
+
Fields are only present when there is something to report (for example `authentication`, `defect` and `anomaly`).
|
|
95
|
+
|
|
96
|
+
| Where | Field | Content |
|
|
97
|
+
| --- | --- | --- |
|
|
98
|
+
| `header` | `from`, `to`, `cc`, `subject`, `date` | Decoded addresses and subject, `date` as an ISO 8601 string |
|
|
99
|
+
| | `received` | The hops of the mail route: `from`, `by`, `with`, `for`, `date`, plus the raw line in `src` |
|
|
100
|
+
| | `received_domain`, `received_ip`, `received_foremail` | Domains, IPs and recipients found along the route |
|
|
101
|
+
| | `authentication` | SPF, DKIM, DMARC, ARC, ... results, e.g. `{"spf": ["pass"]}` |
|
|
102
|
+
| | `header` | All raw header fields, in order of appearance |
|
|
103
|
+
| | `defect` | Structural problems found in the message, e.g. `NoBoundaryInMultipartDefect` |
|
|
104
|
+
| `body[]` | `content_type`, `hash`, `boundary` | One entry per text part, with the SHA-256 of its content |
|
|
105
|
+
| | `uri`, `uri_noscheme`, `unc`, `email`, `domain`, `ip` | Indicators (with `include_raw_body=True`) |
|
|
106
|
+
| | `uri_hash`, `uri_noscheme_hash`, `unc_hash`, `email_hash`, `domain_hash`, `ip_hash` | The same indicators as SHA-256 (default) |
|
|
107
|
+
| `attachment[]` | `filename`, `extension`, `size` | Name, extension and size |
|
|
108
|
+
| | `hash` | MD5, SHA-1, SHA-256 and SHA-512 |
|
|
109
|
+
| | `mime_type`, `mime_type_short` | As detected by libmagic |
|
|
110
|
+
| | `anomaly` | Tricks to disguise the type, see below |
|
|
111
|
+
|
|
112
|
+
### Attachment anomalies
|
|
113
|
+
|
|
114
|
+
`anomaly` lists `filename-bidi-control`, `filename-control-chars`, `filename-double-extension`,
|
|
115
|
+
`filename-trailing-dot-or-space`, `filename-padding` and `extension-content-mismatch` when they apply.
|
|
116
|
+
|
|
117
|
+
### Example
|
|
118
|
+
|
|
119
|
+
This is the (abridged) result for a small phishing message with failing authentication, an obfuscated IP in a link, a
|
|
120
|
+
network share and a double-extension executable ([samples/sample_phishing.eml](samples/sample_phishing.eml); the raw
|
|
121
|
+
headers and the content headers of the parts are left out):
|
|
122
|
+
|
|
123
|
+
```json
|
|
124
|
+
{
|
|
125
|
+
"attachment": [
|
|
126
|
+
{
|
|
127
|
+
"filename": "invoice.pdf.exe",
|
|
128
|
+
"size": 256,
|
|
129
|
+
"extension": "exe",
|
|
130
|
+
"hash": {
|
|
131
|
+
"md5": "00a2126b03824e2eaa5cc87ca2cbda1d",
|
|
132
|
+
"sha1": "8836fcadc9faa02c011534401b5112f4cdf0b5d4",
|
|
133
|
+
"sha256": "eef52d1656582cb18a4f2deba3aed5256f754e45998dc718845b013bccb44186",
|
|
134
|
+
"sha512": "621d591638d18f6b7c44d14dae8953f5cc8ad7952e8235b10d9c886a4fe7b07fb1c4302bbaca1d5e0076772800db757c040359204b51bd528ed6d86611a4dd38"
|
|
135
|
+
},
|
|
136
|
+
"anomaly": [
|
|
137
|
+
"filename-double-extension"
|
|
138
|
+
],
|
|
139
|
+
"mime_type": "PE Unknown PE signature 0 (Control Panel Item), for MS Windows",
|
|
140
|
+
"mime_type_short": "application/vnd.microsoft.portable-executable"
|
|
141
|
+
}
|
|
142
|
+
],
|
|
143
|
+
"body": [
|
|
144
|
+
{
|
|
145
|
+
"uri_hash": [
|
|
146
|
+
"35ccde66a6e0749bb26440355987c3bf73b351c3d0e7cb5a3cc21c648ccb73d8",
|
|
147
|
+
"9d7dc809de40ea7fb9fe30883a2dfc2351b90b9bc08316d7a39bcf3d3282de41"
|
|
148
|
+
],
|
|
149
|
+
"unc_hash": [
|
|
150
|
+
"201e3c9d007c6c6255482794cac37a235dc8083554f06b65e2c96d56e4aaf2e9"
|
|
151
|
+
],
|
|
152
|
+
"domain_hash": [
|
|
153
|
+
"25349124a554706674aebacad6d82390be22a3d44d2939b5805a41b022a33d53",
|
|
154
|
+
"c9a1f844464610c0a2d7489e46e76cb8b51478a0a307818fbf9f677aa26522c7"
|
|
155
|
+
],
|
|
156
|
+
"ip_hash": [
|
|
157
|
+
"c5eb5a4cc76a5cdb16e79864b9ccd26c3553f0c396d0a21bafb7be71c1efcd8c"
|
|
158
|
+
],
|
|
159
|
+
"content_type": "text/html",
|
|
160
|
+
"hash": "aac2579a09161cb15583d1d628b11613a14a69e1589f2e72ab907cba1c6d7395",
|
|
161
|
+
"boundary": "BOUND"
|
|
162
|
+
}
|
|
163
|
+
],
|
|
164
|
+
"header": {
|
|
165
|
+
"subject": "Invoice 4711 - action required",
|
|
166
|
+
"from": "billing@pay-secure.example",
|
|
167
|
+
"to": [
|
|
168
|
+
"victim@example.org"
|
|
169
|
+
],
|
|
170
|
+
"date": "2026-10-05T07:12:40+00:00",
|
|
171
|
+
"received": [
|
|
172
|
+
{
|
|
173
|
+
"src": "from mail.badhost.example (mail.badhost.example [203.0.113.7]) by mx.example.org (postfix) with esmtps id 4f2a31 for <victim@example.org>; mon, 05 oct 2026 09:12:44 +0200",
|
|
174
|
+
"from": [
|
|
175
|
+
"203.0.113.7",
|
|
176
|
+
"mail.badhost.example"
|
|
177
|
+
],
|
|
178
|
+
"by": [
|
|
179
|
+
"mx.example.org"
|
|
180
|
+
],
|
|
181
|
+
"with": "esmtps id 4f2a31",
|
|
182
|
+
"for": [
|
|
183
|
+
"victim@example.org"
|
|
184
|
+
],
|
|
185
|
+
"date": "2026-10-05T07:12:44+00:00"
|
|
186
|
+
}
|
|
187
|
+
],
|
|
188
|
+
"received_domain": [
|
|
189
|
+
"mx.example.org",
|
|
190
|
+
"mail.badhost.example"
|
|
191
|
+
],
|
|
192
|
+
"received_ip": [
|
|
193
|
+
"203.0.113.7"
|
|
194
|
+
],
|
|
195
|
+
"received_foremail": [
|
|
196
|
+
"victim@example.org"
|
|
197
|
+
],
|
|
198
|
+
"authentication": {
|
|
199
|
+
"spf": [
|
|
200
|
+
"fail"
|
|
201
|
+
],
|
|
202
|
+
"dkim": [
|
|
203
|
+
"none"
|
|
204
|
+
],
|
|
205
|
+
"dmarc": [
|
|
206
|
+
"fail"
|
|
207
|
+
]
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
With `include_raw_body=True` the indicators of the body are reported in clear text instead of hashes. Note that the
|
|
214
|
+
obfuscated IP in the link is resolved and the share host is added to the domains:
|
|
215
|
+
|
|
216
|
+
```json
|
|
217
|
+
{
|
|
218
|
+
"uri": [
|
|
219
|
+
"http://3232235777/login",
|
|
220
|
+
"https://pay-secure.example/collect"
|
|
221
|
+
],
|
|
222
|
+
"unc": [
|
|
223
|
+
"\\\\files.evil.example\\share\\invoice.lnk"
|
|
224
|
+
],
|
|
225
|
+
"domain": [
|
|
226
|
+
"pay-secure.example",
|
|
227
|
+
"files.evil.example"
|
|
228
|
+
],
|
|
229
|
+
"ip": [
|
|
230
|
+
"192.168.1.1"
|
|
231
|
+
]
|
|
232
|
+
}
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
## Contributing
|
|
236
|
+
|
|
237
|
+
Please feel free to send me your comments / pull requests.
|
|
238
|
+
|
|
239
|
+
For the changelog, please see [CHANGELOG.md](CHANGELOG.md).
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Detection of tricks used to disguise the type of an attachment."""
|
|
2
|
+
|
|
3
|
+
import unicodedata
|
|
4
|
+
|
|
5
|
+
import eml_parser.constants
|
|
6
|
+
|
|
7
|
+
# Bidirectional control characters: the right-to-left override (U+202E) shows `invoice<U+202E>fdp.exe` as `invoiceexe.pdf`.
|
|
8
|
+
_BIDI_CONTROL = frozenset({'LRE', 'RLE', 'LRO', 'RLO', 'PDF', 'LRI', 'RLI', 'FSI', 'PDI'})
|
|
9
|
+
|
|
10
|
+
_PADDING = ' ' * 10
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def is_executable_content(data: bytes, mime_type: str | None = None) -> bool:
|
|
14
|
+
"""Check whether the data is a Windows (PE) or Linux (ELF) executable, by its signature or by the detected mime type."""
|
|
15
|
+
if mime_type in eml_parser.constants.EXECUTABLE_MIME_TYPES or data.startswith(b'\x7fELF'):
|
|
16
|
+
return True
|
|
17
|
+
|
|
18
|
+
if data.startswith(b'MZ') and len(data) >= 64:
|
|
19
|
+
pe_offset = int.from_bytes(data[60:64], 'little')
|
|
20
|
+
|
|
21
|
+
return data[pe_offset : pe_offset + 4] == b'PE\x00\x00'
|
|
22
|
+
|
|
23
|
+
return False
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def find_filename_anomalies(filename: str, data: bytes, mime_type: str | None = None) -> list[str]:
|
|
27
|
+
"""Look for tricks used to disguise the type of an attachment.
|
|
28
|
+
|
|
29
|
+
The result is a list of the following codes:
|
|
30
|
+
|
|
31
|
+
* *filename-bidi-control*: contains a bidirectional control character (see above).
|
|
32
|
+
* *filename-control-chars*: contains other control or invisible formatting characters (NUL, zero width space, ...).
|
|
33
|
+
* *filename-double-extension*: a document or picture extension followed by an executable one (`invoice.pdf.exe`).
|
|
34
|
+
* *filename-trailing-dot-or-space*: ends with a dot or space, which Windows silently removes.
|
|
35
|
+
* *filename-padding*: a run of 10 or more spaces, hiding the real extension from the display.
|
|
36
|
+
* *extension-content-mismatch*: executable content (PE or ELF) under the name of a document or picture, or without an extension.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
filename: The (decoded) name of the attachment.
|
|
40
|
+
data: The content of the attachment.
|
|
41
|
+
mime_type: The mime type of the content, if known.
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
list: The codes, empty if nothing is suspicious.
|
|
45
|
+
"""
|
|
46
|
+
anomalies: list[str] = []
|
|
47
|
+
|
|
48
|
+
if any(unicodedata.bidirectional(char) in _BIDI_CONTROL for char in filename):
|
|
49
|
+
anomalies.append('filename-bidi-control')
|
|
50
|
+
elif any(unicodedata.category(char) in ('Cc', 'Cf') for char in filename):
|
|
51
|
+
anomalies.append('filename-control-chars')
|
|
52
|
+
|
|
53
|
+
extensions = [part.lower().strip() for part in filename.rstrip(' .').split('.')[1:]]
|
|
54
|
+
|
|
55
|
+
if len(extensions) >= 2 and extensions[-1] in eml_parser.constants.EXECUTABLE_EXTENSIONS and extensions[-2] in eml_parser.constants.DECOY_EXTENSIONS:
|
|
56
|
+
anomalies.append('filename-double-extension')
|
|
57
|
+
|
|
58
|
+
if filename != filename.rstrip(' .'):
|
|
59
|
+
anomalies.append('filename-trailing-dot-or-space')
|
|
60
|
+
|
|
61
|
+
if _PADDING in filename:
|
|
62
|
+
anomalies.append('filename-padding')
|
|
63
|
+
|
|
64
|
+
harmless = {*eml_parser.constants.DECOY_EXTENSIONS, ''}
|
|
65
|
+
if (extensions[-1] if extensions else '') in harmless and is_executable_content(data, mime_type):
|
|
66
|
+
anomalies.append('extension-content-mismatch')
|
|
67
|
+
|
|
68
|
+
return anomalies
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Static tables used by the parser."""
|
|
2
|
+
|
|
3
|
+
# Common file name extensions: a bare "invoice.pdf" in a text is a file name, not a domain. An extension which is a TLD (zip, mov, sh, py, ...)
|
|
4
|
+
# is not a file name for the parser, whether it is listed here or not (the public suffix list decides), so this table does not go stale.
|
|
5
|
+
FILE_EXTENSIONS = frozenset(
|
|
6
|
+
"""
|
|
7
|
+
7z asp aspx avi bak bat bin bmp bz2 cfg cgi cmd cpl crt css csv dat db dll doc docm docx dotm dotx eml eot exe flv gif gz hta htm html ico img ini iso
|
|
8
|
+
jar jpeg jpg js jsp json lnk log mkv mp3 mp4 mpg msg msi odp ods odt otf pdf php png pot potm potx ppt pptm pptx ps1 psd rar rtf scr svg sys tar tgz
|
|
9
|
+
tif tiff tmp ttf txt vbe vbs wav webp wmv woff woff2 wsf xls xlsb xlsm xlsx xml xz yaml yml
|
|
10
|
+
""".split()
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
# Attachments which carry links or run something (scripts, shortcuts, web pages, SVG, invitations): they are scanned like the text of the e-mail.
|
|
14
|
+
# Data formats (XML, JSON, CSV, logs, ...) are left out on purpose: they are full of namespace and schema URLs and of data nobody wants as indicators.
|
|
15
|
+
TEXT_ATTACHMENT_EXTENSIONS = tuple(
|
|
16
|
+
f'.{extension}'
|
|
17
|
+
for extension in """
|
|
18
|
+
bat cmd eml hta htm html ics js jse mht mhtml php ps1 psm1 py rtf sh shtml svg txt url vbe vbs wsf wsh xhtml
|
|
19
|
+
""".split()
|
|
20
|
+
)
|
|
21
|
+
TEXT_ATTACHMENT_CONTENT_TYPES = frozenset(
|
|
22
|
+
"""
|
|
23
|
+
application/hta application/javascript application/rtf application/x-javascript application/x-sh application/xhtml+xml image/svg+xml
|
|
24
|
+
""".split()
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
# Attachments (not inline text) larger than this are not scanned as text; the size is the one of the encoded payload.
|
|
28
|
+
MAX_TEXT_ATTACHMENT_SIZE = 10 * 1024 * 1024
|
|
29
|
+
|
|
30
|
+
# File name extensions of programs and scripts, used to detect disguised executables.
|
|
31
|
+
EXECUTABLE_EXTENSIONS = frozenset('bat cmd com cpl dll exe hta jar js jse lnk msi pif ps1 reg scr vbe vbs wsf wsh'.split())
|
|
32
|
+
|
|
33
|
+
# File name extensions of documents and pictures, which a disguised executable is claiming to be.
|
|
34
|
+
DECOY_EXTENSIONS = frozenset('bmp csv doc docm docx gif htm html jpeg jpg pdf png ppt pptm pptx rtf tif tiff txt xls xlsm xlsx'.split())
|
|
35
|
+
|
|
36
|
+
EXECUTABLE_MIME_TYPES = frozenset(
|
|
37
|
+
"""
|
|
38
|
+
application/vnd.microsoft.portable-executable application/x-dosexec application/x-executable application/x-msdownload
|
|
39
|
+
application/x-mach-binary application/x-sharedlib application/x-pie-executable
|
|
40
|
+
""".split()
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
# Header values longer than this are not run through the structured header parsers of the stdlib (their cost grows with the square of the length).
|
|
44
|
+
# Real headers are far shorter: RFC 5322 recommends lines of 78 and limits them to 998 characters.
|
|
45
|
+
MAX_PARSED_HEADER_SIZE = 8 * 1024
|