Commit
+939 -45 +/-21 browse
1 | diff --git a/Cargo.toml b/Cargo.toml |
2 | index 6bdf063..18c3f76 100644 |
3 | --- a/Cargo.toml |
4 | +++ b/Cargo.toml |
5 | @@ -28,9 +28,9 @@ parking_lot = "0.12.0" |
6 | ahash = "0.8.0" |
7 | quick-xml = "0.27.1" |
8 | serde = { version = "1.0", features = ["derive"] } |
9 | + serde_json = "1.0" |
10 | zip = "0.6.3" |
11 | flate2 = "1.0.25" |
12 | |
13 | [dev-dependencies] |
14 | tokio = { version = "1.16", features = ["net", "io-util", "time", "rt-multi-thread", "macros"] } |
15 | - serde_json = "1.0" |
16 | diff --git a/README.md b/README.md |
17 | index 963779c..934c564 100644 |
18 | --- a/README.md |
19 | +++ b/README.md |
20 | @@ -26,7 +26,8 @@ Features: |
21 | - **Abuse Reporting Format (ARF)**: |
22 | - Abuse and Authentication failure reporting. |
23 | - Feedback report parsing and generation. |
24 | - |
25 | + - **SMTP TLS Reporting**: |
26 | + - Report parsing and generation. |
27 | |
28 | ## Usage examples |
29 | |
30 | @@ -228,6 +229,9 @@ To fuzz the library with `cargo-fuzz`: |
31 | - [RFC 6591 - Authentication Failure Reporting Using the Abuse Reporting Format](https://datatracker.ietf.org/doc/html/rfc6591) |
32 | - [RFC 6650 - Creation and Use of Email Feedback Reports: An Applicability Statement for the Abuse Reporting Format (ARF)](https://datatracker.ietf.org/doc/html/rfc6650) |
33 | |
34 | + ### SMTP TLS Reporting |
35 | + - [RFC 8460 - SMTP TLS Reporting](https://datatracker.ietf.org/doc/html/rfc8460) |
36 | + |
37 | ## License |
38 | |
39 | Licensed under either of |
40 | diff --git a/examples/report_arf_generate.rs b/examples/report_arf_generate.rs |
41 | index c37ec71..fa33503 100644 |
42 | --- a/examples/report_arf_generate.rs |
43 | +++ b/examples/report_arf_generate.rs |
44 | @@ -39,7 +39,7 @@ fn main() { |
45 | .with_spf_dns("v=spf1") |
46 | .with_identity_alignment(IdentityAlignment::DkimSpf) |
47 | .with_message(&b"From: hello@world.org\r\nTo: ciao@mundo.org\r\n\r\n"[..]) |
48 | - .as_rfc5322( |
49 | + .to_rfc5322( |
50 | "no-reply@example.org", |
51 | "ruf@otherdomain.com", |
52 | "DMARC Authentication Failure Report", |
53 | diff --git a/examples/report_dmarc_generate.rs b/examples/report_dmarc_generate.rs |
54 | index b730eb8..51b3b14 100644 |
55 | --- a/examples/report_dmarc_generate.rs |
56 | +++ b/examples/report_dmarc_generate.rs |
57 | @@ -97,7 +97,7 @@ fn main() { |
58 | .with_human_result("no policy found"), |
59 | ), |
60 | ) |
61 | - .as_rfc5322( |
62 | + .to_rfc5322( |
63 | "initech.net", |
64 | "Initech Industries", |
65 | "noreply-dmarc@initech.net", |
66 | diff --git a/resources/tlsrpt/rpt01.eml b/resources/tlsrpt/rpt01.eml |
67 | new file mode 100644 |
68 | index 0000000..e54c9e8 |
69 | --- /dev/null |
70 | +++ b/resources/tlsrpt/rpt01.eml |
71 | @@ -0,0 +1,41 @@ |
72 | + From: tlsrpt@mail.sender.example.com |
73 | + Date: Fri, May 09 2017 16:54:30 -0800 |
74 | + To: mts-sts-tlsrpt@example.net |
75 | + Subject: Report Domain: example.net |
76 | + Submitter: mail.sender.example.com |
77 | + Report-ID: <735ff.e317+bf22029@example.net> |
78 | + TLS-Report-Domain: example.net |
79 | + TLS-Report-Submitter: mail.sender.example.com |
80 | + MIME-Version: 1.0 |
81 | + Content-Type: multipart/report; report-type="tlsrpt"; |
82 | + boundary="----=_NextPart_000_024E_01CC9B0A.AFE54C00" |
83 | + Content-Language: en-us |
84 | + |
85 | + This is a multipart message in MIME format. |
86 | + |
87 | + ------=_NextPart_000_024E_01CC9B0A.AFE54C00 |
88 | + Content-Type: text/plain; charset="us-ascii" |
89 | + Content-Transfer-Encoding: 7bit |
90 | + |
91 | + This is an aggregate TLS report from mail.sender.example.com |
92 | + |
93 | + ------=_NextPart_000_024E_01CC9B0A.AFE54C00 |
94 | + Content-Type: application/tlsrpt+gzip |
95 | + Content-Transfer-Encoding: base64 |
96 | + Content-Disposition: attachment; |
97 | + filename="mail.sender.example!example.com!1013662812!1013749130.json.gz" |
98 | + |
99 | + H4sICCpFtWMAA3JwdDAxLmpzb24uMQCtVVtr2zAYfe+vEN7bmFzZjt3EMLbRhu1hdCUJI+soRpGU |
100 | + VMy2jCSHZCX/fZIvzVLHpc0WDDHSOfqOznfxwxkwP0fIFc75b6y5yGGOM+bEwLkUWYHzLZw772oU |
101 | + xZpBifOV3X6o1qp1pbHU0O5qXlN95EUQDSDyZgjF1XPbnFIxWE778H4QhyPz3DoVfNfEJiLXmGjI |
102 | + 86WwDKUVlKwQUvN89ZE0Ujcu2+CsSFkruYZATi0nRFE48C8I9AMawMEFwXARMQRHg4hhxIZksHgk |
103 | + FiLlhDNleD8fde/vvMdsD7x4sgf1tmCN3L/u/xSltDS3OAh1AFszqUxmYjCdTdfekYMqVCYoi4Fm |
104 | + ylrSC9rE4K2bYZ66rWnbJ6Z1OXiT4JU5exgNEHI6oLv+m1FhQuXWgZdEM+rgvVC634le6V1RByu7 |
105 | + w2COKrMMy57caaFxClVJCFNqWZpX8287g4gyt+LCwI+OqK95SyOwlKxDClDwrKSWR5k2b+qoB12x |
106 | + FVUyVab6sdgIM12x5MS2K9sUXDLal1plOtFUC8w0hryoWxF5MV0MY7wg1PSt58dxb8lJRhhfVwfU |
107 | + mWtnR7bxXllk9vqMdlzzEOrgd90jXmZMNah0qmAutMlvYWfDP3kTnOaN/0pv9ke1OgIXuZ4XuGH0 |
108 | + Sj/NFXoImFJu578pYTtkZVZ9DWy4e60LFZ+f18NUuZ1p2+wklgc+AE7Be9AMW0AABH4AaPAGTBv7 |
109 | + r4WetuaDbueenN41Tjmtv2FNM708td5o6Iaea8rNjfwT8jA8oQzgApNfZfF/GiV4Bm7HimRYVXBa |
110 | + RZ+HaJR8T8aTSXIz+Tb/kdx8mn1Jvo6vP5u/8fxyPL4aXx3JzcHKfsbW63dnuz+8byfQUQgAAA== |
111 | + |
112 | + ------=_NextPart_000_024E_01CC9B0A.AFE54C00-- |
113 | diff --git a/resources/tlsrpt/rpt01.json b/resources/tlsrpt/rpt01.json |
114 | new file mode 100644 |
115 | index 0000000..cc27eae |
116 | --- /dev/null |
117 | +++ b/resources/tlsrpt/rpt01.json |
118 | @@ -0,0 +1,54 @@ |
119 | + { |
120 | + "organization-name": "Company-X", |
121 | + "date-range": { |
122 | + "start-datetime": "2016-04-01T00:00:00Z", |
123 | + "end-datetime": "2016-04-01T23:59:59Z" |
124 | + }, |
125 | + "contact-info": "sts-reporting@company-x.example", |
126 | + "report-id": "5065427c-23d3-47ca-b6e0-946ea0e8c4be", |
127 | + "policies": [ |
128 | + { |
129 | + "policy": { |
130 | + "policy-type": "sts", |
131 | + "policy-string": [ |
132 | + "version: STSv1", |
133 | + "mode: testing", |
134 | + "mx: *.mail.company-y.example", |
135 | + "max_age: 86400" |
136 | + ], |
137 | + "policy-domain": "company-y.example", |
138 | + "mx-host": [ |
139 | + "*.mail.company-y.example" |
140 | + ] |
141 | + }, |
142 | + "summary": { |
143 | + "total-successful-session-count": 5326, |
144 | + "total-failure-session-count": 303 |
145 | + }, |
146 | + "failure-details": [ |
147 | + { |
148 | + "result-type": "certificate-expired", |
149 | + "sending-mta-ip": "2001:db8:abcd:0012::1", |
150 | + "receiving-mx-hostname": "mx1.mail.company-y.example", |
151 | + "failed-session-count": 100 |
152 | + }, |
153 | + { |
154 | + "result-type": "starttls-not-supported", |
155 | + "sending-mta-ip": "2001:db8:abcd:0013::1", |
156 | + "receiving-mx-hostname": "mx2.mail.company-y.example", |
157 | + "receiving-ip": "203.0.113.56", |
158 | + "failed-session-count": 200, |
159 | + "additional-information": "https://reports.company-x.example/report_info ? id = 5065427 c - 23 d3# StarttlsNotSupported " |
160 | + }, |
161 | + { |
162 | + "result-type": "validation-failure", |
163 | + "sending-mta-ip": "198.51.100.62", |
164 | + "receiving-ip": "203.0.113.58", |
165 | + "receiving-mx-hostname": "mx-backup.mail.company-y.example", |
166 | + "failed-session-count": 3, |
167 | + "failure-reason-code": "X509_V_ERR_PROXY_PATH_LENGTH_EXCEEDED" |
168 | + } |
169 | + ] |
170 | + } |
171 | + ] |
172 | + } |
173 | \ No newline at end of file |
174 | diff --git a/resources/tlsrpt/rpt02.eml b/resources/tlsrpt/rpt02.eml |
175 | new file mode 100644 |
176 | index 0000000..afb8687 |
177 | --- /dev/null |
178 | +++ b/resources/tlsrpt/rpt02.eml |
179 | @@ -0,0 +1,64 @@ |
180 | + From: tlsrpt@mail.sender.example.com |
181 | + Date: Fri, May 09 2017 16:54:30 -0800 |
182 | + To: mts-sts-tlsrpt@example.net |
183 | + Subject: Report Domain: example.net |
184 | + Submitter: mail.sender.example.com |
185 | + Report-ID: <735ff.e317+bf22029@example.net> |
186 | + TLS-Report-Domain: example.net |
187 | + TLS-Report-Submitter: mail.sender.example.com |
188 | + MIME-Version: 1.0 |
189 | + Content-Type: multipart/report; report-type="tlsrpt"; |
190 | + boundary="----=_NextPart_000_024E_01CC9B0A.AFE54C00" |
191 | + Content-Language: en-us |
192 | + |
193 | + This is a multipart message in MIME format. |
194 | + |
195 | + ------=_NextPart_000_024E_01CC9B0A.AFE54C00 |
196 | + Content-Type: text/plain; charset="us-ascii" |
197 | + Content-Transfer-Encoding: 7bit |
198 | + |
199 | + This is an aggregate TLS report from mail.sender.example.com |
200 | + |
201 | + ------=_NextPart_000_024E_01CC9B0A.AFE54C00 |
202 | + Content-Type: application/tlsrpt |
203 | + Content-Disposition: attachment; |
204 | + filename="mail.sender.example!example.com!1013662812!1013749130.json" |
205 | + |
206 | + { |
207 | + "report-id": "2020-01-01T00:00:00Z_example.com", |
208 | + "date-range": { |
209 | + "start-datetime": "2020-01-01T00:00:00Z", |
210 | + "end-datetime": "2020-01-07T23:59:59Z" |
211 | + }, |
212 | + "organization-name": "Google Inc.", |
213 | + "contact-info": "smtp-tls-reporting@google.com", |
214 | + "policies": [ |
215 | + { |
216 | + "policy": { |
217 | + "policy-type": "sts", |
218 | + "policy-string": [ |
219 | + "version: STSv1", |
220 | + "mode: enforce", |
221 | + "mx: demo.example.com", |
222 | + "max_age: 604800" |
223 | + ], |
224 | + "policy-domain": "example.com" |
225 | + }, |
226 | + "summary": { |
227 | + "total-successful-session-count": 23, |
228 | + "total-failure-session-count": 1 |
229 | + }, |
230 | + "failure-details": [ |
231 | + { |
232 | + "result-type": "certificate-host-mismatch", |
233 | + "sending-mta-ip": "123.123.123.123", |
234 | + "receiving-ip": "234.234.234.234", |
235 | + "receiving-mx-hostname": "demo.example.com", |
236 | + "failed-session-count": 1 |
237 | + } |
238 | + ] |
239 | + } |
240 | + ] |
241 | + } |
242 | + |
243 | + ------=_NextPart_000_024E_01CC9B0A.AFE54C00-- |
244 | diff --git a/resources/tlsrpt/rpt02.json b/resources/tlsrpt/rpt02.json |
245 | new file mode 100644 |
246 | index 0000000..b8c0b47 |
247 | --- /dev/null |
248 | +++ b/resources/tlsrpt/rpt02.json |
249 | @@ -0,0 +1,36 @@ |
250 | + { |
251 | + "report-id": "2020-01-01T00:00:00Z_example.com", |
252 | + "date-range": { |
253 | + "start-datetime": "2020-01-01T00:00:00Z", |
254 | + "end-datetime": "2020-01-07T23:59:59Z" |
255 | + }, |
256 | + "organization-name": "Google Inc.", |
257 | + "contact-info": "smtp-tls-reporting@google.com", |
258 | + "policies": [ |
259 | + { |
260 | + "policy": { |
261 | + "policy-type": "sts", |
262 | + "policy-string": [ |
263 | + "version: STSv1", |
264 | + "mode: enforce", |
265 | + "mx: demo.example.com", |
266 | + "max_age: 604800" |
267 | + ], |
268 | + "policy-domain": "example.com" |
269 | + }, |
270 | + "summary": { |
271 | + "total-successful-session-count": 23, |
272 | + "total-failure-session-count": 1 |
273 | + }, |
274 | + "failure-details": [ |
275 | + { |
276 | + "result-type": "certificate-host-mismatch", |
277 | + "sending-mta-ip": "123.123.123.123", |
278 | + "receiving-ip": "234.234.234.234", |
279 | + "receiving-mx-hostname": "demo.example.com", |
280 | + "failed-session-count": 1 |
281 | + } |
282 | + ] |
283 | + } |
284 | + ] |
285 | + } |
286 | \ No newline at end of file |
287 | diff --git a/resources/tlsrpt/rpt03.json b/resources/tlsrpt/rpt03.json |
288 | new file mode 100644 |
289 | index 0000000..b6e7285 |
290 | --- /dev/null |
291 | +++ b/resources/tlsrpt/rpt03.json |
292 | @@ -0,0 +1,27 @@ |
293 | + { |
294 | + "organization-name": "Google Inc.", |
295 | + "date-range": { |
296 | + "start-datetime": "2019-04-14T00:00:00Z", |
297 | + "end-datetime": "2019-04-14T23:59:59Z" |
298 | + }, |
299 | + "contact-info": "smtp-tls-reporting@google.com", |
300 | + "report-id": "2019-04-14T00:00:00Z_example.org", |
301 | + "policies": [ |
302 | + { |
303 | + "policy": { |
304 | + "policy-type": "no-policy-found" |
305 | + }, |
306 | + "summary": { |
307 | + "total-successful-session-count": 1 |
308 | + } |
309 | + }, |
310 | + { |
311 | + "policy": { |
312 | + "policy-type": "invalid-policy-type" |
313 | + }, |
314 | + "summary": { |
315 | + "total-successful-session-count": 1 |
316 | + } |
317 | + } |
318 | + ] |
319 | + } |
320 | \ No newline at end of file |
321 | diff --git a/src/common/parse.rs b/src/common/parse.rs |
322 | index 0022afc..adf75ba 100644 |
323 | --- a/src/common/parse.rs |
324 | +++ b/src/common/parse.rs |
325 | @@ -42,7 +42,7 @@ pub(crate) trait TagParser: Sized { |
326 | fn key(&mut self) -> Option<u64>; |
327 | fn value(&mut self) -> u64; |
328 | fn text(&mut self, to_lower: bool) -> String; |
329 | - fn text_qp(&mut self, to_lower: bool) -> String; |
330 | + fn text_qp(&mut self, base: Vec<u8>, to_lower: bool, stop_comma: bool) -> String; |
331 | fn headers_qp<T: ItemParser>(&mut self) -> Vec<T>; |
332 | fn number(&mut self) -> Option<u64>; |
333 | fn items<T: ItemParser>(&mut self) -> Vec<T>; |
334 | @@ -187,10 +187,9 @@ impl TagParser for Iter<'_, u8> { |
335 | |
336 | #[inline(always)] |
337 | #[allow(clippy::while_let_on_iterator)] |
338 | - fn text_qp(&mut self, to_lower: bool) -> String { |
339 | - let mut tag = Vec::with_capacity(20); |
340 | + fn text_qp(&mut self, mut tag: Vec<u8>, to_lower: bool, stop_comma: bool) -> String { |
341 | 'outer: while let Some(&ch) = self.next() { |
342 | - if ch == b';' { |
343 | + if ch == b';' || (stop_comma && ch == b',') { |
344 | break; |
345 | } else if ch == b'=' { |
346 | let mut hex1 = 0; |
347 | diff --git a/src/common/resolver.rs b/src/common/resolver.rs |
348 | index 57bd983..2ecad3a 100644 |
349 | --- a/src/common/resolver.rs |
350 | +++ b/src/common/resolver.rs |
351 | @@ -25,6 +25,7 @@ use trust_dns_resolver::{ |
352 | use crate::{ |
353 | dkim::{Atps, DomainKeyReport}, |
354 | dmarc::Dmarc, |
355 | + mta_sts::{MtaSts, TlsRpt}, |
356 | spf::{Macro, Spf}, |
357 | Error, Resolver, Txt, MX, |
358 | }; |
359 | @@ -99,7 +100,7 @@ impl Resolver { |
360 | }) |
361 | } |
362 | |
363 | - pub(crate) async fn txt_lookup<'x, T: TxtRecordParser + Into<Txt> + UnwrapTxtRecord>( |
364 | + pub async fn txt_lookup<'x, T: TxtRecordParser + Into<Txt> + UnwrapTxtRecord>( |
365 | &self, |
366 | key: impl IntoFqdn<'x>, |
367 | ) -> crate::Result<Arc<T>> { |
368 | @@ -396,6 +397,18 @@ impl From<Dmarc> for Txt { |
369 | } |
370 | } |
371 | |
372 | + impl From<MtaSts> for Txt { |
373 | + fn from(v: MtaSts) -> Self { |
374 | + Txt::MtaSts(v.into()) |
375 | + } |
376 | + } |
377 | + |
378 | + impl From<TlsRpt> for Txt { |
379 | + fn from(v: TlsRpt) -> Self { |
380 | + Txt::TlsRpt(v.into()) |
381 | + } |
382 | + } |
383 | + |
384 | impl<T: Into<Txt>> From<crate::Result<T>> for Txt { |
385 | fn from(v: crate::Result<T>) -> Self { |
386 | match v { |
387 | @@ -405,7 +418,7 @@ impl<T: Into<Txt>> From<crate::Result<T>> for Txt { |
388 | } |
389 | } |
390 | |
391 | - pub(crate) trait UnwrapTxtRecord: Sized { |
392 | + pub trait UnwrapTxtRecord: Sized { |
393 | fn unwrap_txt(txt: Txt) -> crate::Result<Arc<Self>>; |
394 | } |
395 | |
396 | @@ -469,6 +482,26 @@ impl UnwrapTxtRecord for Dmarc { |
397 | } |
398 | } |
399 | |
400 | + impl UnwrapTxtRecord for MtaSts { |
401 | + fn unwrap_txt(txt: Txt) -> crate::Result<Arc<Self>> { |
402 | + match txt { |
403 | + Txt::MtaSts(a) => Ok(a), |
404 | + Txt::Error(err) => Err(err), |
405 | + _ => Err(Error::Io("Invalid record type".to_string())), |
406 | + } |
407 | + } |
408 | + } |
409 | + |
410 | + impl UnwrapTxtRecord for TlsRpt { |
411 | + fn unwrap_txt(txt: Txt) -> crate::Result<Arc<Self>> { |
412 | + match txt { |
413 | + Txt::TlsRpt(a) => Ok(a), |
414 | + Txt::Error(err) => Err(err), |
415 | + _ => Err(Error::Io("Invalid record type".to_string())), |
416 | + } |
417 | + } |
418 | + } |
419 | + |
420 | pub trait IntoFqdn<'x> { |
421 | fn into_fqdn(self) -> Cow<'x, str>; |
422 | } |
423 | diff --git a/src/dkim/parse.rs b/src/dkim/parse.rs |
424 | index 33ccfe7..4ae0f15 100644 |
425 | --- a/src/dkim/parse.rs |
426 | +++ b/src/dkim/parse.rs |
427 | @@ -94,7 +94,7 @@ impl<'x> Signature<'x> { |
428 | } |
429 | D => signature.d = header.text(true).into(), |
430 | H => signature.h = header.items(), |
431 | - I => signature.i = header.text_qp(true).into(), |
432 | + I => signature.i = header.text_qp(Vec::with_capacity(20), true, false).into(), |
433 | L => signature.l = header.number().unwrap_or(0), |
434 | S => signature.s = header.text(true).into(), |
435 | T => signature.t = header.number().unwrap_or(0), |
436 | @@ -318,13 +318,13 @@ impl TxtRecordParser for DomainKeyReport { |
437 | while let Some(key) = header.key() { |
438 | match key { |
439 | RA => { |
440 | - record.ra = header.text_qp(true); |
441 | + record.ra = header.text_qp(Vec::with_capacity(20), true, false); |
442 | } |
443 | RP => { |
444 | record.rp = std::cmp::min(header.number().unwrap_or(0), 100) as u8; |
445 | } |
446 | RS => { |
447 | - record.rs = header.text_qp(false).into(); |
448 | + record.rs = header.text_qp(Vec::with_capacity(20), false, false).into(); |
449 | } |
450 | RR => { |
451 | record.rr = 0; |
452 | diff --git a/src/lib.rs b/src/lib.rs |
453 | index 878e878..4ad1018 100644 |
454 | --- a/src/lib.rs |
455 | +++ b/src/lib.rs |
456 | @@ -36,7 +36,8 @@ |
457 | //! - **Abuse Reporting Format (ARF)**: |
458 | //! - Abuse and Authentication failure reporting. |
459 | //! - Feedback report parsing and generation. |
460 | - //! |
461 | + //! - **SMTP TLS Reporting**: |
462 | + //! - Report parsing and generation. |
463 | //! |
464 | //! ## Usage examples |
465 | //! |
466 | @@ -238,6 +239,9 @@ |
467 | //! - [RFC 6591 - Authentication Failure Reporting Using the Abuse Reporting Format](https://datatracker.ietf.org/doc/html/rfc6591) |
468 | //! - [RFC 6650 - Creation and Use of Email Feedback Reports: An Applicability Statement for the Abuse Reporting Format (ARF)](https://datatracker.ietf.org/doc/html/rfc6650) |
469 | //! |
470 | + //! ### SMTP TLS Reporting |
471 | + //! - [RFC 8460 - SMTP TLS Reporting](https://datatracker.ietf.org/doc/html/rfc8460) |
472 | + //! |
473 | //! ## License |
474 | //! |
475 | //! Licensed under either of |
476 | @@ -265,6 +269,7 @@ use arc::Set; |
477 | use common::{crypto::HashAlgorithm, headers::Header, lru::LruCache, verify::DomainKey}; |
478 | use dkim::{Atps, Canonicalization, DomainKeyReport}; |
479 | use dmarc::Dmarc; |
480 | + use mta_sts::{MtaSts, TlsRpt}; |
481 | use spf::{Macro, Spf}; |
482 | use trust_dns_resolver::{proto::op::ResponseCode, TokioAsyncResolver}; |
483 | |
484 | @@ -272,6 +277,7 @@ pub mod arc; |
485 | pub mod common; |
486 | pub mod dkim; |
487 | pub mod dmarc; |
488 | + pub mod mta_sts; |
489 | pub mod report; |
490 | pub mod spf; |
491 | |
492 | @@ -289,13 +295,15 @@ pub struct Resolver { |
493 | } |
494 | |
495 | #[derive(Clone)] |
496 | - pub(crate) enum Txt { |
497 | + pub enum Txt { |
498 | Spf(Arc<Spf>), |
499 | SpfMacro(Arc<Macro>), |
500 | DomainKey(Arc<DomainKey>), |
501 | DomainKeyReport(Arc<DomainKeyReport>), |
502 | Dmarc(Arc<Dmarc>), |
503 | Atps(Arc<Atps>), |
504 | + MtaSts(Arc<MtaSts>), |
505 | + TlsRpt(Arc<TlsRpt>), |
506 | Error(Error), |
507 | } |
508 | |
509 | diff --git a/src/mta_sts/mod.rs b/src/mta_sts/mod.rs |
510 | new file mode 100644 |
511 | index 0000000..b090406 |
512 | --- /dev/null |
513 | +++ b/src/mta_sts/mod.rs |
514 | @@ -0,0 +1,17 @@ |
515 | + pub mod parse; |
516 | + |
517 | + #[derive(Debug, PartialEq, Eq)] |
518 | + pub struct MtaSts { |
519 | + pub id: String, |
520 | + } |
521 | + |
522 | + #[derive(Debug, PartialEq, Eq)] |
523 | + pub struct TlsRpt { |
524 | + rua: Vec<ReportUri>, |
525 | + } |
526 | + |
527 | + #[derive(Debug, PartialEq, Eq)] |
528 | + pub enum ReportUri { |
529 | + Mail(String), |
530 | + Http(String), |
531 | + } |
532 | diff --git a/src/mta_sts/parse.rs b/src/mta_sts/parse.rs |
533 | new file mode 100644 |
534 | index 0000000..be8ede3 |
535 | --- /dev/null |
536 | +++ b/src/mta_sts/parse.rs |
537 | @@ -0,0 +1,162 @@ |
538 | + use crate::common::parse::{TagParser, TxtRecordParser, V}; |
539 | + |
540 | + use super::{MtaSts, ReportUri, TlsRpt}; |
541 | + |
542 | + const ID: u64 = (b'i' as u64) | ((b'd' as u64) << 8); |
543 | + const RUA: u64 = (b'r' as u64) | (b'u' as u64) << 8 | (b'a' as u64) << 16; |
544 | + |
545 | + const MAILTO: u64 = (b'm' as u64) |
546 | + | (b'a' as u64) << 8 |
547 | + | (b'i' as u64) << 16 |
548 | + | (b'l' as u64) << 24 |
549 | + | (b't' as u64) << 32 |
550 | + | (b'o' as u64) << 40; |
551 | + const HTTPS: u64 = (b'h' as u64) |
552 | + | (b't' as u64) << 8 |
553 | + | (b't' as u64) << 16 |
554 | + | (b'p' as u64) << 24 |
555 | + | (b's' as u64) << 32; |
556 | + |
557 | + impl TxtRecordParser for MtaSts { |
558 | + #[allow(clippy::while_let_on_iterator)] |
559 | + fn parse(record: &[u8]) -> crate::Result<Self> { |
560 | + let mut record = record.iter(); |
561 | + let mut id = None; |
562 | + let mut has_version = false; |
563 | + |
564 | + while let Some(key) = record.key() { |
565 | + match key { |
566 | + V => { |
567 | + if !record.match_bytes(b"STSv1") || !record.seek_tag_end() { |
568 | + return Err(crate::Error::InvalidRecordType); |
569 | + } |
570 | + has_version = true; |
571 | + } |
572 | + ID => { |
573 | + id = record.text(false).into(); |
574 | + } |
575 | + _ => { |
576 | + record.ignore(); |
577 | + } |
578 | + } |
579 | + } |
580 | + |
581 | + if let Some(id) = id { |
582 | + if has_version { |
583 | + return Ok(MtaSts { id }); |
584 | + } |
585 | + } |
586 | + Err(crate::Error::InvalidRecordType) |
587 | + } |
588 | + } |
589 | + |
590 | + impl TxtRecordParser for TlsRpt { |
591 | + #[allow(clippy::while_let_on_iterator)] |
592 | + fn parse(record: &[u8]) -> crate::Result<Self> { |
593 | + let mut record = record.iter(); |
594 | + |
595 | + if record.key().unwrap_or(0) != V |
596 | + || !record.match_bytes(b"TLSRPTv1") |
597 | + || !record.seek_tag_end() |
598 | + { |
599 | + return Err(crate::Error::InvalidRecordType); |
600 | + } |
601 | + |
602 | + let mut rua = Vec::new(); |
603 | + |
604 | + while let Some(key) = record.key() { |
605 | + match key { |
606 | + RUA => loop { |
607 | + match record.flag_value() { |
608 | + (MAILTO, b':') => { |
609 | + let mail_to = record.text_qp(Vec::with_capacity(20), false, true); |
610 | + if !mail_to.is_empty() { |
611 | + rua.push(ReportUri::Mail(mail_to)); |
612 | + } |
613 | + } |
614 | + (HTTPS, b':') => { |
615 | + let mut url = Vec::with_capacity(20); |
616 | + url.extend_from_slice(b"https:"); |
617 | + let url = record.text_qp(url, false, true); |
618 | + if !url.is_empty() { |
619 | + rua.push(ReportUri::Http(url)); |
620 | + } |
621 | + } |
622 | + _ => { |
623 | + record.ignore(); |
624 | + break; |
625 | + } |
626 | + } |
627 | + }, |
628 | + _ => { |
629 | + record.ignore(); |
630 | + } |
631 | + } |
632 | + } |
633 | + |
634 | + if !rua.is_empty() { |
635 | + Ok(TlsRpt { rua }) |
636 | + } else { |
637 | + Err(crate::Error::InvalidRecordType) |
638 | + } |
639 | + } |
640 | + } |
641 | + |
642 | + #[cfg(test)] |
643 | + mod tests { |
644 | + use crate::{ |
645 | + common::parse::TxtRecordParser, |
646 | + mta_sts::{MtaSts, ReportUri, TlsRpt}, |
647 | + }; |
648 | + |
649 | + #[test] |
650 | + fn mta_sts_record_parse() { |
651 | + for (mta_sts, expected_mta_sts) in [ |
652 | + ( |
653 | + "v=STSv1; id=20160831085700Z;", |
654 | + MtaSts { |
655 | + id: "20160831085700Z".to_string(), |
656 | + }, |
657 | + ), |
658 | + ( |
659 | + "v=STSv1; id=20190429T010101", |
660 | + MtaSts { |
661 | + id: "20190429T010101".to_string(), |
662 | + }, |
663 | + ), |
664 | + ] { |
665 | + assert_eq!(MtaSts::parse(mta_sts.as_bytes()).unwrap(), expected_mta_sts); |
666 | + } |
667 | + } |
668 | + |
669 | + #[test] |
670 | + fn tlsrpt_parse() { |
671 | + for (tls_rpt, expected_tls_rpt) in [ |
672 | + ( |
673 | + "v=TLSRPTv1;rua=mailto:reports@example.com", |
674 | + TlsRpt { |
675 | + rua: vec![ReportUri::Mail("reports@example.com".to_string())], |
676 | + }, |
677 | + ), |
678 | + ( |
679 | + "v=TLSRPTv1; rua=https://reporting.example.com/v1/tlsrpt", |
680 | + TlsRpt { |
681 | + rua: vec![ReportUri::Http( |
682 | + "https://reporting.example.com/v1/tlsrpt".to_string(), |
683 | + )], |
684 | + }, |
685 | + ), |
686 | + ( |
687 | + "v=TLSRPTv1; rua=mailto:tlsrpt@mydomain.com,https://tlsrpt.mydomain.com/v1", |
688 | + TlsRpt { |
689 | + rua: vec![ |
690 | + ReportUri::Mail("tlsrpt@mydomain.com".to_string()), |
691 | + ReportUri::Http("https://tlsrpt.mydomain.com/v1".to_string()), |
692 | + ], |
693 | + }, |
694 | + ), |
695 | + ] { |
696 | + assert_eq!(TlsRpt::parse(tls_rpt.as_bytes()).unwrap(), expected_tls_rpt); |
697 | + } |
698 | + } |
699 | + } |
700 | diff --git a/src/report/arf/generate.rs b/src/report/arf/generate.rs |
701 | index 75a2309..68fad46 100644 |
702 | --- a/src/report/arf/generate.rs |
703 | +++ b/src/report/arf/generate.rs |
704 | @@ -29,7 +29,7 @@ impl<'x> Feedback<'x> { |
705 | ) -> io::Result<()> { |
706 | // Generate ARF |
707 | |
708 | - let arf = self.as_arf(); |
709 | + let arf = self.to_arf(); |
710 | |
711 | // Generate text/plain body |
712 | let mut text_body = String::with_capacity(128); |
713 | @@ -101,13 +101,13 @@ impl<'x> Feedback<'x> { |
714 | .write_to(writer) |
715 | } |
716 | |
717 | - pub fn as_rfc5322(&self, from: &str, to: &str, subject: &str) -> io::Result<String> { |
718 | + pub fn to_rfc5322(&self, from: &str, to: &str, subject: &str) -> io::Result<String> { |
719 | let mut buf = Vec::new(); |
720 | self.write_rfc5322(from, to, subject, &mut buf)?; |
721 | String::from_utf8(buf).map_err(|err| io::Error::new(io::ErrorKind::Other, err)) |
722 | } |
723 | |
724 | - pub fn as_arf(&self) -> String { |
725 | + pub fn to_arf(&self) -> String { |
726 | let mut arf = String::with_capacity(128); |
727 | |
728 | write!(&mut arf, "Version: {}\r\n", self.version).ok(); |
729 | @@ -275,7 +275,7 @@ mod test { |
730 | .with_message(&b"From: hello@world.org\r\nTo: ciao@mundo.org\r\n\r\n"[..]); |
731 | |
732 | let message = feedback |
733 | - .as_rfc5322( |
734 | + .to_rfc5322( |
735 | "no-reply@example.org", |
736 | "ruf@otherdomain.com", |
737 | "DMARC Authentication Failure Report", |
738 | diff --git a/src/report/dmarc/generate.rs b/src/report/dmarc/generate.rs |
739 | index e35f00e..1d53148 100644 |
740 | --- a/src/report/dmarc/generate.rs |
741 | +++ b/src/report/dmarc/generate.rs |
742 | @@ -33,7 +33,7 @@ impl Report { |
743 | writer: impl io::Write, |
744 | ) -> io::Result<()> { |
745 | // Compress XML report |
746 | - let xml = self.as_xnl(); |
747 | + let xml = self.to_xml(); |
748 | let mut e = GzEncoder::new(Vec::with_capacity(xml.len()), Compression::default()); |
749 | io::Write::write_all(&mut e, xml.as_bytes())?; |
750 | let compressed_bytes = e.finish()?; |
751 | @@ -74,7 +74,7 @@ impl Report { |
752 | .write_to(writer) |
753 | } |
754 | |
755 | - pub fn as_rfc5322<'x>( |
756 | + pub fn to_rfc5322<'x>( |
757 | &self, |
758 | receiver_domain: &'x str, |
759 | submitter: &'x str, |
760 | @@ -86,17 +86,17 @@ impl Report { |
761 | String::from_utf8(buf).map_err(|err| io::Error::new(io::ErrorKind::Other, err)) |
762 | } |
763 | |
764 | - pub fn as_xnl(&self) -> String { |
765 | + pub fn to_xml(&self) -> String { |
766 | let mut xml = String::with_capacity(128); |
767 | writeln!(&mut xml, "<?xml version=\"1.0\" encoding=\"UTF-8\" ?>").ok(); |
768 | writeln!(&mut xml, "<feedback>").ok(); |
769 | if self.version != 0.0 { |
770 | writeln!(&mut xml, "\t<version>{}</version>", self.version).ok(); |
771 | } |
772 | - self.report_metadata.as_xml(&mut xml); |
773 | - self.policy_published.as_xml(&mut xml); |
774 | + self.report_metadata.to_xml(&mut xml); |
775 | + self.policy_published.to_xml(&mut xml); |
776 | for record in &self.record { |
777 | - record.as_xml(&mut xml); |
778 | + record.to_xml(&mut xml); |
779 | } |
780 | writeln!(&mut xml, "</feedback>").ok(); |
781 | xml |
782 | @@ -104,7 +104,7 @@ impl Report { |
783 | } |
784 | |
785 | impl ReportMetadata { |
786 | - pub(crate) fn as_xml(&self, xml: &mut String) { |
787 | + pub(crate) fn to_xml(&self, xml: &mut String) { |
788 | writeln!(xml, "\t<report_metadata>").ok(); |
789 | writeln!( |
790 | xml, |
791 | @@ -127,7 +127,7 @@ impl ReportMetadata { |
792 | escape_xml(&self.report_id) |
793 | ) |
794 | .ok(); |
795 | - self.date_range.as_xml(xml); |
796 | + self.date_range.to_xml(xml); |
797 | for error in &self.error { |
798 | writeln!(xml, "\t\t<error>{}</error>", escape_xml(error)).ok(); |
799 | } |
800 | @@ -136,7 +136,7 @@ impl ReportMetadata { |
801 | } |
802 | |
803 | impl PolicyPublished { |
804 | - pub(crate) fn as_xml(&self, xml: &mut String) { |
805 | + pub(crate) fn to_xml(&self, xml: &mut String) { |
806 | writeln!(xml, "\t<policy_published>").ok(); |
807 | writeln!(xml, "\t\t<domain>{}</domain>", escape_xml(&self.domain)).ok(); |
808 | if let Some(vp) = &self.version_published { |
809 | @@ -157,7 +157,7 @@ impl PolicyPublished { |
810 | } |
811 | |
812 | impl DateRange { |
813 | - pub(crate) fn as_xml(&self, xml: &mut String) { |
814 | + pub(crate) fn to_xml(&self, xml: &mut String) { |
815 | writeln!(xml, "\t\t<date_range>").ok(); |
816 | writeln!(xml, "\t\t\t<begin>{}</begin>", self.begin).ok(); |
817 | writeln!(xml, "\t\t\t<end>{}</end>", self.end).ok(); |
818 | @@ -166,27 +166,27 @@ impl DateRange { |
819 | } |
820 | |
821 | impl Record { |
822 | - pub(crate) fn as_xml(&self, xml: &mut String) { |
823 | + pub(crate) fn to_xml(&self, xml: &mut String) { |
824 | writeln!(xml, "\t<record>").ok(); |
825 | - self.row.as_xml(xml); |
826 | - self.identifiers.as_xml(xml); |
827 | - self.auth_results.as_xml(xml); |
828 | + self.row.to_xml(xml); |
829 | + self.identifiers.to_xml(xml); |
830 | + self.auth_results.to_xml(xml); |
831 | writeln!(xml, "\t</record>").ok(); |
832 | } |
833 | } |
834 | |
835 | impl Row { |
836 | - pub(crate) fn as_xml(&self, xml: &mut String) { |
837 | + pub(crate) fn to_xml(&self, xml: &mut String) { |
838 | writeln!(xml, "\t\t<row>").ok(); |
839 | writeln!(xml, "\t\t\t<source_ip>{}</source_ip>", self.source_ip).ok(); |
840 | writeln!(xml, "\t\t\t<count>{}</count>", self.count).ok(); |
841 | - self.policy_evaluated.as_xml(xml); |
842 | + self.policy_evaluated.to_xml(xml); |
843 | writeln!(xml, "\t\t</row>").ok(); |
844 | } |
845 | } |
846 | |
847 | impl PolicyEvaluated { |
848 | - pub(crate) fn as_xml(&self, xml: &mut String) { |
849 | + pub(crate) fn to_xml(&self, xml: &mut String) { |
850 | writeln!(xml, "\t\t\t<policy_evaluated>").ok(); |
851 | writeln!( |
852 | xml, |
853 | @@ -197,14 +197,14 @@ impl PolicyEvaluated { |
854 | writeln!(xml, "\t\t\t\t<dkim>{}</dkim>", self.dkim).ok(); |
855 | writeln!(xml, "\t\t\t\t<spf>{}</spf>", self.spf).ok(); |
856 | for reason in &self.reason { |
857 | - reason.as_xml(xml); |
858 | + reason.to_xml(xml); |
859 | } |
860 | writeln!(xml, "\t\t\t</policy_evaluated>").ok(); |
861 | } |
862 | } |
863 | |
864 | impl PolicyOverrideReason { |
865 | - pub(crate) fn as_xml(&self, xml: &mut String) { |
866 | + pub(crate) fn to_xml(&self, xml: &mut String) { |
867 | writeln!(xml, "\t\t\t\t<reason>").ok(); |
868 | writeln!(xml, "\t\t\t\t\t<type>{}</type>", self.type_).ok(); |
869 | if let Some(comment) = &self.comment { |
870 | @@ -215,7 +215,7 @@ impl PolicyOverrideReason { |
871 | } |
872 | |
873 | impl Identifier { |
874 | - pub(crate) fn as_xml(&self, xml: &mut String) { |
875 | + pub(crate) fn to_xml(&self, xml: &mut String) { |
876 | writeln!(xml, "\t\t<identifiers>").ok(); |
877 | if let Some(envelope_to) = &self.envelope_to { |
878 | writeln!( |
879 | @@ -242,20 +242,20 @@ impl Identifier { |
880 | } |
881 | |
882 | impl AuthResult { |
883 | - pub(crate) fn as_xml(&self, xml: &mut String) { |
884 | + pub(crate) fn to_xml(&self, xml: &mut String) { |
885 | writeln!(xml, "\t\t<auth_results>").ok(); |
886 | for dkim in &self.dkim { |
887 | - dkim.as_xml(xml); |
888 | + dkim.to_xml(xml); |
889 | } |
890 | for spf in &self.spf { |
891 | - spf.as_xml(xml); |
892 | + spf.to_xml(xml); |
893 | } |
894 | writeln!(xml, "\t\t</auth_results>").ok(); |
895 | } |
896 | } |
897 | |
898 | impl DKIMAuthResult { |
899 | - pub(crate) fn as_xml(&self, xml: &mut String) { |
900 | + pub(crate) fn to_xml(&self, xml: &mut String) { |
901 | writeln!(xml, "\t\t\t<dkim>").ok(); |
902 | writeln!(xml, "\t\t\t\t<domain>{}</domain>", escape_xml(&self.domain)).ok(); |
903 | writeln!( |
904 | @@ -278,7 +278,7 @@ impl DKIMAuthResult { |
905 | } |
906 | |
907 | impl SPFAuthResult { |
908 | - pub(crate) fn as_xml(&self, xml: &mut String) { |
909 | + pub(crate) fn to_xml(&self, xml: &mut String) { |
910 | writeln!(xml, "\t\t\t<spf>").ok(); |
911 | writeln!(xml, "\t\t\t\t<domain>{}</domain>", escape_xml(&self.domain)).ok(); |
912 | writeln!(xml, "\t\t\t\t<scope>{}</scope>", self.scope).ok(); |
913 | @@ -512,7 +512,7 @@ mod test { |
914 | ); |
915 | |
916 | let message = report |
917 | - .as_rfc5322( |
918 | + .to_rfc5322( |
919 | "initech.net", |
920 | "Initech Industries", |
921 | "noreply-dmarc@initech.net", |
922 | diff --git a/src/report/mod.rs b/src/report/mod.rs |
923 | index 242e8cf..487cf59 100644 |
924 | --- a/src/report/mod.rs |
925 | +++ b/src/report/mod.rs |
926 | @@ -10,6 +10,7 @@ |
927 | |
928 | pub mod arf; |
929 | pub mod dmarc; |
930 | + pub mod tlsrpt; |
931 | |
932 | use std::{ |
933 | borrow::Cow, |
934 | diff --git a/src/report/tlsrpt/generate.rs b/src/report/tlsrpt/generate.rs |
935 | new file mode 100644 |
936 | index 0000000..fc0163b |
937 | --- /dev/null |
938 | +++ b/src/report/tlsrpt/generate.rs |
939 | @@ -0,0 +1,132 @@ |
940 | + use std::io; |
941 | + |
942 | + use flate2::{write::GzEncoder, Compression}; |
943 | + use mail_builder::{ |
944 | + headers::{content_type::ContentType, HeaderType}, |
945 | + mime::{BodyPart, MimePart}, |
946 | + MessageBuilder, |
947 | + }; |
948 | + |
949 | + use super::TlsReport; |
950 | + |
951 | + impl TlsReport { |
952 | + pub fn write_rfc5322<'x>( |
953 | + &self, |
954 | + report_domain: &'x str, |
955 | + receiver_domain: &'x str, |
956 | + submitter: &'x str, |
957 | + from: &'x str, |
958 | + to: &'x str, |
959 | + writer: impl io::Write, |
960 | + ) -> io::Result<()> { |
961 | + // Compress JSON report |
962 | + let json = self.to_json(); |
963 | + let mut e = GzEncoder::new(Vec::with_capacity(json.len()), Compression::default()); |
964 | + io::Write::write_all(&mut e, json.as_bytes())?; |
965 | + let compressed_bytes = e.finish()?; |
966 | + |
967 | + MessageBuilder::new() |
968 | + .header("From", HeaderType::Text(from.into())) |
969 | + .header("To", HeaderType::Text(to.into())) |
970 | + .header("TLS-Report-Domain", HeaderType::Text(report_domain.into())) |
971 | + .header("TLS-Report-Submitter", HeaderType::Text(submitter.into())) |
972 | + .header("Auto-Submitted", HeaderType::Text("auto-generated".into())) |
973 | + .subject(format!( |
974 | + "Report Domain: {} Submitter: {} Report-ID: <{}>", |
975 | + report_domain, submitter, self.report_id |
976 | + )) |
977 | + .body(MimePart::new( |
978 | + ContentType::new("multipart/report").attribute("report-type", "tlsrpt"), |
979 | + BodyPart::Multipart(vec![ |
980 | + MimePart::new( |
981 | + ContentType::new("text/plain"), |
982 | + BodyPart::Text( |
983 | + format!( |
984 | + concat!( |
985 | + "TLS report from {}\r\n\r\n", |
986 | + "Report Domain: {}\r\n", |
987 | + "Submitter: {}\r\n", |
988 | + "Report-ID: {}\r\n", |
989 | + ), |
990 | + receiver_domain, report_domain, submitter, self.report_id |
991 | + ) |
992 | + .into(), |
993 | + ), |
994 | + ), |
995 | + MimePart::new( |
996 | + ContentType::new("application/tlsrpt+gzip"), |
997 | + BodyPart::Binary(compressed_bytes.into()), |
998 | + ) |
999 | + .attachment(format!( |
1000 | + "{}!{}!{}!{}.json.gz", |
1001 | + receiver_domain, |
1002 | + report_domain, |
1003 | + self.date_range.start_datetime.to_timestamp(), |
1004 | + self.date_range.end_datetime.to_timestamp() |
1005 | + )), |
1006 | + ]), |
1007 | + )) |
1008 | + .write_to(writer) |
1009 | + } |
1010 | + |
1011 | + pub fn to_rfc5322<'x>( |
1012 | + &self, |
1013 | + report_domain: &'x str, |
1014 | + receiver_domain: &'x str, |
1015 | + submitter: &'x str, |
1016 | + from: &'x str, |
1017 | + to: &'x str, |
1018 | + ) -> io::Result<String> { |
1019 | + let mut buf = Vec::new(); |
1020 | + self.write_rfc5322( |
1021 | + report_domain, |
1022 | + receiver_domain, |
1023 | + submitter, |
1024 | + from, |
1025 | + to, |
1026 | + &mut buf, |
1027 | + )?; |
1028 | + String::from_utf8(buf).map_err(|err| io::Error::new(io::ErrorKind::Other, err)) |
1029 | + } |
1030 | + |
1031 | + pub fn to_json(&self) -> String { |
1032 | + serde_json::to_string(self).unwrap_or_default() |
1033 | + } |
1034 | + } |
1035 | + |
1036 | + #[cfg(test)] |
1037 | + mod test { |
1038 | + use mail_parser::DateTime; |
1039 | + |
1040 | + use crate::report::tlsrpt::{DateRange, TlsReport}; |
1041 | + |
1042 | + #[test] |
1043 | + fn tlsrpt_generate() { |
1044 | + let report = TlsReport { |
1045 | + organization_name: "Hello World, Inc.".to_string(), |
1046 | + date_range: DateRange { |
1047 | + start_datetime: DateTime::from_timestamp(49823749), |
1048 | + end_datetime: DateTime::from_timestamp(49823899), |
1049 | + }, |
1050 | + contact_info: "tls-report@hello-world.inc".to_string(), |
1051 | + report_id: "abc-123".to_string(), |
1052 | + policies: vec![], |
1053 | + }; |
1054 | + |
1055 | + let message = report |
1056 | + .to_rfc5322( |
1057 | + "hello-world.inc", |
1058 | + "example.org", |
1059 | + "mx.example.org", |
1060 | + "no-reply@example.org", |
1061 | + "tls-reports@hello-world.inc", |
1062 | + ) |
1063 | + .unwrap(); |
1064 | + |
1065 | + println!("{}", message); |
1066 | + |
1067 | + let parsed_report = TlsReport::parse_rfc5322(message.as_bytes()).unwrap(); |
1068 | + |
1069 | + assert_eq!(report, parsed_report); |
1070 | + } |
1071 | + } |
1072 | diff --git a/src/report/tlsrpt/mod.rs b/src/report/tlsrpt/mod.rs |
1073 | new file mode 100644 |
1074 | index 0000000..a301d6e |
1075 | --- /dev/null |
1076 | +++ b/src/report/tlsrpt/mod.rs |
1077 | @@ -0,0 +1,170 @@ |
1078 | + use std::net::IpAddr; |
1079 | + |
1080 | + use mail_parser::DateTime; |
1081 | + use serde::{Deserialize, Deserializer, Serialize, Serializer}; |
1082 | + |
1083 | + pub mod generate; |
1084 | + pub mod parse; |
1085 | + |
1086 | + #[derive(Debug, PartialEq, Eq, Serialize, Deserialize)] |
1087 | + pub struct TlsReport { |
1088 | + #[serde(rename = "organization-name")] |
1089 | + #[serde(default)] |
1090 | + pub organization_name: String, |
1091 | + |
1092 | + #[serde(rename = "date-range")] |
1093 | + pub date_range: DateRange, |
1094 | + |
1095 | + #[serde(rename = "contact-info")] |
1096 | + #[serde(default)] |
1097 | + pub contact_info: String, |
1098 | + |
1099 | + #[serde(rename = "report-id")] |
1100 | + #[serde(default)] |
1101 | + pub report_id: String, |
1102 | + |
1103 | + #[serde(rename = "policies")] |
1104 | + #[serde(default)] |
1105 | + pub policies: Vec<Policy>, |
1106 | + } |
1107 | + |
1108 | + #[derive(Debug, PartialEq, Eq, Serialize, Deserialize)] |
1109 | + pub struct Policy { |
1110 | + #[serde(rename = "policy")] |
1111 | + pub policy: PolicyDetails, |
1112 | + |
1113 | + #[serde(rename = "summary")] |
1114 | + pub summary: Summary, |
1115 | + |
1116 | + #[serde(rename = "failure-details")] |
1117 | + #[serde(default)] |
1118 | + pub failure_details: Vec<FailureDetails>, |
1119 | + } |
1120 | + |
1121 | + #[derive(Debug, PartialEq, Eq, Serialize, Deserialize)] |
1122 | + pub struct PolicyDetails { |
1123 | + #[serde(rename = "policy-type")] |
1124 | + pub policy_type: PolicyType, |
1125 | + |
1126 | + #[serde(rename = "policy-string")] |
1127 | + #[serde(default)] |
1128 | + pub policy_string: Vec<String>, |
1129 | + |
1130 | + #[serde(rename = "policy-domain")] |
1131 | + #[serde(default)] |
1132 | + pub policy_domain: String, |
1133 | + |
1134 | + #[serde(rename = "mx-host")] |
1135 | + #[serde(default)] |
1136 | + pub mx_host: Vec<String>, |
1137 | + } |
1138 | + |
1139 | + #[derive(Debug, PartialEq, Eq, Serialize, Deserialize)] |
1140 | + pub struct Summary { |
1141 | + #[serde(rename = "total-successful-session-count")] |
1142 | + #[serde(default)] |
1143 | + pub total_success: u32, |
1144 | + |
1145 | + #[serde(rename = "total-failure-session-count")] |
1146 | + #[serde(default)] |
1147 | + pub total_failure: u32, |
1148 | + } |
1149 | + |
1150 | + #[derive(Debug, PartialEq, Eq, Serialize, Deserialize)] |
1151 | + pub struct FailureDetails { |
1152 | + #[serde(rename = "result-type")] |
1153 | + pub result_type: ResultType, |
1154 | + |
1155 | + #[serde(rename = "sending-mta-ip")] |
1156 | + pub sending_mta_ip: Option<IpAddr>, |
1157 | + |
1158 | + #[serde(rename = "receiving-mx-hostname")] |
1159 | + #[serde(default)] |
1160 | + pub receiving_mx_hostname: String, |
1161 | + |
1162 | + #[serde(rename = "receiving-mx-helo")] |
1163 | + pub receiving_mx_helo: Option<String>, |
1164 | + |
1165 | + #[serde(rename = "receiving-ip")] |
1166 | + pub receiving_ip: Option<IpAddr>, |
1167 | + |
1168 | + #[serde(rename = "failed-session-count")] |
1169 | + #[serde(default)] |
1170 | + pub failed_session_count: u32, |
1171 | + |
1172 | + #[serde(rename = "additional-information")] |
1173 | + pub additional_information: Option<String>, |
1174 | + |
1175 | + #[serde(rename = "failure-reason-code")] |
1176 | + #[serde(default)] |
1177 | + pub failure_reason_code: String, |
1178 | + } |
1179 | + |
1180 | + #[derive(Debug, PartialEq, Eq, Serialize, Deserialize)] |
1181 | + pub struct DateRange { |
1182 | + #[serde(rename = "start-datetime")] |
1183 | + #[serde(serialize_with = "serialize_datetime")] |
1184 | + #[serde(deserialize_with = "deserialize_datetime")] |
1185 | + pub start_datetime: DateTime, |
1186 | + #[serde(rename = "end-datetime")] |
1187 | + #[serde(serialize_with = "serialize_datetime")] |
1188 | + #[serde(deserialize_with = "deserialize_datetime")] |
1189 | + pub end_datetime: DateTime, |
1190 | + } |
1191 | + |
1192 | + #[derive(Debug, PartialEq, Eq, Serialize, Deserialize)] |
1193 | + pub enum PolicyType { |
1194 | + #[serde(rename = "tlsa")] |
1195 | + Tlsa, |
1196 | + #[serde(rename = "sts")] |
1197 | + Sts, |
1198 | + #[serde(rename = "no-policy-found")] |
1199 | + NoPolicyFound, |
1200 | + #[serde(other)] |
1201 | + Other, |
1202 | + } |
1203 | + |
1204 | + #[derive(Debug, PartialEq, Eq, Serialize, Deserialize)] |
1205 | + pub enum ResultType { |
1206 | + #[serde(rename = "starttls-not-supported")] |
1207 | + StartTlsNotSupported, |
1208 | + #[serde(rename = "certificate-host-mismatch")] |
1209 | + CertificateHostMismatch, |
1210 | + #[serde(rename = "certificate-expired")] |
1211 | + CertificateExpired, |
1212 | + #[serde(rename = "certificate-not-trusted")] |
1213 | + CertificateNotTrusted, |
1214 | + #[serde(rename = "validation-failure")] |
1215 | + ValidationFailure, |
1216 | + #[serde(rename = "tlsa-invalid")] |
1217 | + TlsaInvalid, |
1218 | + #[serde(rename = "dnssec-invalid")] |
1219 | + DnssecInvalid, |
1220 | + #[serde(rename = "dane-required")] |
1221 | + DaneRequired, |
1222 | + #[serde(rename = "sts-policy-fetch-error")] |
1223 | + StsPolicyFetchError, |
1224 | + #[serde(rename = "sts-policy-invalid")] |
1225 | + StsPolicyInvalid, |
1226 | + #[serde(rename = "sts-webpki-invalid")] |
1227 | + StsWebpkiInvalid, |
1228 | + #[serde(other)] |
1229 | + Other, |
1230 | + } |
1231 | + |
1232 | + fn deserialize_datetime<'de, D>(deserializer: D) -> Result<DateTime, D::Error> |
1233 | + where |
1234 | + D: Deserializer<'de>, |
1235 | + { |
1236 | + Ok( |
1237 | + DateTime::parse_rfc3339(Deserialize::deserialize(deserializer)?) |
1238 | + .unwrap_or_else(|| DateTime::from_timestamp(0)), |
1239 | + ) |
1240 | + } |
1241 | + |
1242 | + fn serialize_datetime<S>(datetime: &DateTime, serializer: S) -> Result<S::Ok, S::Error> |
1243 | + where |
1244 | + S: Serializer, |
1245 | + { |
1246 | + serializer.serialize_str(&datetime.to_rfc3339()) |
1247 | + } |
1248 | diff --git a/src/report/tlsrpt/parse.rs b/src/report/tlsrpt/parse.rs |
1249 | new file mode 100644 |
1250 | index 0000000..8ed7cc0 |
1251 | --- /dev/null |
1252 | +++ b/src/report/tlsrpt/parse.rs |
1253 | @@ -0,0 +1,146 @@ |
1254 | + use std::io::{Cursor, Read}; |
1255 | + |
1256 | + use flate2::read::GzDecoder; |
1257 | + use mail_parser::{Message, MimeHeaders, PartType}; |
1258 | + use zip::ZipArchive; |
1259 | + |
1260 | + use crate::report::Error; |
1261 | + |
1262 | + use super::TlsReport; |
1263 | + |
1264 | + impl TlsReport { |
1265 | + pub fn parse_json(report: &[u8]) -> Result<Self, Error> { |
1266 | + serde_json::from_slice(report).map_err(|err| Error::ReportParseError(err.to_string())) |
1267 | + } |
1268 | + |
1269 | + pub fn parse_rfc5322(report: &[u8]) -> Result<Self, Error> { |
1270 | + let message = Message::parse(report).ok_or(Error::MailParseError)?; |
1271 | + let mut error = Error::NoReportsFound; |
1272 | + |
1273 | + for part in &message.parts { |
1274 | + match &part.body { |
1275 | + PartType::Binary(report) | PartType::InlineBinary(report) => { |
1276 | + enum ReportType { |
1277 | + Json, |
1278 | + Gzip, |
1279 | + Zip, |
1280 | + } |
1281 | + |
1282 | + let (_, ext) = part |
1283 | + .attachment_name() |
1284 | + .unwrap_or("file.none") |
1285 | + .rsplit_once('.') |
1286 | + .unwrap_or(("file", "none")); |
1287 | + let subtype = part |
1288 | + .content_type() |
1289 | + .and_then(|ct| ct.subtype()) |
1290 | + .unwrap_or("none"); |
1291 | + let rt = if subtype.eq_ignore_ascii_case("tlsrpt+gzip") { |
1292 | + ReportType::Gzip |
1293 | + } else if subtype.eq_ignore_ascii_case("tlsrpt+zip") { |
1294 | + ReportType::Zip |
1295 | + } else if subtype.eq_ignore_ascii_case("tlsrpt+json") { |
1296 | + ReportType::Json |
1297 | + } else if ext.eq_ignore_ascii_case("gz") { |
1298 | + ReportType::Gzip |
1299 | + } else if ext.eq_ignore_ascii_case("zip") { |
1300 | + ReportType::Zip |
1301 | + } else if ext.eq_ignore_ascii_case("json") { |
1302 | + ReportType::Json |
1303 | + } else { |
1304 | + continue; |
1305 | + }; |
1306 | + |
1307 | + match rt { |
1308 | + ReportType::Gzip => { |
1309 | + let mut file = GzDecoder::new(report.as_ref()); |
1310 | + let mut buf = Vec::new(); |
1311 | + file.read_to_end(&mut buf) |
1312 | + .map_err(|err| Error::UncompressError(err.to_string()))?; |
1313 | + |
1314 | + match Self::parse_json(&buf) { |
1315 | + Ok(report) => return Ok(report), |
1316 | + Err(err) => { |
1317 | + error = err; |
1318 | + } |
1319 | + } |
1320 | + } |
1321 | + ReportType::Zip => { |
1322 | + let mut archive = ZipArchive::new(Cursor::new(report.as_ref())) |
1323 | + .map_err(|err| Error::UncompressError(err.to_string()))?; |
1324 | + for i in 0..archive.len() { |
1325 | + match archive.by_index(i) { |
1326 | + Ok(mut file) => { |
1327 | + let mut buf = |
1328 | + Vec::with_capacity(file.compressed_size() as usize); |
1329 | + file.read_to_end(&mut buf).map_err(|err| { |
1330 | + Error::UncompressError(err.to_string()) |
1331 | + })?; |
1332 | + match Self::parse_json(&buf) { |
1333 | + Ok(report) => return Ok(report), |
1334 | + Err(err) => { |
1335 | + error = err; |
1336 | + } |
1337 | + } |
1338 | + } |
1339 | + Err(err) => { |
1340 | + error = Error::UncompressError(err.to_string()); |
1341 | + } |
1342 | + } |
1343 | + } |
1344 | + } |
1345 | + ReportType::Json => match Self::parse_json(report) { |
1346 | + Ok(report) => return Ok(report), |
1347 | + Err(err) => { |
1348 | + error = err; |
1349 | + } |
1350 | + }, |
1351 | + } |
1352 | + } |
1353 | + _ => (), |
1354 | + } |
1355 | + } |
1356 | + |
1357 | + Err(error) |
1358 | + } |
1359 | + } |
1360 | + |
1361 | + #[cfg(test)] |
1362 | + mod tests { |
1363 | + use std::{fs, path::PathBuf}; |
1364 | + |
1365 | + use crate::report::tlsrpt::TlsReport; |
1366 | + |
1367 | + #[test] |
1368 | + fn tlsrpt_parse() { |
1369 | + // Add dns entries |
1370 | + let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR")); |
1371 | + path.push("resources"); |
1372 | + path.push("tlsrpt"); |
1373 | + |
1374 | + for file in fs::read_dir(&path).unwrap() { |
1375 | + let file = file.as_ref().unwrap().path(); |
1376 | + if !file.extension().map_or(false, |e| e == "json") { |
1377 | + continue; |
1378 | + } |
1379 | + let rpt = TlsReport::parse_json(&fs::read(&file).unwrap()) |
1380 | + .unwrap_or_else(|err| panic!("Failed to parse {}: {:?}", file.display(), err)); |
1381 | + let rpt_check: TlsReport = |
1382 | + serde_json::from_str(&serde_json::to_string(&rpt).unwrap()).unwrap(); |
1383 | + assert_eq!(rpt, rpt_check); |
1384 | + } |
1385 | + |
1386 | + for file in fs::read_dir(&path).unwrap() { |
1387 | + let mut file = file.as_ref().unwrap().path(); |
1388 | + if !file.extension().map_or(false, |e| e == "eml") { |
1389 | + continue; |
1390 | + } |
1391 | + let rpt = TlsReport::parse_rfc5322(&fs::read(&file).unwrap()) |
1392 | + .unwrap_or_else(|err| panic!("Failed to parse {}: {:?}", file.display(), err)); |
1393 | + file.set_extension("json"); |
1394 | + let rpt_check = TlsReport::parse_json(&fs::read(&file).unwrap()) |
1395 | + .unwrap_or_else(|err| panic!("Failed to parse {}: {:?}", file.display(), err)); |
1396 | + assert_eq!(rpt, rpt_check); |
1397 | + } |
1398 | + } |
1399 | + } |