Compare commits
1242 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
918501ccb5 | ||
|
|
036c372ea3 | ||
|
|
a969d83137 | ||
|
|
e299f7d161 | ||
|
|
4c04418dae | ||
|
|
2ca9373ed0 | ||
|
|
961ef6d804 | ||
|
|
573ba1e3e9 | ||
|
|
1d8af3ccff | ||
|
|
8426daa26b | ||
|
|
d1531b86f2 | ||
|
|
8bb046798c | ||
|
|
d64e12548a | ||
|
|
380479cbf1 | ||
|
|
ace21c8084 | ||
|
|
1a1aef21ad | ||
|
|
532dbbdb7e | ||
|
|
45738ae688 | ||
|
|
9d77bd64bc | ||
|
|
140290221d | ||
|
|
187d61b770 | ||
|
|
0443b7365e | ||
|
|
d7b887a835 | ||
|
|
a805733221 | ||
|
|
9552c3ac92 | ||
|
|
5273948be0 | ||
|
|
b51756b8bd | ||
|
|
7fa7c24cb8 | ||
|
|
972237ae7e | ||
|
|
6e5333a342 | ||
|
|
47b074c80b | ||
|
|
a1cfeb3081 | ||
|
|
c7c451b1b1 | ||
|
|
669deb9755 | ||
|
|
446c018920 | ||
|
|
38c6f86973 | ||
|
|
62ccc11925 | ||
|
|
c32ca3cae3 | ||
|
|
010f1f84a7 | ||
|
|
7da57c6382 | ||
|
|
d08e29a306 | ||
|
|
e1e53ad4cb | ||
|
|
4670e9687d | ||
|
|
7f8a2c08cd | ||
|
|
e9c05dd0bf | ||
|
|
9348a474dd | ||
|
|
e0decaba8c | ||
|
|
26a651cded | ||
|
|
bcfcd93fc6 | ||
|
|
54d5ed3543 | ||
|
|
1efbc87e0e | ||
|
|
e78e7f64af | ||
|
|
ad9de65b99 | ||
|
|
b9df12700b | ||
|
|
20843b920f | ||
|
|
e5ae89fedf | ||
|
|
f148cff11c | ||
|
|
4583769e04 | ||
|
|
0ecb80b27c | ||
|
|
b8e62e6d3b | ||
|
|
c67953a2c5 | ||
|
|
27dff4298c | ||
|
|
f2133aacd4 | ||
|
|
31917e58a9 | ||
|
|
bffb98d217 | ||
|
|
1f93b3a7ea | ||
|
|
88debb9729 | ||
|
|
a8a5564780 | ||
|
|
1e26f95b7b | ||
|
|
82b48e4d01 | ||
|
|
617b7c5b4a | ||
|
|
989bfd8f07 | ||
|
|
908cc2918c | ||
|
|
bd5774d71d | ||
|
|
8e9112bad3 | ||
|
|
40e041a8af | ||
|
|
7ba433cddb | ||
|
|
6d467c93f9 | ||
|
|
be38e83761 | ||
|
|
ef4e1ac8dc | ||
|
|
39e4c22ecc | ||
|
|
88ff3a2c23 | ||
|
|
d8aee569f7 | ||
|
|
debc28cc6e | ||
|
|
52ccf0536c | ||
|
|
976a3274e6 | ||
|
|
bb722e651a | ||
|
|
ab280d7a34 | ||
|
|
92b12eaacf | ||
|
|
8444053476 | ||
|
|
1ef3057110 | ||
|
|
fdb4e4cb36 | ||
|
|
d80ce744da | ||
|
|
f12828485b | ||
|
|
0a6cfb602c | ||
|
|
cf46558fa3 | ||
|
|
f1a526c247 | ||
|
|
7344ea9dda | ||
|
|
7633a30066 | ||
|
|
266d57eb8c | ||
|
|
7b7d20b1a4 | ||
|
|
b530d624e6 | ||
|
|
5973ca8205 | ||
|
|
e967778f25 | ||
|
|
630863df5c | ||
|
|
3a3b02687e | ||
|
|
79cd7d3c3d | ||
|
|
3ca3d64775 | ||
|
|
21180f4bb8 | ||
|
|
fc36a78a4d | ||
|
|
f3a5f10d67 | ||
|
|
4787da9ea1 | ||
|
|
06d8578c47 | ||
|
|
ef621d68c7 | ||
|
|
1c3bcc05c7 | ||
|
|
554888b3dd | ||
|
|
17d9599c54 | ||
|
|
fb0adf0627 | ||
|
|
ef820c5d68 | ||
|
|
cc1b6ae389 | ||
|
|
0fa6bebf5a | ||
|
|
06ce080171 | ||
|
|
11e0461b9d | ||
|
|
efe4893a7f | ||
|
|
9b32d9459f | ||
|
|
aa357dc50f | ||
|
|
e9f0cdef1f | ||
|
|
410663dbca | ||
|
|
b110d06adb | ||
|
|
61402d6284 | ||
|
|
e6e282a10c | ||
|
|
f618f69c6c | ||
|
|
13ddc26d70 | ||
|
|
899dc060b2 | ||
|
|
1145b0f63b | ||
|
|
91191e30f3 | ||
|
|
42be53349f | ||
|
|
20f451192f | ||
|
|
6e96b88a27 | ||
|
|
201280d700 | ||
|
|
f8ee9bd166 | ||
|
|
ed92e0f7eb | ||
|
|
c150c7671f | ||
|
|
6bd9aab925 | ||
|
|
f98dc6d452 | ||
|
|
7aa2e14cbb | ||
|
|
0b46c1807c | ||
|
|
11a9e959a0 | ||
|
|
b6a8739b4f | ||
|
|
a53d35a90c | ||
|
|
662b7b694b | ||
|
|
6a06d01b44 | ||
|
|
422f7a074a | ||
|
|
d6128eae9f | ||
|
|
36eedcb446 | ||
|
|
acef7bdd6e | ||
|
|
8936193280 | ||
|
|
fd5b792c4a | ||
|
|
041296b1f1 | ||
|
|
2e49db3c13 | ||
|
|
871d678d16 | ||
|
|
7a8781ef5c | ||
|
|
9084d32040 | ||
|
|
29fe768772 | ||
|
|
0cad27c686 | ||
|
|
1f9a5ffe58 | ||
|
|
9ffc63f895 | ||
|
|
26f62082c3 | ||
|
|
d7dbf68e7d | ||
|
|
465829c18b | ||
|
|
09238d5ca8 | ||
|
|
d907dd8cff | ||
|
|
18f7508a1f | ||
|
|
ed593a0b49 | ||
|
|
d27e195645 | ||
|
|
ede8c0b6a2 | ||
|
|
91daa31172 | ||
|
|
cdde9734ec | ||
|
|
62ad46b8ba | ||
|
|
fc5e0fe4d5 | ||
|
|
633435547a | ||
|
|
fd0572cdd0 | ||
|
|
e550f42a22 | ||
|
|
2cde116a93 | ||
|
|
a915385246 | ||
|
|
1e565d9eb2 | ||
|
|
3a1360a47a | ||
|
|
5f3977d686 | ||
|
|
65d04bcb78 | ||
|
|
f3206dcdab | ||
|
|
995bdbcd97 | ||
|
|
77132b3fc5 | ||
|
|
a1f141d84c | ||
|
|
efe74091f3 | ||
|
|
d2145b71ba | ||
|
|
d512b9f60e | ||
|
|
93278bc023 | ||
|
|
9e9065837e | ||
|
|
1c1ce7fea3 | ||
|
|
fc49f7f56c | ||
|
|
b8088505b1 | ||
|
|
7d2b431e5f | ||
|
|
cb3f82e847 | ||
|
|
100f12ed35 | ||
|
|
37a6155262 | ||
|
|
25086763a9 | ||
|
|
b89c38c22a | ||
|
|
c71bc19cea | ||
|
|
3bf0bea710 | ||
|
|
95954c5d87 | ||
|
|
6120b8683d | ||
|
|
21d6f92fd4 | ||
|
|
a164fb8e04 | ||
|
|
762d92f6d2 | ||
|
|
1655b84cc2 | ||
|
|
0eaba079b9 | ||
|
|
1de4a94c37 | ||
|
|
722ff79e23 | ||
|
|
68145b8b06 | ||
|
|
732547e622 | ||
|
|
aaf269b11b | ||
|
|
2bee4fc591 | ||
|
|
4d0974948d | ||
|
|
8b5834b00d | ||
|
|
31db7d2301 | ||
|
|
26027ef6b3 | ||
|
|
4ff44dcb0f | ||
|
|
557e2e0554 | ||
|
|
6c84cfb7c4 | ||
|
|
a4b0aabdfb | ||
|
|
51760181b0 | ||
|
|
89872d78ac | ||
|
|
477a45d19b | ||
|
|
e5e7a6fe75 | ||
|
|
5a659ea578 | ||
|
|
d2d62488f7 | ||
|
|
bf69ea8ccc | ||
|
|
af1e299dd4 | ||
|
|
d426098b7e | ||
|
|
a06fdc586f | ||
|
|
062d6ea821 | ||
|
|
b15e8d0aad | ||
|
|
bf102f78aa | ||
|
|
f6c0a4ecbc | ||
|
|
044038a381 | ||
|
|
cd475255c5 | ||
|
|
2b35b785c6 | ||
|
|
634631e326 | ||
|
|
a7280988eb | ||
|
|
02e856a9bf | ||
|
|
70a9d11adb | ||
|
|
9c86e2df49 | ||
|
|
b75259c58c | ||
|
|
16318b9152 | ||
|
|
22a6d21362 | ||
|
|
b10cc06441 | ||
|
|
a54cc3e6dd | ||
|
|
8b8c8c15fe | ||
|
|
bb838bb11a | ||
|
|
375aaf025d | ||
|
|
f82445fa06 | ||
|
|
70ff13bfae | ||
|
|
fcc64ed85a | ||
|
|
ea777d9d18 | ||
|
|
4217a076de | ||
|
|
0a0e4beb27 | ||
|
|
304ebaaa42 | ||
|
|
bcf242b0ab | ||
|
|
1380eed2b8 | ||
|
|
69c2c6bdb6 | ||
|
|
7c349fe97e | ||
|
|
49f9d75413 | ||
|
|
b86365225e | ||
|
|
dde79c9e26 | ||
|
|
79d99d0b2e | ||
|
|
126bab1c3b | ||
|
|
4a607420a7 | ||
|
|
be4c236d69 | ||
|
|
4376b12c93 | ||
|
|
12e591255c | ||
|
|
6ccc827e4c | ||
|
|
132bcde655 | ||
|
|
6540577ad5 | ||
|
|
26f43b3315 | ||
|
|
1e0fa9472c | ||
|
|
475b89adb0 | ||
|
|
de3002db8b | ||
|
|
d2da6f30af | ||
|
|
10e15d963b | ||
|
|
84a7386726 | ||
|
|
9d739ccd65 | ||
|
|
4f53894ce1 | ||
|
|
1d1f9e84b0 | ||
|
|
11c151e818 | ||
|
|
d1da40bab7 | ||
|
|
046a7885ea | ||
|
|
bddc2adb9c | ||
|
|
4e8c74599b | ||
|
|
b15425f50e | ||
|
|
315b99f95a | ||
|
|
f070ee95c3 | ||
|
|
eda5726652 | ||
|
|
5ab649cf8c | ||
|
|
ae8c587aed | ||
|
|
3850c7bdc4 | ||
|
|
9381381f40 | ||
|
|
a496b4e42a | ||
|
|
9671a49166 | ||
|
|
6354c9bce7 | ||
|
|
9d05fe776a | ||
|
|
0e6e6c31c0 | ||
|
|
31821bcfd9 | ||
|
|
9bf4e75e0e | ||
|
|
ed39c7d89e | ||
|
|
1b443b8843 | ||
|
|
c5a624274c | ||
|
|
4018e828e9 | ||
|
|
2d85e095fe | ||
|
|
abf9695228 | ||
|
|
665720a017 | ||
|
|
6b2131f0e8 | ||
|
|
6702181400 | ||
|
|
b9c3404989 | ||
|
|
b6054aafce | ||
|
|
abf07125c4 | ||
|
|
bfd1aa8172 | ||
|
|
a1c912fc7d | ||
|
|
6d0717d6c0 | ||
|
|
8943430ff3 | ||
|
|
03811988d3 | ||
|
|
996deb042c | ||
|
|
fe5559e44e | ||
|
|
af2afddf96 | ||
|
|
1b0f95a0ff | ||
|
|
167de27d34 | ||
|
|
e46a768b82 | ||
|
|
75da9f6a30 | ||
|
|
64f8eef27b | ||
|
|
adee1288c7 | ||
|
|
514ca35117 | ||
|
|
23df163759 | ||
|
|
763476cdd3 | ||
|
|
64b88991d1 | ||
|
|
d2cc93f23f | ||
|
|
e2ac067bf3 | ||
|
|
d03d2b5f44 | ||
|
|
cf682337e9 | ||
|
|
6a1a88cfdf | ||
|
|
5ad7e821b6 | ||
|
|
657f34dc2a | ||
|
|
b0a6a5bbff | ||
|
|
03ff412c70 | ||
|
|
bd35d31573 | ||
|
|
90a53f2217 | ||
|
|
4faf8ad651 | ||
|
|
8da627aa61 | ||
|
|
f5dcde183a | ||
|
|
bc5cca69ba | ||
|
|
a37f7aef5e | ||
|
|
3bce7cf300 | ||
|
|
c777d6759d | ||
|
|
d76b0adc67 | ||
|
|
754e1d6bc5 | ||
|
|
445e3cdf9d | ||
|
|
db1d3443fd | ||
|
|
59a39b1509 | ||
|
|
1f865ae566 | ||
|
|
88c8af8334 | ||
|
|
30539dc111 | ||
|
|
e79dbd702e | ||
|
|
5be36e431c | ||
|
|
8441f8badd | ||
|
|
e721f5701e | ||
|
|
315d400677 | ||
|
|
5e0ac908c6 | ||
|
|
12e1382f13 | ||
|
|
672a0f255d | ||
|
|
21c7e9d4af | ||
|
|
7a44164ce5 | ||
|
|
68bc430476 | ||
|
|
2b55c9ec13 | ||
|
|
c5248a9375 | ||
|
|
3fb3d6f920 | ||
|
|
632b501f76 | ||
|
|
dcdc210ab6 | ||
|
|
8259f6866f | ||
|
|
4f22ab4879 | ||
|
|
ce2943e0cc | ||
|
|
c0f82fa363 | ||
|
|
293dfc46b7 | ||
|
|
fcf5a3be31 | ||
|
|
c0e975b1e0 | ||
|
|
d50259cdc3 | ||
|
|
8a5242be5a | ||
|
|
ddb02cd031 | ||
|
|
273e9906a3 | ||
|
|
a87b11f726 | ||
|
|
de8f18dcd3 | ||
|
|
061c5a084e | ||
|
|
c921814e18 | ||
|
|
ca2c18b566 | ||
|
|
25b63e484c | ||
|
|
89f43bbe5d | ||
|
|
b54bcc9138 | ||
|
|
de3ac64583 | ||
|
|
1b69aa1fef | ||
|
|
499516585b | ||
|
|
76a7a47c53 | ||
|
|
2748022824 | ||
|
|
51eea6c08d | ||
|
|
16fb7bca2c | ||
|
|
2bcd601d33 | ||
|
|
405c842737 | ||
|
|
404ed401f9 | ||
|
|
b42a850749 | ||
|
|
25b96aa4c0 | ||
|
|
d2378d4690 | ||
|
|
bdf61f437d | ||
|
|
ca18a89718 | ||
|
|
36a1695281 | ||
|
|
d335d594f5 | ||
|
|
2ea89c60b9 | ||
|
|
69e29b2dfd | ||
|
|
6d689ca8f5 | ||
|
|
377df6a719 | ||
|
|
a10e6592fe | ||
|
|
4c5a266f19 | ||
|
|
6d5f8a9ec3 | ||
|
|
e841a49ca7 | ||
|
|
3d0f7c8c83 | ||
|
|
b8a148f7d4 | ||
|
|
89816bbc6e | ||
|
|
4dfde372c1 | ||
|
|
242522f7ee | ||
|
|
dc7533793a | ||
|
|
d722bbf8f4 | ||
|
|
0a1e57fd1b | ||
|
|
d3f1d761f1 | ||
|
|
d2d2000040 | ||
|
|
0758bc179c | ||
|
|
f694b6c489 | ||
|
|
8337c19399 | ||
|
|
5e82b29afd | ||
|
|
72e1448f32 | ||
|
|
ca36db5f24 | ||
|
|
837ba7ef4d | ||
|
|
00cb5bc4e8 | ||
|
|
ca15ff51bd | ||
|
|
d330b25205 | ||
|
|
4bc7b0b62c | ||
|
|
7e9bbfc805 | ||
|
|
4ad693301e | ||
|
|
0aa7d84d0d | ||
|
|
595ff0abb7 | ||
|
|
9dab931f44 | ||
|
|
bbc379aaca | ||
|
|
bd8f13796e | ||
|
|
df0e5467ab | ||
|
|
3615ad3799 | ||
|
|
775a6f2181 | ||
|
|
0d680edd31 | ||
|
|
50b7b5f28e | ||
|
|
190a6a004b | ||
|
|
184425f567 | ||
|
|
1b61156d50 | ||
|
|
55196cb389 | ||
|
|
77331b55c5 | ||
|
|
609fbdce6f | ||
|
|
e51f2b0127 | ||
|
|
36c592cc5a | ||
|
|
394dddd2df | ||
|
|
85e7fd4ce6 | ||
|
|
de05be90df | ||
|
|
9522c9b6e4 | ||
|
|
eba722cddc | ||
|
|
5f6b945839 | ||
|
|
a4acd5f232 | ||
|
|
291d389f69 | ||
|
|
755ee3ded7 | ||
|
|
bafa4861b1 | ||
|
|
bc684c8913 | ||
|
|
c853c47087 | ||
|
|
a00cee8ba4 | ||
|
|
76614bdc94 | ||
|
|
0e2636225e | ||
|
|
be8395dbe3 | ||
|
|
478452de20 | ||
|
|
b43a622f9e | ||
|
|
8feffcb1ac | ||
|
|
0f8d5477a6 | ||
|
|
7d7a197ff3 | ||
|
|
4d40f59491 | ||
|
|
72b0a1b053 | ||
|
|
08457b66fd | ||
|
|
83e229aeb1 | ||
|
|
49d09a51ba | ||
|
|
3f1e25e315 | ||
|
|
ddb007af13 | ||
|
|
529fe27a97 | ||
|
|
e5720422f6 | ||
|
|
4c3fb65af1 | ||
|
|
dbfed2e309 | ||
|
|
f0612203f0 | ||
|
|
226afee12d | ||
|
|
809d533ce0 | ||
|
|
87092cf4ca | ||
|
|
06e42791c4 | ||
|
|
f18322c16c | ||
|
|
07f8a30f08 | ||
|
|
de02edc0a9 | ||
|
|
a36dc21c07 | ||
|
|
3312387852 | ||
|
|
bd7819881d | ||
|
|
dedbd518e8 | ||
|
|
c1681dc48b | ||
|
|
82b1a7e292 | ||
|
|
93189945b3 | ||
|
|
1f557f9e41 | ||
|
|
9d920e0bd8 | ||
|
|
e1059b234e | ||
|
|
73b8866b29 | ||
|
|
20f9890008 | ||
|
|
d4905968f6 | ||
|
|
382e33f687 | ||
|
|
fd0896ac40 | ||
|
|
bd82966d1c | ||
|
|
c9355d7c94 | ||
|
|
e583728d4b | ||
|
|
4fca674064 | ||
|
|
8431207920 | ||
|
|
8bfaa3951b | ||
|
|
585f87e46e | ||
|
|
a89d41acd0 | ||
|
|
f0169a451a | ||
|
|
3a6a84dbec | ||
|
|
b01b8d9374 | ||
|
|
e940268e16 | ||
|
|
ebda496178 | ||
|
|
74de4fecf9 | ||
|
|
3a11ed3ac3 | ||
|
|
6a9e3f0f5d | ||
|
|
d0bb858e74 | ||
|
|
0ae15ed90c | ||
|
|
7cfa8c20bb | ||
|
|
97db183031 | ||
|
|
95477bb818 | ||
|
|
c50bdf8d7e | ||
|
|
4edd86ce73 | ||
|
|
ac25262385 | ||
|
|
52eaa32c3b | ||
|
|
c1a78264d2 | ||
|
|
335ee39d6b | ||
|
|
88304bbf67 | ||
|
|
e30ccf2e44 | ||
|
|
abcb739e67 | ||
|
|
3eccfb1bc1 | ||
|
|
a997496e75 | ||
|
|
8ca62a9860 | ||
|
|
1271b26fd5 | ||
|
|
de465aa84d | ||
|
|
20ac81343b | ||
|
|
c67c991ee2 | ||
|
|
d82f211946 | ||
|
|
097a847f49 | ||
|
|
4c57181e21 | ||
|
|
4673ebb1c4 | ||
|
|
793cf3588d | ||
|
|
d3f25c9447 | ||
|
|
54cdd2cf51 | ||
|
|
747d22358f | ||
|
|
180d18ada3 | ||
|
|
b81aba4a58 | ||
|
|
3721b25a04 | ||
|
|
757a28b56d | ||
|
|
9e50a1db57 | ||
|
|
193e0fd98c | ||
|
|
eefc74c576 | ||
|
|
a95cfa4efd | ||
|
|
e054bc7cbe | ||
|
|
d12c1baa75 | ||
|
|
d4ec6dee65 | ||
|
|
13a2624850 | ||
|
|
9bc23a20fa | ||
|
|
2b789c869a | ||
|
|
28608e1573 | ||
|
|
e9a507bf99 | ||
|
|
4685d0a750 | ||
|
|
6fd80ebdee | ||
|
|
7d7a3e0919 | ||
|
|
ff5bb1e03e | ||
|
|
8a45628f25 | ||
|
|
189acd8779 | ||
|
|
c991feb860 | ||
|
|
3fef3b58a8 | ||
|
|
bf6bea1456 | ||
|
|
1f196486d9 | ||
|
|
a1303a2168 | ||
|
|
8a41505c4e | ||
|
|
61e282d6ea | ||
|
|
6ce06f7f10 | ||
|
|
6efec4e633 | ||
|
|
537651836b | ||
|
|
623eeddc8e | ||
|
|
cb63c55b94 | ||
|
|
7f99759d30 | ||
|
|
af9da9bc6b | ||
|
|
ef0323ceb4 | ||
|
|
c98416950b | ||
|
|
8471d20d4b | ||
|
|
e340232bd4 | ||
|
|
a58975fc1c | ||
|
|
2d5f613870 | ||
|
|
09b4607ba5 | ||
|
|
ff6b8b2daf | ||
|
|
4b56c516b1 | ||
|
|
2a25ce5b62 | ||
|
|
5a6b51c59f | ||
|
|
766786344d | ||
|
|
8b3fc00f13 | ||
|
|
0036bbf14e | ||
|
|
ca0e992c90 | ||
|
|
615c10c0c6 | ||
|
|
73675b17b9 | ||
|
|
43eba0738b | ||
|
|
364f292a15 | ||
|
|
38c8d14c3e | ||
|
|
f5b64151eb | ||
|
|
90e92809e3 | ||
|
|
c455143c21 | ||
|
|
5294d7140c | ||
|
|
2351590c4d | ||
|
|
11cf6f8ba6 | ||
|
|
6a27f41de1 | ||
|
|
15b444141f | ||
|
|
ffdeb8cfd3 | ||
|
|
1be7e3ff4c | ||
|
|
1c9a6c4e85 | ||
|
|
32cfede9ac | ||
|
|
4722aadfba | ||
|
|
34e428f1cf | ||
|
|
20ff722f30 | ||
|
|
0d609c4ff2 | ||
|
|
1ad994c717 | ||
|
|
ecc9fd434c | ||
|
|
714697720b | ||
|
|
cf62534c5b | ||
|
|
3b366a24e4 | ||
|
|
0638650550 | ||
|
|
7f85b1b346 | ||
|
|
a2998f3968 | ||
|
|
6b3a51a3f0 | ||
|
|
76749f0b5f | ||
|
|
af3eb1bd40 | ||
|
|
5f49998e05 | ||
|
|
5312f4082a | ||
|
|
cced69e31d | ||
|
|
873985251c | ||
|
|
ea6ed8f19c | ||
|
|
0feaec93dd | ||
|
|
c9bb7a7af0 | ||
|
|
8612a5d1b3 | ||
|
|
775f80c02c | ||
|
|
536b94ff90 | ||
|
|
0ee60f46ac | ||
|
|
2696162a49 | ||
|
|
9d680a20d6 | ||
|
|
ce7655ec8f | ||
|
|
60cefa8066 | ||
|
|
04d1f5e7c9 | ||
|
|
d4e8974853 | ||
|
|
06371dfe9b | ||
|
|
af36df2f48 | ||
|
|
4fce44bfa4 | ||
|
|
18e714aedf | ||
|
|
38b7299db7 | ||
|
|
1389e4df8d | ||
|
|
55a7e9c69b | ||
|
|
065aba7f6f | ||
|
|
d1c483d337 | ||
|
|
2119382054 | ||
|
|
84dbf4d475 | ||
|
|
086a9b1fbf | ||
|
|
a7814d1bf7 | ||
|
|
2f44d8fe3d | ||
|
|
e3bbb4e008 | ||
|
|
bd2b08027f | ||
|
|
5ad5107aec | ||
|
|
76a8f61d40 | ||
|
|
63c5656354 | ||
|
|
be5d01ff57 | ||
|
|
11c76a42d6 | ||
|
|
86527a5555 | ||
|
|
a732f7123a | ||
|
|
0dfe978b3d | ||
|
|
29d3714721 | ||
|
|
042e2cfafb | ||
|
|
342acd94b2 | ||
|
|
70a6cdc581 | ||
|
|
47f32fb189 | ||
|
|
cc6f4bb680 | ||
|
|
4813f7bc87 | ||
|
|
e3f4291ff1 | ||
|
|
d8bf5f950a | ||
|
|
445435cf99 | ||
|
|
2b01e97c8e | ||
|
|
182cc251fc | ||
|
|
8fc856d0e3 | ||
|
|
75dadb31bf | ||
|
|
b99187b423 | ||
|
|
ad6860817f | ||
|
|
dc5dd1dc54 | ||
|
|
47598d9de9 | ||
|
|
7681f30295 | ||
|
|
d29ae43dd7 | ||
|
|
de8b4f936c | ||
|
|
39d71968f1 | ||
|
|
97b581f404 | ||
|
|
89ce95e2cd | ||
|
|
a16b5c5627 | ||
|
|
0e5247d79f | ||
|
|
ecdff4d339 | ||
|
|
559b5dff07 | ||
|
|
6ac5305db5 | ||
|
|
264ed68b14 | ||
|
|
3a5d97e8f7 | ||
|
|
304074ade5 | ||
|
|
6f7a333623 | ||
|
|
884b3759e7 | ||
|
|
81ad0b85c2 | ||
|
|
41823cbb00 | ||
|
|
baa544217f | ||
|
|
2e2e47b202 | ||
|
|
8f702b9bc2 | ||
|
|
c2b5ec9fbd | ||
|
|
b3d2efe0b0 | ||
|
|
a27b5a4291 | ||
|
|
9bed81ad07 | ||
|
|
2ae500ba9c | ||
|
|
f722907a9a | ||
|
|
2eceac3100 | ||
|
|
fdf8ea292f | ||
|
|
22230af4d2 | ||
|
|
71362b8d69 | ||
|
|
53510c1f78 | ||
|
|
d68294c58a | ||
|
|
93ecf4a262 | ||
|
|
e12c5637e1 | ||
|
|
f5d645cebd | ||
|
|
b70d47e1b2 | ||
|
|
603dd65da5 | ||
|
|
e588845f23 | ||
|
|
6d047befcb | ||
|
|
a06db17a52 | ||
|
|
007225302e | ||
|
|
34594ca514 | ||
|
|
b56c958146 | ||
|
|
444deeab7c | ||
|
|
bca34f3891 | ||
|
|
b61717d184 | ||
|
|
7b7ac245b0 | ||
|
|
8ed6c7840d | ||
|
|
21b6ccb427 | ||
|
|
9329feec1f | ||
|
|
2aa4b6aac4 | ||
|
|
59acc5238d | ||
|
|
9b5e3db91f | ||
|
|
af79a1f286 | ||
|
|
74dacf48fb | ||
|
|
bd76344baa | ||
|
|
84522a2fa0 | ||
|
|
9a3971ca50 | ||
|
|
43791dd64a | ||
|
|
d0d386e7ad | ||
|
|
975365413d | ||
|
|
c796b0be6c | ||
|
|
6d399ef931 | ||
|
|
528cfb2822 | ||
|
|
5c0de87d4e | ||
|
|
61f33f0017 | ||
|
|
eacd30688e | ||
|
|
3f71518498 | ||
|
|
80ee60f69e | ||
|
|
e8feca3117 | ||
|
|
1ba1bc9543 | ||
|
|
cc315d00e1 | ||
|
|
e85e99a416 | ||
|
|
f883996126 | ||
|
|
e929476f32 | ||
|
|
6c49d76688 | ||
|
|
59168ca8f7 | ||
|
|
05f225721d | ||
|
|
0e39830783 | ||
|
|
241ea4d1bd | ||
|
|
bb1b62b1fe | ||
|
|
a3a6e97876 | ||
|
|
2a507a764e | ||
|
|
c3652e06e0 | ||
|
|
7619bde93f | ||
|
|
92a6341e5d | ||
|
|
01ee77eb6b | ||
|
|
9af06f5de3 | ||
|
|
105a956f8c | ||
|
|
df1511d8db | ||
|
|
51a0e891e7 | ||
|
|
d224964449 | ||
|
|
fcf39d4810 | ||
|
|
475c5f5b3c | ||
|
|
c5f2e463c1 | ||
|
|
1792e868e2 | ||
|
|
419e8a68b2 | ||
|
|
b64fa96d88 | ||
|
|
6a6370dbda | ||
|
|
2ca7bb200a | ||
|
|
048fa28160 | ||
|
|
a38c66d0b5 | ||
|
|
884786116e | ||
|
|
c8e1424c3f | ||
|
|
ead7f9ad09 | ||
|
|
3c0e550a3a | ||
|
|
2345908bff | ||
|
|
4d94f7bba0 | ||
|
|
fe73f21df4 | ||
|
|
cbe554ae5f | ||
|
|
08df8d3344 | ||
|
|
a9b92e31e4 | ||
|
|
0d103a3d54 | ||
|
|
f0928b1063 | ||
|
|
028be52653 | ||
|
|
76f3007740 | ||
|
|
f706041701 | ||
|
|
b196b5fca0 | ||
|
|
8548a3749e | ||
|
|
46f5967212 | ||
|
|
7d9cf723c1 | ||
|
|
f59af2334b | ||
|
|
bdf9f62377 | ||
|
|
e7e1f238ab | ||
|
|
78de18eb64 | ||
|
|
2b44bd5111 | ||
|
|
eaaebb54be | ||
|
|
1343b25963 | ||
|
|
a227b73cfb | ||
|
|
f19ea5b950 | ||
|
|
3d59be3ec3 | ||
|
|
b25cf80a75 | ||
|
|
e264c5744e | ||
|
|
7634b9c9d1 | ||
|
|
5934b0abae | ||
|
|
f0ef25bcd7 | ||
|
|
9945c3f384 | ||
|
|
6367c069b1 | ||
|
|
4a43243835 | ||
|
|
8e81d61207 | ||
|
|
8ea02668e7 | ||
|
|
e805b9dbeb | ||
|
|
f982d870fe | ||
|
|
be27080e4d | ||
|
|
84f7930e39 | ||
|
|
6072d9df0b | ||
|
|
89248b8124 | ||
|
|
6eaccdc2fc | ||
|
|
68e2437364 | ||
|
|
dbb3d7de4d | ||
|
|
6740ae1e5c | ||
|
|
7b017612f8 | ||
|
|
06425b6302 | ||
|
|
f5956ccd5b | ||
|
|
85489d9ea9 | ||
|
|
92056738e3 | ||
|
|
aa88d3eeb4 | ||
|
|
7d2301c5bd | ||
|
|
7fb81abef0 | ||
|
|
1bd6d46b61 | ||
|
|
9c4dca0545 | ||
|
|
017f7bc432 | ||
|
|
6a4445c799 | ||
|
|
13e2b50671 | ||
|
|
0ddc904c9d | ||
|
|
bbc64ca044 | ||
|
|
ba8c6fd30c | ||
|
|
0d7e14a784 | ||
|
|
c581efbae6 | ||
|
|
4cf5dfc4e4 | ||
|
|
76993d5e8b | ||
|
|
6467ebe73d | ||
|
|
2e1dcbf438 | ||
|
|
d4936ea5a8 | ||
|
|
fe7c732084 | ||
|
|
78e796a97c | ||
|
|
0e398f2c8d | ||
|
|
0c47ac178d | ||
|
|
a543cb4e44 | ||
|
|
490b6f4700 | ||
|
|
9f7b7fcc93 | ||
|
|
e0c532c7eb | ||
|
|
01b72119fe | ||
|
|
003ac9b0f1 | ||
|
|
ee00861146 | ||
|
|
5eb533e2a5 | ||
|
|
c6728186cc | ||
|
|
c75244b476 | ||
|
|
2e13b83945 | ||
|
|
d4fda8c93c | ||
|
|
7576508f2c | ||
|
|
825fba8951 | ||
|
|
6cb78f65cf | ||
|
|
e87da5dd0f | ||
|
|
28379226c1 | ||
|
|
90cbd95063 | ||
|
|
446732dad2 | ||
|
|
4b1721a96d | ||
|
|
90207a39a4 | ||
|
|
29324d4b2a | ||
|
|
2e362d9fb9 | ||
|
|
7f6eae712e | ||
|
|
33d7c17177 | ||
|
|
eff2435989 | ||
|
|
f54ef80b00 | ||
|
|
1323e85530 | ||
|
|
6a5d2c4105 | ||
|
|
71745f006d | ||
|
|
dff55f7abe | ||
|
|
0e65cf7ae2 | ||
|
|
29ef06f892 | ||
|
|
77111075b4 | ||
|
|
30b04645d7 | ||
|
|
1253f729b1 | ||
|
|
2c8c7f4659 | ||
|
|
48ca180db5 | ||
|
|
a57d6836aa | ||
|
|
7fdee0ab76 | ||
|
|
3a575e91a1 | ||
|
|
0f63290d6e | ||
|
|
826583cd37 | ||
|
|
c56c538c88 | ||
|
|
a4a03bb027 | ||
|
|
41e80f3788 | ||
|
|
03f987840b | ||
|
|
b40ca17263 | ||
|
|
6971bc1bda | ||
|
|
187c788b47 | ||
|
|
658b5466ca | ||
|
|
8480bee676 | ||
|
|
a50c055579 | ||
|
|
52dba5041d | ||
|
|
ca151f54f2 | ||
|
|
6234bd1ef6 | ||
|
|
7076bccc8d | ||
|
|
49387d9033 | ||
|
|
7394c40167 | ||
|
|
f45ab94e06 | ||
|
|
babdc661ac | ||
|
|
a1e8506d42 | ||
|
|
0a2aea0a7a | ||
|
|
b335edacaf | ||
|
|
e138c5467d | ||
|
|
466745e5fb | ||
|
|
c47b9fed4e | ||
|
|
f18bc98a96 | ||
|
|
d8e0b05c6a | ||
|
|
7867baa842 | ||
|
|
7728713ae8 | ||
|
|
231a921d5c | ||
|
|
e76f89a338 | ||
|
|
28a62cdbc6 | ||
|
|
694c2afe23 | ||
|
|
6f5b23445e | ||
|
|
b37205a98d | ||
|
|
ab37f7ac5c | ||
|
|
cff1cede46 | ||
|
|
524f9c0327 | ||
|
|
074ce9b815 | ||
|
|
8d1c0cf3a0 | ||
|
|
fe611ac9df | ||
|
|
66e707bfdf | ||
|
|
966495a2a9 | ||
|
|
19df7f65c4 | ||
|
|
88e3a5e0d6 | ||
|
|
687a44ee58 | ||
|
|
20afbba7e2 | ||
|
|
35e6a72691 | ||
|
|
ee97a76654 | ||
|
|
a3ba85803a | ||
|
|
d25d01a230 | ||
|
|
c944264760 | ||
|
|
0d1a4786e1 | ||
|
|
d3cdb81977 | ||
|
|
16dcb2edc7 | ||
|
|
21af33687c | ||
|
|
8ea0e62bdd | ||
|
|
d4cf4a7e5f | ||
|
|
5827d8b137 | ||
|
|
fd2d5093a9 | ||
|
|
7d2949d6a7 | ||
|
|
df1c2bac5f | ||
|
|
e2ec3bc2da | ||
|
|
20433cd2b6 | ||
|
|
f235149863 | ||
|
|
66af8e6090 | ||
|
|
f1fa8709c2 | ||
|
|
5b5e65d48b | ||
|
|
37d40c01ba | ||
|
|
16a4be2205 | ||
|
|
ead03b9779 | ||
|
|
ad922ad028 | ||
|
|
7a5e5b6d1f | ||
|
|
eda6d0907b | ||
|
|
84bba2783b | ||
|
|
293d3ecf74 | ||
|
|
20282b4d30 | ||
|
|
1c20bfe200 | ||
|
|
bb55bb3911 | ||
|
|
a4373c73e6 | ||
|
|
1696096583 | ||
|
|
ec4793241e | ||
|
|
cd6191463e | ||
|
|
a1927be492 | ||
|
|
0709f8cc2f | ||
|
|
07051212c4 | ||
|
|
4604ef64bc | ||
|
|
2aa1e2ef23 | ||
|
|
123ec62052 | ||
|
|
5cbd685019 | ||
|
|
bb98377a29 | ||
|
|
ce74617195 | ||
|
|
71e6ded025 | ||
|
|
2ce57aeffc | ||
|
|
625089a12c | ||
|
|
32c46795e8 | ||
|
|
b22fa6fdf7 | ||
|
|
c5e44327b3 | ||
|
|
db2625fff9 | ||
|
|
18255103ed | ||
|
|
a7fb20713b | ||
|
|
ec5e8a4ca1 | ||
|
|
c4e39d61b5 | ||
|
|
fa1b2721d7 | ||
|
|
08806f0d0c | ||
|
|
4a34445b81 | ||
|
|
c102c2f21c | ||
|
|
83a76ec0cd | ||
|
|
cdb9546bc0 | ||
|
|
c9177f3342 | ||
|
|
caf6cd1872 | ||
|
|
fa38bea8ea | ||
|
|
eff7c552c9 | ||
|
|
c964241cba | ||
|
|
ba3c9de9b7 | ||
|
|
253d421e29 | ||
|
|
861ee7d247 | ||
|
|
a1a4cbbf28 | ||
|
|
2a4f558bbc | ||
|
|
b11c6d587c | ||
|
|
5657a27262 | ||
|
|
0a694b0a24 | ||
|
|
0989a8bb8a | ||
|
|
c051980f26 | ||
|
|
6b01fc0f3f | ||
|
|
db4e145b7a | ||
|
|
68c54d4c5c | ||
|
|
aead7ee754 | ||
|
|
3fdd5457b1 | ||
|
|
d18d9cf5d0 | ||
|
|
9cf113abdc | ||
|
|
2796fdd691 | ||
|
|
5160d687f3 | ||
|
|
b46fec8983 | ||
|
|
e8dd04f952 | ||
|
|
4d0bf2723f | ||
|
|
b4b2dc298a | ||
|
|
e274052133 | ||
|
|
0bb7a5108a | ||
|
|
f59c0d62fc | ||
|
|
a6dbf807e4 | ||
|
|
b1b7f3c329 | ||
|
|
b9c4c62b00 | ||
|
|
92f4085386 | ||
|
|
a6094b2144 | ||
|
|
8e102b4e95 | ||
|
|
51987ba770 | ||
|
|
bcde4bebd5 | ||
|
|
f19d623d7d | ||
|
|
7c6a0b185a | ||
|
|
8afa271cb7 | ||
|
|
ff8aa4fc32 | ||
|
|
22c2e8799a | ||
|
|
ca0397c331 | ||
|
|
4853537765 | ||
|
|
3954ecc595 | ||
|
|
245262d997 | ||
|
|
8438e9bd5a | ||
|
|
7f7bde3145 | ||
|
|
7106fe620e | ||
|
|
1b14147d5b | ||
|
|
1e130ca70a | ||
|
|
7758411244 | ||
|
|
d74ec346ce | ||
|
|
4d1cdf9e18 | ||
|
|
ebc79cbe9c | ||
|
|
f0040ce53e | ||
|
|
46fdbc79a2 | ||
|
|
c3a862c245 | ||
|
|
2d0b0098a1 | ||
|
|
89a93ed4a8 | ||
|
|
aed4d60ccb | ||
|
|
b97a6f5150 | ||
|
|
de0a3b7c56 | ||
|
|
a8471848dc | ||
|
|
9b25e294ea | ||
|
|
105e286d79 | ||
|
|
900b1707fb | ||
|
|
37ce15e284 | ||
|
|
3cec775854 | ||
|
|
e30a5bb14f | ||
|
|
c269e49c2a | ||
|
|
155351edbc | ||
|
|
60f9c06458 | ||
|
|
a049acfa5b | ||
|
|
3f19489a9b | ||
|
|
c351d22095 | ||
|
|
9877abaf92 | ||
|
|
6c7965e35d | ||
|
|
cc3aff690e | ||
|
|
8584fd1a81 | ||
|
|
a1defd1512 | ||
|
|
45fe413b30 | ||
|
|
44f87fe924 | ||
|
|
1761f12604 | ||
|
|
70d26506bb | ||
|
|
9b85662988 | ||
|
|
0f0b40238f | ||
|
|
0df1ebc3d7 | ||
|
|
b8ff5b10d6 | ||
|
|
acf912eaa4 | ||
|
|
aca42a6300 | ||
|
|
9390e10f54 | ||
|
|
011a0f299f | ||
|
|
ee7ecc1c18 | ||
|
|
519ff1ce5a | ||
|
|
29d3fb63d7 | ||
|
|
2deb92686a | ||
|
|
5a397821d0 | ||
|
|
2c7002d3cc | ||
|
|
055b98c88a | ||
|
|
c50a907988 | ||
|
|
a2bbc0effc | ||
|
|
da57ccdf18 | ||
|
|
085c90a028 | ||
|
|
5df5e362d6 | ||
|
|
cc38f05593 | ||
|
|
c27acb7901 | ||
|
|
19157830bf | ||
|
|
12288606f6 | ||
|
|
ab9cce3197 | ||
|
|
845ee644fd | ||
|
|
3f00677449 | ||
|
|
e9821c585a | ||
|
|
95f58018f2 | ||
|
|
68653c6b2c | ||
|
|
77c0255f54 | ||
|
|
e54def617d | ||
|
|
b8eca3a536 | ||
|
|
f28bd7f059 | ||
|
|
b55fbf7568 | ||
|
|
b4eeca6155 | ||
|
|
d4af392b58 | ||
|
|
e3035242f9 | ||
|
|
486dbce7a6 | ||
|
|
dc9a935fe1 | ||
|
|
02944a8f70 | ||
|
|
5405675e26 | ||
|
|
df5f407c7d | ||
|
|
8a49aacd43 | ||
|
|
eb05aaf709 | ||
|
|
02d2c12188 | ||
|
|
84adf2be2e | ||
|
|
5d2e766d65 | ||
|
|
35adcb63ca | ||
|
|
aeb16b5f73 | ||
|
|
7ff1cd6ae8 | ||
|
|
faeef6e43d | ||
|
|
bfe6fcfb7b | ||
|
|
513d703440 | ||
|
|
6409a90a5b | ||
|
|
8c99baba30 | ||
|
|
401edcba9c | ||
|
|
87e1b6737e | ||
|
|
880644a6ca | ||
|
|
cc9c496be1 | ||
|
|
eea57a5719 | ||
|
|
bcf0acef34 | ||
|
|
f755696df0 | ||
|
|
0392675a07 | ||
|
|
19b1df4f44 | ||
|
|
f2c0fde99d | ||
|
|
3c1f664d83 | ||
|
|
fc9222322f | ||
|
|
182c5870c1 | ||
|
|
d13be1aab4 | ||
|
|
4090f10d6f | ||
|
|
6de5eba4a3 | ||
|
|
cd54112782 | ||
|
|
248a731df8 | ||
|
|
b0f57d6233 | ||
|
|
d11a9f4d34 | ||
|
|
4a7df9804b | ||
|
|
a0219004aa | ||
|
|
268b78b10a | ||
|
|
effbb0bceb | ||
|
|
fc16bb8a2f | ||
|
|
ae8c12732e | ||
|
|
d781001087 | ||
|
|
9a4a66f22b | ||
|
|
1f73dcfe8c | ||
|
|
1c1280a0a2 | ||
|
|
2b25308402 | ||
|
|
d900ebf0eb | ||
|
|
28a115a223 | ||
|
|
0924b0bfba | ||
|
|
fd31cf164f | ||
|
|
e6a44232aa | ||
|
|
3f823b4818 | ||
|
|
544c915f0b | ||
|
|
5043d34872 | ||
|
|
614c8b68fb | ||
|
|
1b50d37e30 | ||
|
|
7f686497ec | ||
|
|
d56d01592d | ||
|
|
545fd31783 | ||
|
|
d98df5f02b | ||
|
|
7b3eb2aa2f | ||
|
|
aa73f55681 | ||
|
|
0135d46afb | ||
|
|
36a2cef580 | ||
|
|
05d49222c6 | ||
|
|
ff4e32e43d | ||
|
|
8015e6a25c | ||
|
|
c8d7bc703e | ||
|
|
4d0c33b59f | ||
|
|
ff518558cc | ||
|
|
ef24b8563c | ||
|
|
1bb26a718e |
5
.dockerignore
Normal file
@@ -0,0 +1,5 @@
|
||||
venv/
|
||||
dist/
|
||||
build/
|
||||
test/
|
||||
parsedmarc.egg-info/
|
||||
1
.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
||||
samples/* binary
|
||||
57
.github/workflows/docker.yml
vendored
Normal file
@@ -0,0 +1,57 @@
|
||||
name: Build docker image
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
release:
|
||||
types:
|
||||
- published
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
build-and-push-image:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v3
|
||||
with:
|
||||
images: |
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
# generate Docker tags based on the following events/attributes
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
|
||||
- name: Log in to the Container registry
|
||||
# https://github.com/docker/login-action/releases/tag/v2.0.0
|
||||
uses: docker/login-action@49ed152c8eca782a232dede0303416e8f356c37b
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
# https://github.com/docker/build-push-action/releases/tag/v3.0.0
|
||||
uses: docker/build-push-action@e551b19e49efd4e98792db7592c17c09b89db8d8
|
||||
with:
|
||||
context: .
|
||||
push: ${{ github.event_name == 'release' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
70
.github/workflows/python-tests.yml
vendored
Normal file
@@ -0,0 +1,70 @@
|
||||
name: Python tests
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ master ]
|
||||
pull_request:
|
||||
branches: [ master ]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
services:
|
||||
elasticsearch:
|
||||
image: elasticsearch:8.18.2
|
||||
env:
|
||||
discovery.type: single-node
|
||||
cluster.name: parsedmarc-cluster
|
||||
discovery.seed_hosts: elasticsearch
|
||||
bootstrap.memory_lock: true
|
||||
xpack.security.enabled: false
|
||||
xpack.license.self_generated.type: basic
|
||||
ports:
|
||||
- 9200:9200
|
||||
- 9300:9300
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: ["3.9", "3.10", "3.11", "3.12"]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libemail-outlook-message-perl
|
||||
- name: Install Python dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install .[build]
|
||||
- name: Test building documentation
|
||||
run: |
|
||||
cd docs
|
||||
make html
|
||||
- name: Check code style
|
||||
run: |
|
||||
ruff check .
|
||||
- name: Run unit tests
|
||||
run: |
|
||||
pytest --cov --cov-report=xml tests.py
|
||||
- name: Test sample DMARC reports
|
||||
run: |
|
||||
pip install -e .
|
||||
parsedmarc --debug -c ci.ini samples/aggregate/*
|
||||
parsedmarc --debug -c ci.ini samples/forensic/*
|
||||
- name: Test building packages
|
||||
run: |
|
||||
hatch build
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v4
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
42
.gitignore
vendored
@@ -24,6 +24,7 @@ wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
_tmp*
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
@@ -45,6 +46,7 @@ nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
@@ -62,7 +64,7 @@ instance/
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
docs/build/
|
||||
|
||||
# PyBuilder
|
||||
target/
|
||||
@@ -103,18 +105,40 @@ ENV/
|
||||
# PyCharm Project settings
|
||||
.idea/
|
||||
|
||||
# I/O files
|
||||
# VS Code launch config
|
||||
.vscode/launch.json
|
||||
|
||||
*.xml
|
||||
*.zip
|
||||
*.gz
|
||||
*.json
|
||||
*.csv
|
||||
# Visual Studio Code settings
|
||||
#.vscode/
|
||||
|
||||
# I/O files
|
||||
output/
|
||||
*.xls*
|
||||
|
||||
# LibreOffice lock files
|
||||
.~*
|
||||
|
||||
# ignore data files
|
||||
# Data files
|
||||
*.dat
|
||||
*.mmdb
|
||||
GeoIP*
|
||||
GeoLite*
|
||||
|
||||
# Temp files
|
||||
tmp/
|
||||
|
||||
# Config files
|
||||
prod*.ini
|
||||
stage*.ini
|
||||
dev*.ini
|
||||
|
||||
# Private samples
|
||||
samples/private
|
||||
|
||||
*.html
|
||||
*.sqlite-journal
|
||||
|
||||
parsedmarc.ini
|
||||
scratch.py
|
||||
|
||||
parsedmarc/resources/maps/base_reverse_dns.csv
|
||||
parsedmarc/resources/maps/unknown_base_reverse_dns.csv
|
||||
|
||||
23
.travis.yml
@@ -1,23 +0,0 @@
|
||||
language: python
|
||||
|
||||
sudo: false
|
||||
|
||||
python:
|
||||
- '2.7'
|
||||
- '3.4'
|
||||
- '3.5'
|
||||
- '3.6'
|
||||
|
||||
# commands to install dependencies
|
||||
install:
|
||||
- "pip install flake8 pytest-cov pytest coveralls"
|
||||
- "pip install -r requirements.txt"
|
||||
|
||||
# commands to run samples
|
||||
script:
|
||||
- "flake8 *.py"
|
||||
- "cd docs"
|
||||
- "make html"
|
||||
- "cd .."
|
||||
- "python tests.py"
|
||||
- "python setup.py bdist_wheel"
|
||||
132
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,132 @@
|
||||
{
|
||||
"markdownlint.config": {
|
||||
"MD024": false
|
||||
},
|
||||
"cSpell.words": [
|
||||
"adkim",
|
||||
"akamaiedge",
|
||||
"amsmath",
|
||||
"andrewmcgilvray",
|
||||
"arcname",
|
||||
"aspf",
|
||||
"autoclass",
|
||||
"automodule",
|
||||
"backported",
|
||||
"bellsouth",
|
||||
"brakhane",
|
||||
"Brightmail",
|
||||
"CEST",
|
||||
"CHACHA",
|
||||
"checkdmarc",
|
||||
"Codecov",
|
||||
"confnew",
|
||||
"dateparser",
|
||||
"dateutil",
|
||||
"Davmail",
|
||||
"DBIP",
|
||||
"dearmor",
|
||||
"deflist",
|
||||
"devel",
|
||||
"DMARC",
|
||||
"Dmarcian",
|
||||
"dnspython",
|
||||
"dollarmath",
|
||||
"dpkg",
|
||||
"exampleuser",
|
||||
"expiringdict",
|
||||
"fieldlist",
|
||||
"genindex",
|
||||
"geoipupdate",
|
||||
"Geolite",
|
||||
"geolocation",
|
||||
"githubpages",
|
||||
"Grafana",
|
||||
"hostnames",
|
||||
"htpasswd",
|
||||
"httpasswd",
|
||||
"IMAP",
|
||||
"Interaktive",
|
||||
"IPDB",
|
||||
"journalctl",
|
||||
"keepalive",
|
||||
"keyout",
|
||||
"keyrings",
|
||||
"Leeman",
|
||||
"libemail",
|
||||
"linkify",
|
||||
"LISTSERV",
|
||||
"lxml",
|
||||
"mailparser",
|
||||
"mailrelay",
|
||||
"mailsuite",
|
||||
"maxdepth",
|
||||
"maxmind",
|
||||
"mbox",
|
||||
"mfrom",
|
||||
"michaeldavie",
|
||||
"mikesiegel",
|
||||
"mitigations",
|
||||
"MMDB",
|
||||
"modindex",
|
||||
"msgconvert",
|
||||
"msgraph",
|
||||
"MSSP",
|
||||
"Munge",
|
||||
"ndjson",
|
||||
"newkey",
|
||||
"Nhcm",
|
||||
"nojekyll",
|
||||
"nondigest",
|
||||
"nosecureimap",
|
||||
"nosniff",
|
||||
"nwettbewerb",
|
||||
"parsedmarc",
|
||||
"passsword",
|
||||
"Postorius",
|
||||
"premade",
|
||||
"procs",
|
||||
"publicsuffix",
|
||||
"publixsuffix",
|
||||
"pypy",
|
||||
"quickstart",
|
||||
"Reindex",
|
||||
"replyto",
|
||||
"reversename",
|
||||
"Rollup",
|
||||
"Rpdm",
|
||||
"SAMEORIGIN",
|
||||
"Servernameone",
|
||||
"setuptools",
|
||||
"smartquotes",
|
||||
"SMTPTLS",
|
||||
"sourcetype",
|
||||
"STARTTLS",
|
||||
"tasklist",
|
||||
"timespan",
|
||||
"tlsa",
|
||||
"tlsrpt",
|
||||
"toctree",
|
||||
"TQDDM",
|
||||
"tqdm",
|
||||
"truststore",
|
||||
"Übersicht",
|
||||
"uids",
|
||||
"unparasable",
|
||||
"uper",
|
||||
"urllib",
|
||||
"Valimail",
|
||||
"venv",
|
||||
"Vhcw",
|
||||
"viewcode",
|
||||
"virtualenv",
|
||||
"WBITS",
|
||||
"webmail",
|
||||
"Wettbewerber",
|
||||
"Whalen",
|
||||
"whitespaces",
|
||||
"xennn",
|
||||
"xmltodict",
|
||||
"xpack",
|
||||
"zscholl"
|
||||
],
|
||||
}
|
||||
1160
CHANGELOG.md
35
Dockerfile
Normal file
@@ -0,0 +1,35 @@
|
||||
ARG BASE_IMAGE=python:3.9-slim
|
||||
ARG USERNAME=parsedmarc
|
||||
ARG USER_UID=1000
|
||||
ARG USER_GID=$USER_UID
|
||||
|
||||
## build
|
||||
|
||||
FROM $BASE_IMAGE AS build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN pip install hatch
|
||||
|
||||
COPY parsedmarc/ parsedmarc/
|
||||
COPY README.md pyproject.toml ./
|
||||
|
||||
RUN hatch build
|
||||
|
||||
## image
|
||||
|
||||
FROM $BASE_IMAGE
|
||||
ARG USERNAME
|
||||
ARG USER_UID
|
||||
ARG USER_GID
|
||||
|
||||
COPY --from=build /app/dist/*.whl /tmp/dist/
|
||||
RUN set -ex; \
|
||||
groupadd --gid ${USER_GID} ${USERNAME}; \
|
||||
useradd --uid ${USER_UID} --gid ${USER_GID} -m ${USERNAME}; \
|
||||
pip install /tmp/dist/*.whl; \
|
||||
rm -rf /tmp/dist
|
||||
|
||||
USER $USERNAME
|
||||
|
||||
ENTRYPOINT ["parsedmarc"]
|
||||
47
README.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# parsedmarc
|
||||
|
||||
[](https://github.com/domainaware/parsedmarc/actions/workflows/python-tests.yml)
|
||||
[](https://codecov.io/gh/domainaware/parsedmarc)
|
||||
[](https://pypi.org/project/parsedmarc/)
|
||||
[](https://pypistats.org/packages/parsedmarc)
|
||||
|
||||
<p align="center">
|
||||
<img src="https://github.com/domainaware/parsedmarc/raw/master/docs/source/_static/screenshots/dmarc-summary-charts.png?raw=true" alt="A screenshot of DMARC summary charts in Kibana"/>
|
||||
</p>
|
||||
|
||||
`parsedmarc` is a Python module and CLI utility for parsing DMARC
|
||||
reports. When used with Elasticsearch and Kibana (or Splunk), it works
|
||||
as a self-hosted open-source alternative to commercial DMARC report
|
||||
processing services such as Agari Brand Protection, Dmarcian, OnDMARC,
|
||||
ProofPoint Email Fraud Defense, and Valimail.
|
||||
|
||||
> [!NOTE]
|
||||
> __Domain-based Message Authentication, Reporting, and Conformance__ (DMARC) is an email authentication protocol.
|
||||
|
||||
## Help Wanted
|
||||
|
||||
This project is maintained by one developer. Please consider
|
||||
reviewing the open
|
||||
[issues](https://github.com/domainaware/parsedmarc/issues) to see how
|
||||
you can contribute code, documentation, or user support. Assistance on
|
||||
the pinned issues would be particularly helpful.
|
||||
|
||||
Thanks to all
|
||||
[contributors](https://github.com/domainaware/parsedmarc/graphs/contributors)!
|
||||
|
||||
## Features
|
||||
|
||||
- Parses draft and 1.0 standard aggregate/rua reports
|
||||
- Parses forensic/failure/ruf reports
|
||||
- Can parse reports from an inbox over IMAP, Microsoft Graph, or Gmail
|
||||
API
|
||||
- Transparently handles gzip or zip compressed reports
|
||||
- Consistent data structures
|
||||
- Simple JSON and/or CSV output
|
||||
- Optionally email the results
|
||||
- Optionally send the results to Elasticsearch, Opensearch, and/or Splunk, for use
|
||||
with premade dashboards
|
||||
- Optionally send reports to Apache Kafka
|
||||
188
README.rst
@@ -1,188 +0,0 @@
|
||||
checkdmarc
|
||||
==========
|
||||
|
||||
|Build Status|
|
||||
|
||||
``pasedmarc`` is a Python module and CLI utility for parsing aggregate DMARC reports.
|
||||
|
||||
Features
|
||||
========
|
||||
|
||||
* Parses draft and 1.0 standard aggregate reports
|
||||
* Transparently handles gzip or zip compressed reports
|
||||
* Consistent data structures
|
||||
* Simple JSON or CSV output
|
||||
* Python 2 and 3 support
|
||||
|
||||
CLI help
|
||||
========
|
||||
|
||||
::
|
||||
|
||||
usage: parsedmarc.py [-h] [-f FORMAT] [-o OUTPUT]
|
||||
[-n NAMESERVER [NAMESERVER ...]] [-t TIMEOUT] [-v]
|
||||
file_path [file_path ...]
|
||||
|
||||
Parses aggregate DMARC reports
|
||||
|
||||
positional arguments:
|
||||
file_path one or more paths of aggregate report files
|
||||
(compressed or uncompressed)
|
||||
|
||||
optional arguments:
|
||||
-h, --help show this help message and exit
|
||||
-f FORMAT, --format FORMAT
|
||||
specify JSON or CSV output format
|
||||
-o OUTPUT, --output OUTPUT
|
||||
output to a file path rather than printing to the
|
||||
screen
|
||||
-n NAMESERVER [NAMESERVER ...], --nameserver NAMESERVER [NAMESERVER ...]
|
||||
nameservers to query
|
||||
-t TIMEOUT, --timeout TIMEOUT
|
||||
number of seconds to wait for an answer from DNS
|
||||
(default 6.0)
|
||||
-v, --version show program's version number and exit
|
||||
|
||||
|
||||
Sample output
|
||||
=============
|
||||
|
||||
Here are the results from parsing the `example <https://dmarc.org/wiki/FAQ#I_need_to_implement_aggregate_reports.2C_what_do_they_look_like.3F>`_
|
||||
report from the dmarc.org wiki. It's actually an older draft of the the 1.0
|
||||
report schema standardized in
|
||||
`RFC 7480 Appendix C <https://tools.ietf.org/html/rfc7489#appendix-C>`_.
|
||||
This draft schema is still in wide use.
|
||||
|
||||
``parsedmarc`` produces consistent, normalized output, regardless of the report schema.
|
||||
|
||||
JSON
|
||||
----
|
||||
|
||||
.. code-block:: json
|
||||
|
||||
{
|
||||
"xml_schema": "draft",
|
||||
"report_metadata": {
|
||||
"org_name": "acme.com",
|
||||
"org_email": "noreply-dmarc-support@acme.com",
|
||||
"org_extra_contact_info": "http://acme.com/dmarc/support",
|
||||
"report_id": "9391651994964116463",
|
||||
"begin_date": "2012-04-27 20:00:00",
|
||||
"end_date": "2012-04-28 19:59:59",
|
||||
"errors": []
|
||||
},
|
||||
"policy_published": {
|
||||
"domain": "example.com",
|
||||
"adkim": "r",
|
||||
"aspf": "r",
|
||||
"p": "none",
|
||||
"sp": "none",
|
||||
"pct": "100",
|
||||
"fo": "0"
|
||||
},
|
||||
"records": [
|
||||
{
|
||||
"source": {
|
||||
"ip_address": "72.150.241.94",
|
||||
"country": "US",
|
||||
"reverse_dns": "adsl-72-150-241-94.shv.bellsouth.net",
|
||||
"base_domain": "bellsouth.net"
|
||||
},
|
||||
"count": 2,
|
||||
"policy_evaluated": {
|
||||
"disposition": "none",
|
||||
"dkim": "fail",
|
||||
"spf": "pass",
|
||||
"policy_override_reasons": []
|
||||
},
|
||||
"identifiers": {
|
||||
"header_from": "example.com",
|
||||
"envelope_from": "example.com",
|
||||
"envelope_to": null
|
||||
},
|
||||
"auth_results": {
|
||||
"dkim": [
|
||||
{
|
||||
"domain": "example.com",
|
||||
"selector": "none",
|
||||
"result": "fail"
|
||||
}
|
||||
],
|
||||
"spf": [
|
||||
{
|
||||
"domain": "example.com",
|
||||
"scope": "mfrom",
|
||||
"result": "pass"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
CSV
|
||||
---
|
||||
|
||||
::
|
||||
|
||||
xml_schema,org_name,org_email,org_extra_contact_info,report_id,begin_date,end_date,errors,domain,adkim,aspf,p,sp,pct,fo,source_ip_address,source_country,source_reverse_dns,source_base_domain,count,disposition,dkim_alignment,spf_alignment,policy_override_reasons,policy_override_comments,envelope_from,header_from,envelope_to,dkim_domains,dkim_selectors,dkim_results,spf_domains,spf_scopes,spf_results
|
||||
draft,acme.com,noreply-dmarc-support@acme.com,http://acme.com/dmarc/support,9391651994964116463,2012-04-27 20:00:00,2012-04-28 19:59:59,[],example.com,r,r,none,none,100,0,72.150.241.94,US,adsl-72-150-241-94.shv.bellsouth.net,bellsouth.net,2,none,fail,pass,,,example.com,example.com,,example.com,none,fail,example.com,mfrom,pass
|
||||
|
||||
What about forensic DMARC reports?
|
||||
==================================
|
||||
|
||||
Forensic DMARC reports are emails with an attached email sample that failed a
|
||||
DMARC check. You can parse them with any email message parser, such as
|
||||
`mail-parser <https://pypi.python.org/pypi/mail-parser/>`_.
|
||||
|
||||
Very few recipients send forensic reports, and even those who do will often
|
||||
provide only the message headers, and not the message's content, for privacy
|
||||
reasons.
|
||||
|
||||
Installation
|
||||
============
|
||||
|
||||
``parsedmarc`` works with Python 2 or 3, but Python 3 is preferred.
|
||||
|
||||
On Debian or Ubuntu systems, run:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
$ sudo apt-get install python3-pip
|
||||
|
||||
|
||||
Python 3 installers for Windows and macOS can be found at https://www.python.org/downloads/
|
||||
|
||||
To install or upgrade to the latest stable release of ``parsedmarc`` on macOS or Linux, run
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
$ sudo -H pip3 install -U checkdmarc
|
||||
|
||||
Or, install the latest development release directly from GitHub:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
$ sudo -H pip3 install -U git+https://github.com/domainaware/parsedmarc.git
|
||||
|
||||
.. note::
|
||||
|
||||
On Windows, ``pip3`` is ``pip``, even with Python 3. So on Windows, simply
|
||||
substitute ``pip`` as an administrator in place of ``sudo pip3``, in the above commands.
|
||||
|
||||
|
||||
|
||||
Documentation
|
||||
=============
|
||||
|
||||
https://domainaware.github.io/parsedmarc
|
||||
|
||||
Bug reports
|
||||
===========
|
||||
|
||||
Please report bugs on the GitHub issue tracker
|
||||
|
||||
https://github.com/domainaware/parsedmarc/issues
|
||||
|
||||
.. |Build Status| image:: https://travis-ci.org/domainaware/parsedmarc.svg?branch=master
|
||||
:target: https://travis-ci.org/domainaware/parsedmarc
|
||||
25
build.sh
Executable file
@@ -0,0 +1,25 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -e
|
||||
|
||||
if [ ! -d "venv" ]; then
|
||||
virtualenv venv || exit
|
||||
fi
|
||||
|
||||
. venv/bin/activate
|
||||
pip install .[build]
|
||||
ruff format .
|
||||
ruff check .
|
||||
cd docs
|
||||
make clean
|
||||
make html
|
||||
touch build/html/.nojekyll
|
||||
if [ -d "./../parsedmarc-docs" ]; then
|
||||
cp -rf build/html/* ../../parsedmarc-docs/
|
||||
fi
|
||||
cd ..
|
||||
sort -o "parsedmarc/resources/maps/known_unknown_base_reverse_dns.txt" "parsedmarc/resources/maps/known_unknown_base_reverse_dns.txt"
|
||||
./sortmaps.py
|
||||
python3 tests.py
|
||||
rm -rf dist/ build/
|
||||
hatch build
|
||||
12
ci.ini
Normal file
@@ -0,0 +1,12 @@
|
||||
[general]
|
||||
save_aggregate = True
|
||||
save_forensic = True
|
||||
save_smtp_tls = True
|
||||
debug = True
|
||||
|
||||
[elasticsearch]
|
||||
hosts = http://localhost:9200
|
||||
ssl = False
|
||||
number_of_shards=2
|
||||
number_of_replicas=2
|
||||
|
||||
57
docker-compose.yml
Normal file
@@ -0,0 +1,57 @@
|
||||
version: '3.7'
|
||||
|
||||
services:
|
||||
elasticsearch:
|
||||
image: docker.elastic.co/elasticsearch/elasticsearch:8.3.1
|
||||
environment:
|
||||
- network.host=127.0.0.1
|
||||
- http.host=0.0.0.0
|
||||
- node.name=elasticsearch
|
||||
- discovery.type=single-node
|
||||
- cluster.name=parsedmarc-cluster
|
||||
- discovery.seed_hosts=elasticsearch
|
||||
- bootstrap.memory_lock=true
|
||||
- xpack.security.enabled=false
|
||||
- xpack.license.self_generated.type=basic
|
||||
ports:
|
||||
- 127.0.0.1:9200:9200
|
||||
ulimits:
|
||||
memlock:
|
||||
soft: -1
|
||||
hard: -1
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD-SHELL",
|
||||
"curl -s -XGET http://localhost:9200/_cluster/health?pretty | grep status | grep -q '\\(green\\|yellow\\)'"
|
||||
]
|
||||
interval: 10s
|
||||
timeout: 10s
|
||||
retries: 24
|
||||
|
||||
opensearch:
|
||||
image: opensearchproject/opensearch:2.18.0
|
||||
environment:
|
||||
- network.host=127.0.0.1
|
||||
- http.host=0.0.0.0
|
||||
- node.name=opensearch
|
||||
- discovery.type=single-node
|
||||
- cluster.name=parsedmarc-cluster
|
||||
- discovery.seed_hosts=opensearch
|
||||
- bootstrap.memory_lock=true
|
||||
- OPENSEARCH_INITIAL_ADMIN_PASSWORD=${OPENSEARCH_INITIAL_ADMIN_PASSWORD}
|
||||
ports:
|
||||
- 127.0.0.1:9201:9200
|
||||
ulimits:
|
||||
memlock:
|
||||
soft: -1
|
||||
hard: -1
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD-SHELL",
|
||||
"curl -s -XGET http://localhost:9201/_cluster/health?pretty | grep status | grep -q '\\(green\\|yellow\\)'"
|
||||
]
|
||||
interval: 10s
|
||||
timeout: 10s
|
||||
retries: 24
|
||||
@@ -3,10 +3,10 @@
|
||||
|
||||
# You can set these variables from the command line.
|
||||
SPHINXOPTS =
|
||||
SPHINXBUILD = python -msphinx
|
||||
SPHINXBUILD = python3 -msphinx
|
||||
SPHINXPROJ = parsedmarc
|
||||
SOURCEDIR = .
|
||||
BUILDDIR = _build
|
||||
SOURCEDIR = source
|
||||
BUILDDIR = build
|
||||
|
||||
# Put it first so that "make" without argument is like "make help".
|
||||
help:
|
||||
|
||||
1
docs/_templates/placeholder
vendored
@@ -1 +0,0 @@
|
||||
Make directory show up in git.
|
||||
179
docs/conf.py
@@ -1,179 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# parsedmarc documentation build configuration file, created by
|
||||
# sphinx-quickstart on Mon Feb 5 18:25:39 2018.
|
||||
#
|
||||
# This file is execfile()d with the current directory set to its
|
||||
# containing dir.
|
||||
#
|
||||
# Note that not all possible configuration values are present in this
|
||||
# autogenerated file.
|
||||
#
|
||||
# All configuration values have a default; values that are commented out
|
||||
# serve to show the default.
|
||||
|
||||
# If extensions (or modules to document with autodoc) are in another directory,
|
||||
# add these directories to sys.path here. If the directory is relative to the
|
||||
# documentation root, use os.path.abspath to make it absolute, like shown here.
|
||||
#
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.abspath('..'))
|
||||
|
||||
from parsedmarc import __version__
|
||||
|
||||
|
||||
# -- General configuration ------------------------------------------------
|
||||
|
||||
# If your documentation needs a minimal Sphinx version, state it here.
|
||||
#
|
||||
# needs_sphinx = '1.0'
|
||||
|
||||
# Add any Sphinx extension module names here, as strings. They can be
|
||||
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
|
||||
# ones.
|
||||
extensions = ['sphinx.ext.autodoc',
|
||||
'sphinx.ext.doctest',
|
||||
'sphinx.ext.todo',
|
||||
'sphinx.ext.viewcode',
|
||||
'sphinx.ext.githubpages',
|
||||
'sphinx.ext.napoleon']
|
||||
|
||||
# Add any paths that contain templates here, relative to this directory.
|
||||
templates_path = ['_templates']
|
||||
|
||||
# The suffix(es) of source filenames.
|
||||
# You can specify multiple suffix as a list of string:
|
||||
#
|
||||
# source_suffix = ['.rst', '.md']
|
||||
source_suffix = '.rst'
|
||||
|
||||
# The master toctree document.
|
||||
master_doc = 'index'
|
||||
|
||||
# General information about the project.
|
||||
project = 'parsedmarc'
|
||||
copyright = '2018, Sean Whalen'
|
||||
author = 'Sean Whalen'
|
||||
|
||||
# The version info for the project you're documenting, acts as replacement for
|
||||
# |version| and |release|, also used in various other places throughout the
|
||||
# built documents.
|
||||
#
|
||||
# The short X.Y version.
|
||||
version = __version__
|
||||
# The full version, including alpha/beta/rc tags.
|
||||
release = version
|
||||
|
||||
# The language for content autogenerated by Sphinx. Refer to documentation
|
||||
# for a list of supported languages.
|
||||
#
|
||||
# This is also used if you do content translation via gettext catalogs.
|
||||
# Usually you set "language" from the command line for these cases.
|
||||
language = None
|
||||
|
||||
# List of patterns, relative to source directory, that match files and
|
||||
# directories to ignore when looking for source files.
|
||||
# This patterns also effect to html_static_path and html_extra_path
|
||||
exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']
|
||||
|
||||
# The name of the Pygments (syntax highlighting) style to use.
|
||||
pygments_style = 'sphinx'
|
||||
|
||||
todo_include_todos = False
|
||||
|
||||
|
||||
# -- Options for HTML output ----------------------------------------------
|
||||
|
||||
# The theme to use for HTML and HTML Help pages. See the documentation for
|
||||
# a list of builtin themes.
|
||||
#
|
||||
html_theme = 'sphinx_rtd_theme'
|
||||
|
||||
# Theme options are theme-specific and customize the look and feel of a theme
|
||||
# further. For a list of options available for each theme, see the
|
||||
# documentation.
|
||||
#
|
||||
# html_theme_options = {}
|
||||
|
||||
# Add any paths that contain custom static files (such as style sheets) here,
|
||||
# relative to this directory. They are copied after the builtin static files,
|
||||
# so a file named "default.css" will overwrite the builtin "default.css".
|
||||
html_static_path = ['_static']
|
||||
|
||||
# Custom sidebar templates, must be a dictionary that maps document names
|
||||
# to template names.
|
||||
#
|
||||
# This is required for the alabaster theme
|
||||
# refs: http://alabaster.readthedocs.io/en/latest/installation.html#sidebars
|
||||
html_sidebars = {
|
||||
'**': [
|
||||
'about.html',
|
||||
'navigation.html',
|
||||
'relations.html', # needs 'show_related': True theme option to display
|
||||
'searchbox.html',
|
||||
'donate.html',
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
# -- Options for HTMLHelp output ------------------------------------------
|
||||
|
||||
# Output file base name for HTML help builder.
|
||||
htmlhelp_basename = 'parsedmarcdoc'
|
||||
|
||||
|
||||
# -- Options for LaTeX output ---------------------------------------------
|
||||
|
||||
latex_elements = {
|
||||
# The paper size ('letterpaper' or 'a4paper').
|
||||
#
|
||||
# 'papersize': 'letterpaper',
|
||||
|
||||
# The font size ('10pt', '11pt' or '12pt').
|
||||
#
|
||||
# 'pointsize': '10pt',
|
||||
|
||||
# Additional stuff for the LaTeX preamble.
|
||||
#
|
||||
# 'preamble': '',
|
||||
|
||||
# Latex figure (float) alignment
|
||||
#
|
||||
# 'figure_align': 'htbp',
|
||||
}
|
||||
|
||||
# Grouping the document tree into LaTeX files. List of tuples
|
||||
# (source start file, target name, title,
|
||||
# author, documentclass [howto, manual, or own class]).
|
||||
latex_documents = [
|
||||
(master_doc, 'parsedmarc.tex', 'parsedmarc Documentation',
|
||||
'parsedmarc', 'manual'),
|
||||
]
|
||||
|
||||
|
||||
# -- Options for manual page output ---------------------------------------
|
||||
|
||||
# One entry per manual page. List of tuples
|
||||
# (source start file, name, description, authors, manual section).
|
||||
man_pages = [
|
||||
(master_doc, 'parsedmarc', 'parsedmarc Documentation',
|
||||
[author], 1)
|
||||
]
|
||||
|
||||
|
||||
# -- Options for Texinfo output -------------------------------------------
|
||||
|
||||
# Grouping the document tree into Texinfo files. List of tuples
|
||||
# (source start file, target name, title, author,
|
||||
# dir menu entry, description, category)
|
||||
texinfo_documents = [
|
||||
(master_doc, 'parsedmarc', 'parsedmarc Documentation',
|
||||
author, 'parsedmarc', 'One line description of project.',
|
||||
'Miscellaneous'),
|
||||
]
|
||||
|
||||
|
||||
|
||||
204
docs/index.rst
@@ -1,204 +0,0 @@
|
||||
.. parsedmarc documentation master file, created by
|
||||
sphinx-quickstart on Mon Feb 5 18:25:39 2018.
|
||||
You can adapt this file completely to your liking, but it should at least
|
||||
contain the root `toctree` directive.
|
||||
|
||||
Welcome to parsedmarc's documentation!
|
||||
======================================
|
||||
|
||||
|Build Status|
|
||||
|
||||
``pasedmarc`` is a Python module and CLI utility for parsing aggregate DMARC reports.
|
||||
|
||||
Features
|
||||
========
|
||||
|
||||
* Parses draft and 1.0 standard aggregate reports
|
||||
* Transparently handles gzip or zip compressed reports
|
||||
* Consistent data structures
|
||||
* Simple JSON or CSV output
|
||||
* Python 2 and 3 support
|
||||
|
||||
CLI help
|
||||
========
|
||||
|
||||
::
|
||||
|
||||
usage: parsedmarc.py [-h] [-f FORMAT] [-o OUTPUT]
|
||||
[-n NAMESERVER [NAMESERVER ...]] [-t TIMEOUT] [-v]
|
||||
file_path [file_path ...]
|
||||
|
||||
Parses aggregate DMARC reports
|
||||
|
||||
positional arguments:
|
||||
file_path one or more paths of aggregate report files
|
||||
(compressed or uncompressed)
|
||||
|
||||
optional arguments:
|
||||
-h, --help show this help message and exit
|
||||
-f FORMAT, --format FORMAT
|
||||
specify JSON or CSV output format
|
||||
-o OUTPUT, --output OUTPUT
|
||||
output to a file path rather than printing to the
|
||||
screen
|
||||
-n NAMESERVER [NAMESERVER ...], --nameserver NAMESERVER [NAMESERVER ...]
|
||||
nameservers to query
|
||||
-t TIMEOUT, --timeout TIMEOUT
|
||||
number of seconds to wait for an answer from DNS
|
||||
(default 6.0)
|
||||
-v, --version show program's version number and exit
|
||||
|
||||
|
||||
Sample output
|
||||
=============
|
||||
|
||||
Here are the results from parsing the `example <https://dmarc.org/wiki/FAQ#I_need_to_implement_aggregate_reports.2C_what_do_they_look_like.3F>`_
|
||||
report from the dmarc.org wiki. It's actually an older draft of the the 1.0
|
||||
report schema standardized in
|
||||
`RFC 7480 Appendix C <https://tools.ietf.org/html/rfc7489#appendix-C>`_.
|
||||
This draft schema is still in wide use.
|
||||
|
||||
``parsedmarc`` produces consistent, normalized output, regardless of the report schema.
|
||||
|
||||
JSON
|
||||
----
|
||||
|
||||
.. code-block:: json
|
||||
|
||||
{
|
||||
"xml_schema": "draft",
|
||||
"report_metadata": {
|
||||
"org_name": "acme.com",
|
||||
"org_email": "noreply-dmarc-support@acme.com",
|
||||
"org_extra_contact_info": "http://acme.com/dmarc/support",
|
||||
"report_id": "9391651994964116463",
|
||||
"begin_date": "2012-04-27 20:00:00",
|
||||
"end_date": "2012-04-28 19:59:59",
|
||||
"errors": []
|
||||
},
|
||||
"policy_published": {
|
||||
"domain": "example.com",
|
||||
"adkim": "r",
|
||||
"aspf": "r",
|
||||
"p": "none",
|
||||
"sp": "none",
|
||||
"pct": "100",
|
||||
"fo": "0"
|
||||
},
|
||||
"records": [
|
||||
{
|
||||
"source": {
|
||||
"ip_address": "72.150.241.94",
|
||||
"country": "US",
|
||||
"reverse_dns": "adsl-72-150-241-94.shv.bellsouth.net",
|
||||
"base_domain": "bellsouth.net"
|
||||
},
|
||||
"count": 2,
|
||||
"policy_evaluated": {
|
||||
"disposition": "none",
|
||||
"dkim": "fail",
|
||||
"spf": "pass",
|
||||
"policy_override_reasons": []
|
||||
},
|
||||
"identifiers": {
|
||||
"header_from": "example.com",
|
||||
"envelope_from": "example.com",
|
||||
"envelope_to": null
|
||||
},
|
||||
"auth_results": {
|
||||
"dkim": [
|
||||
{
|
||||
"domain": "example.com",
|
||||
"selector": "none",
|
||||
"result": "fail"
|
||||
}
|
||||
],
|
||||
"spf": [
|
||||
{
|
||||
"domain": "example.com",
|
||||
"scope": "mfrom",
|
||||
"result": "pass"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
CSV
|
||||
---
|
||||
|
||||
::
|
||||
|
||||
xml_schema,org_name,org_email,org_extra_contact_info,report_id,begin_date,end_date,errors,domain,adkim,aspf,p,sp,pct,fo,source_ip_address,source_country,source_reverse_dns,source_base_domain,count,disposition,dkim_alignment,spf_alignment,policy_override_reasons,policy_override_comments,envelope_from,header_from,envelope_to,dkim_domains,dkim_selectors,dkim_results,spf_domains,spf_scopes,spf_results
|
||||
draft,acme.com,noreply-dmarc-support@acme.com,http://acme.com/dmarc/support,9391651994964116463,2012-04-27 20:00:00,2012-04-28 19:59:59,[],example.com,r,r,none,none,100,0,72.150.241.94,US,adsl-72-150-241-94.shv.bellsouth.net,bellsouth.net,2,none,fail,pass,,,example.com,example.com,,example.com,none,fail,example.com,mfrom,pass
|
||||
|
||||
What about forensic DMARC reports?
|
||||
==================================
|
||||
|
||||
Forensic DMARC reports are emails with an attached email sample that failed a
|
||||
DMARC check. You can parse them with any email message parser, such as
|
||||
`mail-parser <https://pypi.python.org/pypi/mail-parser/>`_.
|
||||
|
||||
Very few recipients send forensic reports, and even those who do will often
|
||||
provide only the message headers, and not the message's content, for privacy
|
||||
reasons.
|
||||
|
||||
Bug reports
|
||||
===========
|
||||
|
||||
Please report bugs on the GitHub issue tracker
|
||||
|
||||
https://github.com/domainaware/parsedmarc/issues
|
||||
|
||||
Installation
|
||||
============
|
||||
|
||||
``parsedmarc`` works with Python 2 or 3, but Python 3 is preferred.
|
||||
|
||||
On Debian or Ubuntu systems, run:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
$ sudo apt-get install python3-pip
|
||||
|
||||
|
||||
Python 3 installers for Windows and macOS can be found at https://www.python.org/downloads/
|
||||
|
||||
To install or upgrade to the latest stable release of ``parsedmarc`` on macOS or Linux, run
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
$ sudo -H pip3 install -U checkdmarc
|
||||
|
||||
Or, install the latest development release directly from GitHub:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
$ sudo -H pip3 install -U git+https://github.com/domainaware/parsedmarc.git
|
||||
|
||||
.. note::
|
||||
|
||||
On Windows, ``pip3`` is ``pip``, even with Python 3. So on Windows, simply
|
||||
substitute ``pip`` as an administrator in place of ``sudo pip3``, in the above commands.
|
||||
|
||||
API
|
||||
===
|
||||
|
||||
.. automodule:: parsedmarc
|
||||
:members:
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 2
|
||||
:caption: Contents:
|
||||
|
||||
Indices and tables
|
||||
==================
|
||||
|
||||
* :ref:`genindex`
|
||||
* :ref:`modindex`
|
||||
* :ref:`search`
|
||||
|
||||
|
||||
.. |Build Status| image:: https://travis-ci.org/domainaware/parsedmarc.svg?branch=master
|
||||
:target: https://travis-ci.org/domainaware/parsedmarc
|
||||
@@ -7,8 +7,8 @@ REM Command file for Sphinx documentation
|
||||
if "%SPHINXBUILD%" == "" (
|
||||
set SPHINXBUILD=python -msphinx
|
||||
)
|
||||
set SOURCEDIR=.
|
||||
set BUILDDIR=_build
|
||||
set SOURCEDIR=source
|
||||
set BUILDDIR=build
|
||||
set SPHINXPROJ=parsedmarc
|
||||
|
||||
if "%1" == "" goto help
|
||||
|
||||
BIN
docs/source/_static/screenshots/confirm-overwrite.png
Normal file
|
After Width: | Height: | Size: 9.0 KiB |
BIN
docs/source/_static/screenshots/define-dmarc-aggregate.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
docs/source/_static/screenshots/define-dmarc-forensic.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
docs/source/_static/screenshots/dmarc-aggregate-time-field.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
docs/source/_static/screenshots/dmarc-forensic-time-field.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
docs/source/_static/screenshots/dmarc-summary-charts.png
Normal file
|
After Width: | Height: | Size: 102 KiB |
BIN
docs/source/_static/screenshots/dmarc-summary-details.png
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
docs/source/_static/screenshots/dmarc-summary-map.png
Normal file
|
After Width: | Height: | Size: 250 KiB |
BIN
docs/source/_static/screenshots/index-pattern-conflicts.png
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
docs/source/_static/screenshots/saved-objects.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
42
docs/source/api.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# API reference
|
||||
|
||||
## parsedmarc
|
||||
|
||||
```{eval-rst}
|
||||
.. automodule:: parsedmarc
|
||||
:members:
|
||||
```
|
||||
|
||||
## parsedmarc.elastic
|
||||
|
||||
```{eval-rst}
|
||||
.. automodule:: parsedmarc.elastic
|
||||
:members:
|
||||
```
|
||||
|
||||
## parsedmarc.opensearch
|
||||
|
||||
```{eval-rst}
|
||||
.. automodule:: parsedmarc.opensearch
|
||||
:members:
|
||||
```
|
||||
|
||||
|
||||
## parsedmarc.splunk
|
||||
|
||||
```{eval-rst}
|
||||
.. automodule:: parsedmarc.splunk
|
||||
:members:
|
||||
```
|
||||
|
||||
## parsedmarc.utils
|
||||
|
||||
```{eval-rst}
|
||||
.. automodule:: parsedmarc.utils
|
||||
:members:
|
||||
```
|
||||
|
||||
## Indices and tables
|
||||
|
||||
- {ref}`genindex`
|
||||
- {ref}`modindex`
|
||||
94
docs/source/conf.py
Normal file
@@ -0,0 +1,94 @@
|
||||
# Configuration file for the Sphinx documentation builder.
|
||||
#
|
||||
# This file only contains a selection of the most common options. For a full
|
||||
# list see the documentation:
|
||||
# https://www.sphinx-doc.org/en/master/usage/configuration.html
|
||||
|
||||
# -- Path setup --------------------------------------------------------------
|
||||
|
||||
# If extensions (or modules to document with autodoc) are in another directory,
|
||||
# add these directories to sys.path here. If the directory is relative to the
|
||||
# documentation root, use os.path.abspath to make it absolute, like shown here.
|
||||
#
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.abspath(os.path.join("..", "..")))
|
||||
|
||||
from parsedmarc import __version__
|
||||
|
||||
# -- Project information -----------------------------------------------------
|
||||
|
||||
project = "parsedmarc"
|
||||
copyright = "2018 - 2023, Sean Whalen and contributors"
|
||||
author = "Sean Whalen and contributors"
|
||||
|
||||
# The version info for the project you're documenting, acts as replacement for
|
||||
# |version| and |release|, also used in various other places throughout the
|
||||
# built documents.
|
||||
#
|
||||
# The short X.Y version.
|
||||
version = __version__
|
||||
# The full version, including alpha/beta/rc tags.
|
||||
release = version
|
||||
|
||||
# -- General configuration ---------------------------------------------------
|
||||
|
||||
# Add any Sphinx extension module names here, as strings. They can be
|
||||
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
|
||||
# ones.
|
||||
extensions = [
|
||||
"sphinx.ext.autodoc",
|
||||
"sphinx.ext.doctest",
|
||||
"sphinx.ext.todo",
|
||||
"sphinx.ext.viewcode",
|
||||
"sphinx.ext.githubpages",
|
||||
"sphinx.ext.napoleon",
|
||||
"myst_parser",
|
||||
]
|
||||
|
||||
myst_enable_extensions = [
|
||||
"amsmath",
|
||||
"colon_fence",
|
||||
"deflist",
|
||||
"dollarmath",
|
||||
"fieldlist",
|
||||
"html_admonition",
|
||||
"html_image",
|
||||
"linkify",
|
||||
"replacements",
|
||||
"smartquotes",
|
||||
"strikethrough",
|
||||
"substitution",
|
||||
"tasklist",
|
||||
]
|
||||
|
||||
myst_heading_anchors = 3
|
||||
autoclass_content = "init"
|
||||
|
||||
# Add any paths that contain templates here, relative to this directory.
|
||||
templates_path = ["_templates"]
|
||||
|
||||
|
||||
# The suffixes of source filenames.
|
||||
source_suffix = [".rst", ".md"]
|
||||
|
||||
# List of patterns, relative to source directory, that match files and
|
||||
# directories to ignore when looking for source files.
|
||||
# This pattern also affects html_static_path and html_extra_path.
|
||||
exclude_patterns = []
|
||||
|
||||
|
||||
# -- Options for HTML output -------------------------------------------------
|
||||
|
||||
# The theme to use for HTML and HTML Help pages. See the documentation for
|
||||
# a list of builtin themes.
|
||||
#
|
||||
html_theme = "sphinx_rtd_theme"
|
||||
|
||||
html_theme_options = {"globaltoc_collapse": False}
|
||||
|
||||
# Add any paths that contain custom static files (such as style sheets) here,
|
||||
# relative to this directory. They are copied after the builtin static files,
|
||||
# so a file named "default.css" will overwrite the builtin "default.css".
|
||||
html_static_path = ["_static"]
|
||||
7
docs/source/contributing.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# Contributing to parsedmarc
|
||||
|
||||
## Bug reports
|
||||
|
||||
Please report bugs on the GitHub issue tracker
|
||||
|
||||
<https://github.com/domainaware/parsedmarc/issues>
|
||||
189
docs/source/davmail.md
Normal file
@@ -0,0 +1,189 @@
|
||||
# Accessing an inbox using OWA/EWS
|
||||
|
||||
:::{note}
|
||||
Starting in 8.0.0, parsedmarc supports accessing Microsoft/Office 365
|
||||
inboxes via the Microsoft Graph API, which is preferred over Davmail.
|
||||
:::
|
||||
|
||||
Some organizations do not allow IMAP or the Microsoft Graph API,
|
||||
and only support Exchange Web Services (EWS)/Outlook Web Access (OWA).
|
||||
In that case, Davmail will need to be set up
|
||||
as a local EWS/OWA IMAP gateway. It can even work where
|
||||
[Modern Auth/multi-factor authentication] is required.
|
||||
|
||||
To do this, download the latest `davmail-version.zip` from
|
||||
<https://sourceforge.net/projects/davmail/files/>
|
||||
|
||||
Extract the zip using the `unzip` command.
|
||||
|
||||
Install Java:
|
||||
|
||||
```bash
|
||||
sudo apt-get install default-jre-headless
|
||||
```
|
||||
|
||||
Configure Davmail by creating a `davmail.properties` file
|
||||
|
||||
```properties
|
||||
# DavMail settings, see http://davmail.sourceforge.net/ for documentation
|
||||
|
||||
#############################################################
|
||||
# Basic settings
|
||||
|
||||
# Server or workstation mode
|
||||
davmail.server=true
|
||||
|
||||
# connection mode auto, EWS or WebDav
|
||||
davmail.enableEws=auto
|
||||
|
||||
# base Exchange OWA or EWS url
|
||||
davmail.url=https://outlook.office365.com/EWS/Exchange.asmx
|
||||
|
||||
# Listener ports
|
||||
davmail.imapPort=1143
|
||||
|
||||
#############################################################
|
||||
# Network settings
|
||||
|
||||
# Network proxy settings
|
||||
davmail.enableProxy=false
|
||||
davmail.useSystemProxies=false
|
||||
davmail.proxyHost=
|
||||
davmail.proxyPort=
|
||||
davmail.proxyUser=
|
||||
davmail.proxyPassword=
|
||||
|
||||
# proxy exclude list
|
||||
davmail.noProxyFor=
|
||||
|
||||
# block remote connection to DavMail
|
||||
davmail.allowRemote=false
|
||||
|
||||
# bind server sockets to the loopback address
|
||||
davmail.bindAddress=127.0.0.1
|
||||
|
||||
# disable SSL for specified listeners
|
||||
davmail.ssl.nosecureimap=true
|
||||
|
||||
# Send keepalive character during large folder and messages download
|
||||
davmail.enableKeepalive=true
|
||||
|
||||
# Message count limit on folder retrieval
|
||||
davmail.folderSizeLimit=0
|
||||
|
||||
#############################################################
|
||||
# IMAP settings
|
||||
|
||||
# Delete messages immediately on IMAP STORE \Deleted flag
|
||||
davmail.imapAutoExpunge=true
|
||||
|
||||
# Enable IDLE support, set polling delay in minutes
|
||||
davmail.imapIdleDelay=1
|
||||
|
||||
# Always reply to IMAP RFC822.SIZE requests with Exchange approximate
|
||||
# message size for performance reasons
|
||||
davmail.imapAlwaysApproxMsgSize=true
|
||||
|
||||
# Client connection timeout in seconds - default 300, 0 to disable
|
||||
davmail.clientSoTimeout=0
|
||||
|
||||
#############################################################
|
||||
```
|
||||
|
||||
## Running DavMail as a systemd service
|
||||
|
||||
Use systemd to run `davmail` as a service.
|
||||
|
||||
Create a system user
|
||||
|
||||
```bash
|
||||
sudo useradd davmail -r -s /bin/false
|
||||
```
|
||||
|
||||
Protect the `davmail` configuration file from prying eyes
|
||||
|
||||
```bash
|
||||
sudo chown root:davmail /opt/davmail/davmail.properties
|
||||
sudo chmod u=rw,g=r,o= /opt/davmail/davmail.properties
|
||||
```
|
||||
|
||||
Create the service configuration file
|
||||
|
||||
```bash
|
||||
sudo nano /etc/systemd/system/davmail.service
|
||||
```
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=DavMail gateway service
|
||||
Documentation=https://sourceforge.net/projects/davmail/
|
||||
Wants=network-online.target
|
||||
After=syslog.target network.target
|
||||
|
||||
[Service]
|
||||
ExecStart=/opt/davmail/davmail /opt/davmail/davmail.properties
|
||||
User=davmail
|
||||
Group=davmail
|
||||
Restart=always
|
||||
RestartSec=5m
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
Then, enable the service
|
||||
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable parsedmarc.service
|
||||
sudo service davmail restart
|
||||
```
|
||||
|
||||
:::{note}
|
||||
You must also run the above commands whenever you edit
|
||||
`davmail.service`.
|
||||
:::
|
||||
|
||||
:::{warning}
|
||||
Always restart the service every time you upgrade to a new version of
|
||||
`davmail`:
|
||||
|
||||
```bash
|
||||
sudo service davmail restart
|
||||
```
|
||||
|
||||
:::
|
||||
|
||||
To check the status of the service, run:
|
||||
|
||||
```bash
|
||||
service davmail status
|
||||
```
|
||||
|
||||
:::{note}
|
||||
In the event of a crash, systemd will restart the service after 5
|
||||
minutes, but the `service davmail status` command will only show the
|
||||
logs for the current process. To vew the logs for previous runs as
|
||||
well as the current process (newest to oldest), run:
|
||||
|
||||
```bash
|
||||
journalctl -u davmail.service -r
|
||||
```
|
||||
|
||||
:::
|
||||
|
||||
## Configuring parsedmarc for DavMail
|
||||
|
||||
Because you are interacting with DavMail server over the loopback
|
||||
(i.e. `127.0.0.1`), add the following options to `parsedmarc.ini`
|
||||
config file:
|
||||
|
||||
```ini
|
||||
[imap]
|
||||
host=127.0.0.1
|
||||
port=1143
|
||||
ssl=False
|
||||
watch=True
|
||||
```
|
||||
|
||||
[modern auth/multi-factor authentication]: https://davmail.sourceforge.net/faq.html
|
||||
71
docs/source/dmarc.md
Normal file
@@ -0,0 +1,71 @@
|
||||
# Understanding DMARC
|
||||
|
||||
## Resources
|
||||
|
||||
### DMARC guides
|
||||
|
||||
- [Demystifying DMARC] - A complete guide to SPF, DKIM, and DMARC
|
||||
|
||||
[demystifying dmarc]: https://seanthegeek.net/459/demystifying-dmarc/
|
||||
|
||||
### SPF and DMARC record validation
|
||||
|
||||
If you are looking for SPF and DMARC record validation and parsing,
|
||||
check out the sister project,
|
||||
[checkdmarc](https://domainaware.github.io/checkdmarc/).
|
||||
|
||||
### Lookalike domains
|
||||
|
||||
DMARC protects against domain spoofing, not lookalike domains. for open source
|
||||
lookalike domain monitoring, check out [DomainAware](https://github.com/seanthegeek/domainaware).
|
||||
|
||||
## DMARC Alignment Guide
|
||||
|
||||
DMARC ensures that SPF and DKM authentication mechanisms actually authenticate
|
||||
against the same domain that the end user sees.
|
||||
|
||||
A message passes a DMARC check by passing DKIM or SPF, **as long as the related
|
||||
indicators are also in alignment**.
|
||||
|
||||
```{eval-rst}
|
||||
+-----------------------+-----------------------+-----------------------+
|
||||
| | **DKIM** | **SPF** |
|
||||
+-----------------------+-----------------------+-----------------------+
|
||||
| **Passing** | The signature in the | The mail server's IP |
|
||||
| | DKIM header is | address is listed in |
|
||||
| | validated using a | the SPF record of the |
|
||||
| | public key that is | domain in the SMTP |
|
||||
| | published as a DNS | envelope's mail from |
|
||||
| | record of the domain | header |
|
||||
| | name specified in the | |
|
||||
| | signature | |
|
||||
+-----------------------+-----------------------+-----------------------+
|
||||
| **Alignment** | The signing domain | The domain in the |
|
||||
| | aligns with the | SMTP envelope's mail |
|
||||
| | domain in the | from header aligns |
|
||||
| | message's from header | with the domain in |
|
||||
| | | the message's from |
|
||||
| | | header |
|
||||
+-----------------------+-----------------------+-----------------------+
|
||||
```
|
||||
|
||||
## What if a sender won't support DKIM/DMARC?
|
||||
|
||||
1. Some vendors don't know about DMARC yet; ask about SPF and DKIM/email
|
||||
authentication.
|
||||
2. Check if they can send through your email relays instead of theirs.
|
||||
3. Do they really need to spoof your domain? Why not use the display
|
||||
name instead?
|
||||
4. Worst case, have that vendor send email as a specific subdomain of
|
||||
your domain (e.g. `noreply@news.example.com`), and then create
|
||||
separate SPF and DMARC records on `news.example.com`, and set
|
||||
`p=none` in that DMARC record.
|
||||
|
||||
:::{warning}
|
||||
Do not alter the `p` or `sp` values of the DMARC record on the
|
||||
Top-Level Domain (TLD) – that would leave you vulnerable to
|
||||
spoofing of your TLD and/or any subdomain.
|
||||
:::
|
||||
|
||||
```{include} mailing-lists.md
|
||||
```
|
||||
236
docs/source/elasticsearch.md
Normal file
@@ -0,0 +1,236 @@
|
||||
# Elasticsearch and Kibana
|
||||
|
||||
To set up visual dashboards of DMARC data, install Elasticsearch and Kibana.
|
||||
|
||||
:::{note}
|
||||
Elasticsearch and Kibana 6 or later are required
|
||||
:::
|
||||
|
||||
## Installation
|
||||
|
||||
On Debian/Ubuntu based systems, run:
|
||||
|
||||
```bash
|
||||
sudo apt-get install -y apt-transport-https
|
||||
wget -qO - https://artifacts.elastic.co/GPG-KEY-elasticsearch | sudo gpg --dearmor -o /usr/share/keyrings/elasticsearch-keyring.gpg
|
||||
echo "deb [signed-by=/usr/share/keyrings/elasticsearch-keyring.gpg] https://artifacts.elastic.co/packages/8.x/apt stable main" | sudo tee /etc/apt/sources.list.d/elastic-8.x.list
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y elasticsearch kibana
|
||||
```
|
||||
|
||||
For CentOS, RHEL, and other RPM systems, follow the Elastic RPM guides for
|
||||
[Elasticsearch] and [Kibana].
|
||||
|
||||
:::{note}
|
||||
Previously, the default JVM heap size for Elasticsearch was very small (1g),
|
||||
which will cause it to crash under a heavy load. To fix this, increase the
|
||||
minimum and maximum JVM heap sizes in `/etc/elasticsearch/jvm.options` to
|
||||
more reasonable levels, depending on your server's resources.
|
||||
|
||||
Make sure the system has at least 2 GB more RAM than the assigned JVM
|
||||
heap size.
|
||||
|
||||
Always set the minimum and maximum JVM heap sizes to the same
|
||||
value.
|
||||
|
||||
For example, to set a 4 GB heap size, set
|
||||
|
||||
```bash
|
||||
-Xms4g
|
||||
-Xmx4g
|
||||
```
|
||||
|
||||
See <https://www.elastic.co/guide/en/elasticsearch/reference/current/important-settings.html#heap-size-settings>
|
||||
for more information.
|
||||
:::
|
||||
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable elasticsearch.service
|
||||
sudo systemctl enable kibana.service
|
||||
sudo systemctl start elasticsearch.service
|
||||
sudo systemctl start kibana.service
|
||||
```
|
||||
|
||||
As of Elasticsearch 8.7, activate secure mode (xpack.security.*.ssl)
|
||||
|
||||
```bash
|
||||
sudo vim /etc/elasticsearch/elasticsearch.yml
|
||||
```
|
||||
|
||||
Add the following configuration
|
||||
|
||||
```text
|
||||
# Enable security features
|
||||
xpack.security.enabled: true
|
||||
xpack.security.enrollment.enabled: true
|
||||
# Enable encryption for HTTP API client connections, such as Kibana, Logstash, and Agents
|
||||
xpack.security.http.ssl:
|
||||
enabled: true
|
||||
keystore.path: certs/http.p12
|
||||
# Enable encryption and mutual authentication between cluster nodes
|
||||
xpack.security.transport.ssl:
|
||||
enabled: true
|
||||
verification_mode: certificate
|
||||
keystore.path: certs/transport.p12
|
||||
truststore.path: certs/transport.p12
|
||||
```
|
||||
|
||||
```bash
|
||||
sudo systemctl restart elasticsearch
|
||||
```
|
||||
|
||||
To create a self-signed certificate, run:
|
||||
|
||||
```bash
|
||||
openssl req -x509 -nodes -days 365 -newkey rsa:4096 -keyout kibana.key -out kibana.crt
|
||||
```
|
||||
|
||||
Or, to create a Certificate Signing Request (CSR) for a CA, run:
|
||||
|
||||
```bash
|
||||
openssl req -newkey rsa:4096-nodes -keyout kibana.key -out kibana.csr
|
||||
```
|
||||
|
||||
Fill in the prompts. Watch out for Common Name (e.g. server FQDN or YOUR
|
||||
domain name), which is the IP address or domain name that you will use to access Kibana. it is the most important field.
|
||||
|
||||
If you generated a CSR, remove the CSR after you have your certs
|
||||
|
||||
```bash
|
||||
rm -f kibana.csr
|
||||
```
|
||||
|
||||
Move the keys into place and secure them:
|
||||
|
||||
```bash
|
||||
sudo mv kibana.* /etc/kibana
|
||||
sudo chmod 660 /etc/kibana/kibana.key
|
||||
```
|
||||
|
||||
Activate the HTTPS server in Kibana
|
||||
|
||||
```bash
|
||||
sudo vim /etc/kibana/kibana.yml
|
||||
```
|
||||
|
||||
Add the following configuration
|
||||
|
||||
```text
|
||||
server.host: "SERVER_IP"
|
||||
server.publicBaseUrl: "https://SERVER_IP"
|
||||
server.ssl.enabled: true
|
||||
server.ssl.certificate: /etc/kibana/kibana.crt
|
||||
server.ssl.key: /etc/kibana/kibana.key
|
||||
```
|
||||
|
||||
:::{note}
|
||||
For more security, you can configure Kibana to use a local network connexion
|
||||
to elasticsearch :
|
||||
```text
|
||||
elasticsearch.hosts: ['https://SERVER_IP:9200']
|
||||
```
|
||||
=>
|
||||
```text
|
||||
elasticsearch.hosts: ['https://127.0.0.1:9200']
|
||||
```
|
||||
:::
|
||||
|
||||
```bash
|
||||
sudo systemctl restart kibana
|
||||
```
|
||||
|
||||
Enroll Kibana in Elasticsearch
|
||||
|
||||
```bash
|
||||
sudo /usr/share/elasticsearch/bin/elasticsearch-create-enrollment-token -s kibana
|
||||
```
|
||||
|
||||
Then access to your web server at `https://SERVER_IP:5601`, accept the self-signed
|
||||
certificate and paste the token in the "Enrollment token" field.
|
||||
|
||||
```bash
|
||||
sudo /usr/share/kibana/bin/kibana-verification-code
|
||||
```
|
||||
|
||||
Then put the verification code to your web browser.
|
||||
|
||||
End Kibana configuration
|
||||
|
||||
```bash
|
||||
sudo /usr/share/elasticsearch/bin/elasticsearch-setup-passwords interactive
|
||||
sudo /usr/share/kibana/bin/kibana-encryption-keys generate
|
||||
sudo vim /etc/kibana/kibana.yml
|
||||
```
|
||||
|
||||
Add previously generated encryption keys
|
||||
|
||||
```text
|
||||
xpack.encryptedSavedObjects.encryptionKey: xxxx...xxxx
|
||||
xpack.reporting.encryptionKey: xxxx...xxxx
|
||||
xpack.security.encryptionKey: xxxx...xxxx
|
||||
```
|
||||
|
||||
```bash
|
||||
sudo systemctl restart kibana
|
||||
sudo systemctl restart elasticsearch
|
||||
```
|
||||
|
||||
Now that Elasticsearch is up and running, use `parsedmarc` to send data to
|
||||
it.
|
||||
|
||||
Download (right-click the link and click save as) [export.ndjson].
|
||||
|
||||
Connect to kibana using the "elastic" user and the password you previously provide
|
||||
on the console ("End Kibana configuration" part).
|
||||
|
||||
Import `export.ndjson` the Saved Objects tab of the Stack management
|
||||
page of Kibana. (Hamburger menu -> "Management" -> "Stack Management" ->
|
||||
"Kibana" -> "Saved Objects")
|
||||
|
||||
It will give you the option to overwrite existing saved dashboards or
|
||||
visualizations, which could be used to restore them if you or someone else
|
||||
breaks them, as there are no permissions/access controls in Kibana without
|
||||
the commercial [X-Pack].
|
||||
|
||||
```{image} _static/screenshots/saved-objects.png
|
||||
:align: center
|
||||
:alt: A screenshot of setting the Saved Objects Stack management UI in Kibana
|
||||
:target: _static/screenshots/saved-objects.png
|
||||
```
|
||||
|
||||
```{image} _static/screenshots/confirm-overwrite.png
|
||||
:align: center
|
||||
:alt: A screenshot of the overwrite conformation prompt
|
||||
:target: _static/screenshots/confirm-overwrite.png
|
||||
```
|
||||
|
||||
## Upgrading Kibana index patterns
|
||||
|
||||
`parsedmarc` 5.0.0 makes some changes to the way data is indexed in
|
||||
Elasticsearch. if you are upgrading from a previous release of
|
||||
`parsedmarc`, you need to complete the following steps to replace the
|
||||
Kibana index patterns with versions that match the upgraded indexes:
|
||||
|
||||
1. Login in to Kibana, and click on Management
|
||||
2. Under Kibana, click on Saved Objects
|
||||
3. Check the checkboxes for the `dmarc_aggregate` and `dmarc_forensic`
|
||||
index patterns
|
||||
4. Click Delete
|
||||
5. Click Delete on the conformation message
|
||||
6. Download (right-click the link and click save as)
|
||||
the latest version of [export.ndjson]
|
||||
7. Import `export.ndjson` by clicking Import from the Kibana
|
||||
Saved Objects page
|
||||
|
||||
## Records retention
|
||||
|
||||
Starting in version 5.0.0, `parsedmarc` stores data in a separate
|
||||
index for each day to make it easy to comply with records
|
||||
retention regulations such as GDPR. For more information,
|
||||
check out the Elastic guide to [managing time-based indexes efficiently](https://www.elastic.co/blog/managing-time-based-indices-efficiently).
|
||||
|
||||
[elasticsearch]: https://www.elastic.co/guide/en/elasticsearch/reference/current/rpm.html
|
||||
[export.ndjson]: https://raw.githubusercontent.com/domainaware/parsedmarc/master/kibana/export.ndjson
|
||||
[kibana]: https://www.elastic.co/guide/en/kibana/current/rpm.html
|
||||
[x-pack]: https://www.elastic.co/products/x-pack
|
||||
31
docs/source/example.ini
Normal file
@@ -0,0 +1,31 @@
|
||||
# This is an example comment
|
||||
|
||||
[general]
|
||||
save_aggregate = True
|
||||
save_forensic = True
|
||||
|
||||
[imap]
|
||||
host = imap.example.com
|
||||
user = dmarcresports@example.com
|
||||
password = $uperSecure
|
||||
watch = True
|
||||
|
||||
[elasticsearch]
|
||||
hosts = 127.0.0.1:9200
|
||||
ssl = False
|
||||
|
||||
[splunk_hec]
|
||||
url = https://splunkhec.example.com
|
||||
token = HECTokenGoesHere
|
||||
index = email
|
||||
|
||||
[s3]
|
||||
bucket = my-bucket
|
||||
path = parsedmarc
|
||||
|
||||
[gmail_api]
|
||||
credentials_file = /etc/example/credentials.json
|
||||
token_file = /etc/example/token.json
|
||||
include_spam_trash = True
|
||||
paginate_messages = True
|
||||
scopes = https://www.googleapis.com/auth/gmail.modify
|
||||
65
docs/source/index.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# parsedmarc documentation - Open source DMARC report analyzer and visualizer
|
||||
|
||||
[](https://github.com/domainaware/parsedmarc/actions/workflows/python-tests.yml)
|
||||
[](https://codecov.io/gh/domainaware/parsedmarc)
|
||||
[](https://pypi.org/project/parsedmarc/)
|
||||
[](https://pypistats.org/packages/parsedmarc)
|
||||
|
||||
:::{note}
|
||||
**Help Wanted**
|
||||
|
||||
This is a project is maintained by one developer.
|
||||
Please consider reviewing the open [issues] to see how you can contribute code, documentation, or user support.
|
||||
Assistance on the pinned issues would be particularly helpful.
|
||||
|
||||
Thanks to all [contributors]!
|
||||
:::
|
||||
|
||||
```{image} _static/screenshots/dmarc-summary-charts.png
|
||||
:align: center
|
||||
:alt: A screenshot of DMARC summary charts in Kibana
|
||||
:scale: 50 %
|
||||
:target: _static/screenshots/dmarc-summary-charts.png
|
||||
```
|
||||
|
||||
`parsedmarc` is a Python module and CLI utility for parsing DMARC reports.
|
||||
When used with Elasticsearch and Kibana (or Splunk), or with OpenSearch and Grafana, it works as a self-hosted
|
||||
open source alternative to commercial DMARC report processing services such
|
||||
as Agari Brand Protection, Dmarcian, OnDMARC, ProofPoint Email Fraud Defense,
|
||||
and Valimail.
|
||||
|
||||
## Features
|
||||
|
||||
- Parses draft and 1.0 standard aggregate/rua reports
|
||||
- Parses forensic/failure/ruf reports
|
||||
- Can parse reports from an inbox over IMAP, Microsoft Graph, or Gmail API
|
||||
- Transparently handles gzip or zip compressed reports
|
||||
- Consistent data structures
|
||||
- Simple JSON and/or CSV output
|
||||
- Optionally email the results
|
||||
- Optionally send the results to Elasticsearch/OpenSearch and/or Splunk, for use with
|
||||
premade dashboards
|
||||
- Optionally send reports to Apache Kafka
|
||||
|
||||
```{toctree}
|
||||
:caption: 'Contents'
|
||||
:maxdepth: 2
|
||||
|
||||
installation
|
||||
usage
|
||||
output
|
||||
elasticsearch
|
||||
opensearch
|
||||
kibana
|
||||
splunk
|
||||
davmail
|
||||
dmarc
|
||||
contributing
|
||||
api
|
||||
```
|
||||
|
||||
[contributors]: https://github.com/domainaware/parsedmarc/graphs/contributors
|
||||
[issues]: https://github.com/domainaware/parsedmarc/issues
|
||||
205
docs/source/installation.md
Normal file
@@ -0,0 +1,205 @@
|
||||
# Installation
|
||||
|
||||
## Prerequisites
|
||||
|
||||
`parsedmarc` works with Python 3 only.
|
||||
|
||||
### Testing multiple report analyzers
|
||||
|
||||
If you would like to test parsedmarc and another report processing
|
||||
solution at the same time, you can have up to two `mailto` URIs in each of the rua and ruf
|
||||
tags in your DMARC record, separated by commas.
|
||||
|
||||
### Using a web proxy
|
||||
|
||||
If your system is behind a web proxy, you need to configure your system
|
||||
to use that proxy. To do this, edit `/etc/environment` and add your
|
||||
proxy details there, for example:
|
||||
|
||||
```bash
|
||||
http_proxy=http://user:password@prox-server:3128
|
||||
https_proxy=https://user:password@prox-server:3128
|
||||
ftp_proxy=http://user:password@prox-server:3128
|
||||
```
|
||||
|
||||
Or if no credentials are needed:
|
||||
|
||||
```bash
|
||||
http_proxy=http://prox-server:3128
|
||||
https_proxy=https://prox-server:3128
|
||||
ftp_proxy=http://prox-server:3128
|
||||
```
|
||||
|
||||
This will set the proxy up for use system-wide, including for `parsedmarc`.
|
||||
|
||||
### Using Microsoft Exchange
|
||||
|
||||
If your mail server is Microsoft Exchange, ensure that it is patched to at
|
||||
least:
|
||||
|
||||
- Exchange Server 2010 Update Rollup 22 ([KB4295699])
|
||||
- Exchange Server 2013 Cumulative Update 21 ([KB4099855])
|
||||
- Exchange Server 2016 Cumulative Update 11 ([KB4134118])
|
||||
|
||||
### geoipupdate setup
|
||||
|
||||
:::{note}
|
||||
Starting in `parsedmarc` 7.1.0, a static copy of the
|
||||
[IP to Country Lite database] from IPDB is distributed with
|
||||
`parsedmarc`, under the terms of the
|
||||
[Creative Commons Attribution 4.0 International License].
|
||||
as a fallback if the [MaxMind GeoLite2 Country database] is not
|
||||
installed. However, `parsedmarc` cannot install updated versions of
|
||||
these databases as they are released, so MaxMind's databases and the
|
||||
[geoipupdate] tool is still the preferable solution.
|
||||
|
||||
The location of the database file can be overridden by using the
|
||||
`ip_db_path` setting.
|
||||
:::
|
||||
|
||||
On Debian 10 (Buster) or later, run:
|
||||
|
||||
```bash
|
||||
sudo apt-get install -y geoipupdate
|
||||
```
|
||||
|
||||
:::{note}
|
||||
[Component "contrib"] is required in your apt sources.
|
||||
:::
|
||||
|
||||
On Ubuntu systems run:
|
||||
|
||||
```bash
|
||||
sudo add-apt-repository ppa:maxmind/ppa
|
||||
sudo apt update
|
||||
sudo apt install -y geoipupdate
|
||||
```
|
||||
|
||||
On CentOS or RHEL systems, run:
|
||||
|
||||
```bash
|
||||
sudo dnf install -y geoipupdate
|
||||
```
|
||||
|
||||
The latest builds for Linux, macOS, and Windows can be downloaded
|
||||
from the [geoipupdate releases page on GitHub].
|
||||
|
||||
On December 30th, 2019, MaxMind started requiring free accounts to
|
||||
access the free Geolite2 databases, in order
|
||||
[to comply with various privacy regulations].
|
||||
|
||||
Start by [registering for a free GeoLite2 account], and signing in.
|
||||
|
||||
Then, navigate to the [License Keys] page under your account,
|
||||
and create a new license key for the version of
|
||||
`geoipupdate` that was installed.
|
||||
|
||||
:::{warning}
|
||||
The configuration file format is different for older (i.e. \<=3.1.1) and newer (i.e. >=3.1.1) versions
|
||||
of `geoipupdate`. Be sure to select the correct version for your system.
|
||||
:::
|
||||
|
||||
:::{note}
|
||||
To check the version of `geoipupdate` that is installed, run:
|
||||
|
||||
```bash
|
||||
geoipupdate -V
|
||||
```
|
||||
|
||||
:::
|
||||
|
||||
You can use `parsedmarc` as the description for the key.
|
||||
|
||||
Once you have generated a key, download the config pre-filled
|
||||
configuration file. This file should be saved at `/etc/GeoIP.conf`
|
||||
on Linux or macOS systems, or at
|
||||
`%SystemDrive%\ProgramData\MaxMind\GeoIPUpdate\GeoIP.conf` on
|
||||
Windows systems.
|
||||
|
||||
Then run
|
||||
|
||||
```bash
|
||||
sudo geoipupdate
|
||||
```
|
||||
|
||||
To download the databases for the first time.
|
||||
|
||||
The GeoLite2 Country, City, and ASN databases are updated weekly,
|
||||
every Tuesday. `geoipupdate` can be run weekly by adding a cron
|
||||
job or scheduled task.
|
||||
|
||||
More information about `geoipupdate` can be found at the
|
||||
[MaxMind geoipupdate page].
|
||||
|
||||
## Installing parsedmarc
|
||||
|
||||
On Debian or Ubuntu systems, run:
|
||||
|
||||
```bash
|
||||
sudo apt-get install -y python3-pip python3-virtualenv python3-dev libxml2-dev libxslt-dev
|
||||
```
|
||||
|
||||
On CentOS or RHEL systems, run:
|
||||
|
||||
```bash
|
||||
sudo dnf install -y python39 python3-virtualenv python3-setuptools python3-devel libxml2-devel libxslt-devel
|
||||
```
|
||||
|
||||
Python 3 installers for Windows and macOS can be found at
|
||||
<https://www.python.org/downloads/>.
|
||||
|
||||
Create a system user
|
||||
|
||||
```bash
|
||||
sudo mkdir /opt
|
||||
sudo useradd parsedmarc -r -s /bin/false -m -b /opt
|
||||
```
|
||||
|
||||
Install parsedmarc in a virtualenv
|
||||
|
||||
```bash
|
||||
sudo -u parsedmarc virtualenv /opt/parsedmarc/venv
|
||||
```
|
||||
|
||||
CentOS/RHEL 8 systems use Python 3.6 by default, so on those systems
|
||||
explicitly tell `virtualenv` to use `python3.9` instead
|
||||
|
||||
```bash
|
||||
sudo -u parsedmarc virtualenv -p python3.9 /opt/parsedmarc/venv
|
||||
```
|
||||
|
||||
Activate the virtualenv
|
||||
|
||||
```bash
|
||||
source /opt/parsedmarc/venv/bin/activate
|
||||
```
|
||||
|
||||
To install or upgrade `parsedmarc` inside the virtualenv, run:
|
||||
|
||||
```bash
|
||||
sudo -u parsedmarc /opt/parsedmarc/venv/bin/pip install -U parsedmarc
|
||||
```
|
||||
|
||||
## Optional dependencies
|
||||
|
||||
If you would like to be able to parse emails saved from Microsoft
|
||||
Outlook (i.e. OLE .msg files), install `msgconvert`:
|
||||
|
||||
On Debian or Ubuntu systems, run:
|
||||
|
||||
```bash
|
||||
sudo apt-get install libemail-outlook-message-perl
|
||||
```
|
||||
|
||||
[KB4295699]: https://support.microsoft.com/KB/4295699
|
||||
[KB4099855]: https://support.microsoft.com/KB/4099855
|
||||
[KB4134118]: https://support.microsoft.com/kb/4134118
|
||||
[Component "contrib"]: https://wiki.debian.org/SourcesList#Component
|
||||
[geoipupdate]: https://github.com/maxmind/geoipupdate
|
||||
[geoipupdate releases page on github]: https://github.com/maxmind/geoipupdate/releases
|
||||
[ip to country lite database]: https://db-ip.com/db/download/ip-to-country-lite
|
||||
[license keys]: https://www.maxmind.com/en/accounts/current/license-key
|
||||
[maxmind geoipupdate page]: https://dev.maxmind.com/geoip/geoipupdate/
|
||||
[maxmind geolite2 country database]: https://dev.maxmind.com/geoip/geolite2-free-geolocation-data
|
||||
[registering for a free geolite2 account]: https://www.maxmind.com/en/geolite2/signup
|
||||
[to comply with various privacy regulations]: https://blog.maxmind.com/2019/12/18/significant-changes-to-accessing-and-using-geolite2-databases/
|
||||
87
docs/source/kibana.md
Normal file
@@ -0,0 +1,87 @@
|
||||
|
||||
# Using the Kibana dashboards
|
||||
|
||||
The Kibana DMARC dashboards are a human-friendly way to understand the
|
||||
results from incoming DMARC reports.
|
||||
|
||||
:::{note}
|
||||
The default dashboard is DMARC Summary. To switch between dashboards,
|
||||
click on the Dashboard link on the left side menu of Kibana.
|
||||
:::
|
||||
|
||||
## DMARC Summary
|
||||
|
||||
As the name suggests, this dashboard is the best place to start
|
||||
reviewing your aggregate DMARC data.
|
||||
|
||||
Across the top of the dashboard, three pie charts display the percentage of
|
||||
alignment pass/fail for SPF, DKIM, and DMARC. Clicking on any chart segment
|
||||
will filter for that value.
|
||||
|
||||
:::{note}
|
||||
Messages should not be considered malicious just because they failed to pass
|
||||
DMARC; especially if you have just started collecting data. It may be a
|
||||
legitimate service that needs SPF and DKIM configured correctly.
|
||||
:::
|
||||
|
||||
Start by filtering the results to only show failed DKIM alignment. While DMARC
|
||||
passes if a message passes SPF or DKIM alignment, only DKIM alignment remains
|
||||
valid when a message is forwarded without changing the from address, which is
|
||||
often caused by a mailbox forwarding rule. This is because DKIM signatures are
|
||||
part of the message headers, whereas SPF relies on SMTP session headers.
|
||||
|
||||
Underneath the pie charts. you can see graphs of DMARC passage and message
|
||||
disposition over time.
|
||||
|
||||
Under the graphs you will find the most useful data tables on the dashboard. On
|
||||
the left, there is a list of organizations that are sending you DMARC reports.
|
||||
In the center, there is a list of sending servers grouped by the base domain
|
||||
in their reverse DNS. On the right, there is a list of email from domains,
|
||||
sorted by message volume.
|
||||
|
||||
By hovering your mouse over a data table value and using the magnifying glass
|
||||
icons, you can filter on our filter out different values. Start by looking at
|
||||
the Message Sources by Reverse DNS table. Find a sender that you recognize,
|
||||
such as an email marketing service, hover over it, and click on the plus (+)
|
||||
magnifying glass icon, to add a filter that only shows results for that sender.
|
||||
Now, look at the Message From Header table to the right. That shows you the
|
||||
domains that a sender is sending as, which might tell you which brand/business
|
||||
is using a particular service. With that information, you can contact them and
|
||||
have them set up DKIM.
|
||||
|
||||
:::{note}
|
||||
If you have a lot of B2C customers, you may see a high volume of emails as
|
||||
your domains coming from consumer email services, such as Google/Gmail and
|
||||
Yahoo! This occurs when customers have mailbox rules in place that forward
|
||||
emails from an old account to a new account, which is why DKIM
|
||||
authentication is so important, as mentioned earlier. Similar patterns may
|
||||
be observed with businesses who send from reverse DNS addressees of
|
||||
parent, subsidiary, and outdated brands.
|
||||
:::
|
||||
|
||||
Further down the dashboard, you can filter by source country or source IP
|
||||
address.
|
||||
|
||||
Tables showing SPF and DKIM alignment details are located under the IP address
|
||||
table.
|
||||
|
||||
:::{note}
|
||||
Previously, the alignment tables were included in a separate dashboard
|
||||
called DMARC Alignment Failures. That dashboard has been consolidated into
|
||||
the DMARC Summary dashboard. To view failures only, use the pie chart.
|
||||
:::
|
||||
|
||||
Any other filters work the same way. You can also add your own custom temporary
|
||||
filters by clicking on Add Filter at the upper right of the page.
|
||||
|
||||
## DMARC Forensic Samples
|
||||
|
||||
The DMARC Forensic Samples dashboard contains information on DMARC forensic
|
||||
reports (also known as failure reports or ruf reports). These reports contain
|
||||
samples of emails that have failed to pass DMARC.
|
||||
|
||||
:::{note}
|
||||
Most recipients do not send forensic/failure/ruf reports at all to avoid
|
||||
privacy leaks. Some recipients (notably Chinese webmail services) will only
|
||||
supply the headers of sample emails. Very few provide the entire email.
|
||||
:::
|
||||
206
docs/source/mailing-lists.md
Normal file
@@ -0,0 +1,206 @@
|
||||
## What about mailing lists?
|
||||
|
||||
When you deploy DMARC on your domain, you might find that messages
|
||||
relayed by mailing lists are failing DMARC, most likely because the mailing
|
||||
list is spoofing your from address, and modifying the subject,
|
||||
footer, or other part of the message, thereby breaking the
|
||||
DKIM signature.
|
||||
|
||||
### Mailing list best practices
|
||||
|
||||
Ideally, a mailing list should forward messages without altering the
|
||||
headers or body content at all. [Joe Nelson] does a fantastic job of
|
||||
explaining exactly what mailing lists should and shouldn't do to be
|
||||
fully DMARC compliant. Rather than repeat his fine work, here's a
|
||||
summary:
|
||||
|
||||
#### Do
|
||||
|
||||
- Retain headers from the original message
|
||||
|
||||
- Add [RFC 2369] List-Unsubscribe headers to outgoing messages, instead of
|
||||
adding unsubscribe links to the body
|
||||
|
||||
> List-Unsubscribe: <https://list.example.com/unsubscribe-link>
|
||||
|
||||
- Add [RFC 2919] List-Id headers instead of modifying the subject
|
||||
|
||||
> List-Id: Example Mailing List <list.example.com>
|
||||
|
||||
Modern mail clients and webmail services generate unsubscribe buttons based on
|
||||
these headers.
|
||||
|
||||
#### Do not
|
||||
|
||||
- Remove or modify any existing headers from the original message, including
|
||||
From, Date, Subject, etc.
|
||||
- Add to or remove content from the message body, **including traditional
|
||||
disclaimers and unsubscribe footers**
|
||||
|
||||
In addition to complying with DMARC, this configuration ensures that Reply
|
||||
and Reply All actions work like they would with any email message. Reply
|
||||
replies to the message sender, and Reply All replies to the sender and the
|
||||
list.
|
||||
|
||||
Even without a subject prefix or body footer, mailing list users can still
|
||||
tell that a message came from the mailing list, because the message was sent
|
||||
to the mailing list post address, and not their email address.
|
||||
|
||||
Configuration steps for common mailing list platforms are listed below.
|
||||
|
||||
#### Mailman 2
|
||||
|
||||
Navigate to General Settings, and configure the settings below
|
||||
|
||||
```{eval-rst}
|
||||
============================ ==========
|
||||
**Setting** **Value**
|
||||
**subject_prefix**
|
||||
**from_is_list** No
|
||||
**first_strip_reply_to** No
|
||||
**reply_goes_to_list** Poster
|
||||
**include_rfc2369_headers** Yes
|
||||
**include_list_post_header** Yes
|
||||
**include_sender_header** No
|
||||
============================ ==========
|
||||
```
|
||||
|
||||
Navigate to Non-digest options, and configure the settings below
|
||||
|
||||
```{eval-rst}
|
||||
=================== ==========
|
||||
**Setting** **Value**
|
||||
**msg_header**
|
||||
**msg_footer**
|
||||
**scrub_nondigest** No
|
||||
=================== ==========
|
||||
```
|
||||
|
||||
Navigate to Privacy Options> Sending Filters, and configure the settings below
|
||||
|
||||
```{eval-rst}
|
||||
====================================== ==========
|
||||
**Setting** **Value**
|
||||
**dmarc_moderation_action** Accept
|
||||
**dmarc_quarantine_moderation_action** Yes
|
||||
**dmarc_none_moderation_action** Yes
|
||||
====================================== ==========
|
||||
```
|
||||
|
||||
#### Mailman 3
|
||||
|
||||
Navigate to Settings> List Identity
|
||||
|
||||
Make Subject prefix blank.
|
||||
|
||||
Navigate to Settings> Alter Messages
|
||||
|
||||
Configure the settings below
|
||||
|
||||
```{eval-rst}
|
||||
====================================== ==========
|
||||
**Setting** **Value**
|
||||
**Convert html to plaintext** No
|
||||
**Include RFC2369 headers** Yes
|
||||
**Include the list post header** Yes
|
||||
**Explicit reply-to address**
|
||||
**First strip replyto** No
|
||||
**Reply goes to list** No munging
|
||||
====================================== ==========
|
||||
```
|
||||
|
||||
Navigate to Settings> DMARC Mitigation
|
||||
|
||||
Configure the settings below
|
||||
|
||||
```{eval-rst}
|
||||
================================== ===============================
|
||||
**Setting** **Value**
|
||||
**DMARC mitigation action** No DMARC mitigations
|
||||
**DMARC mitigate unconditionally** No
|
||||
================================== ===============================
|
||||
```
|
||||
|
||||
Create a blank footer template for your mailing list to remove the message
|
||||
footer. Unfortunately, the Postorius mailing list admin UI will not allow you
|
||||
to create an empty template, so you'll have to create one using the system's
|
||||
command line instead, for example:
|
||||
|
||||
```bash
|
||||
touch var/templates/lists/list.example.com/en/list:member:regular:footer
|
||||
```
|
||||
|
||||
Where `list.example.com` the list ID, and `en` is the language.
|
||||
|
||||
Then restart mailman core.
|
||||
|
||||
#### LISTSERV
|
||||
|
||||
[LISTSERV 16.0-2017a] and higher will rewrite the From header for domains
|
||||
that enforce with a DMARC quarantine or reject policy.
|
||||
|
||||
Some additional steps are needed for Linux hosts.
|
||||
|
||||
#### Workarounds
|
||||
|
||||
If a mailing list must go **against** best practices and
|
||||
modify the message (e.g. to add a required legal footer), the mailing
|
||||
list administrator must configure the list to replace the From address of the
|
||||
message (also known as munging) with the address of the mailing list, so they
|
||||
no longer spoof email addresses with domains protected by DMARC.
|
||||
|
||||
Configuration steps for common mailing list platforms are listed below.
|
||||
|
||||
##### Mailman 2
|
||||
|
||||
Navigate to Privacy Options> Sending Filters, and configure the settings below
|
||||
|
||||
```{eval-rst}
|
||||
====================================== ==========
|
||||
**Setting** **Value**
|
||||
**dmarc_moderation_action** Munge From
|
||||
**dmarc_quarantine_moderation_action** Yes
|
||||
**dmarc_none_moderation_action** Yes
|
||||
====================================== ==========
|
||||
```
|
||||
|
||||
:::{note}
|
||||
Message wrapping could be used as the DMARC mitigation action instead. In
|
||||
that case, the original message is added as an attachment to the mailing
|
||||
list message, but that could interfere with inbox searching, or mobile
|
||||
clients.
|
||||
|
||||
On the other hand, replacing the From address might cause users to
|
||||
accidentally reply to the entire list, when they only intended to reply to
|
||||
the original sender.
|
||||
|
||||
Choose the option that best fits your community.
|
||||
:::
|
||||
|
||||
##### Mailman 3
|
||||
|
||||
In the DMARC Mitigations tab of the Settings page, configure the settings below
|
||||
|
||||
```{eval-rst}
|
||||
================================== ===============================
|
||||
**Setting** **Value**
|
||||
**DMARC mitigation action** Replace From: with list address
|
||||
**DMARC mitigate unconditionally** No
|
||||
================================== ===============================
|
||||
```
|
||||
|
||||
:::{note}
|
||||
Message wrapping could be used as the DMARC mitigation action instead. In
|
||||
that case, the original message is added as an attachment to the mailing
|
||||
list message, but that could interfere with inbox searching, or mobile
|
||||
clients.
|
||||
|
||||
On the other hand, replacing the From address might cause users to
|
||||
accidentally reply to the entire list, when they only intended to reply to
|
||||
the original sender.
|
||||
:::
|
||||
|
||||
[joe nelson]: https://begriffs.com/posts/2018-09-18-dmarc-mailing-list.html
|
||||
[listserv 16.0-2017a]: https://www.lsoft.com/news/dmarc-issue1-2018.asp
|
||||
[rfc 2369]: https://tools.ietf.org/html/rfc2369
|
||||
[rfc 2919]: https://tools.ietf.org/html/rfc2919
|
||||
14
docs/source/opensearch.md
Normal file
@@ -0,0 +1,14 @@
|
||||
# OpenSearch and Grafana
|
||||
|
||||
To set up visual dashboards of DMARC data, install OpenSearch and Grafana.
|
||||
|
||||
## Installation
|
||||
|
||||
OpenSearch: https://opensearch.org/docs/latest/install-and-configure/install-opensearch/index/
|
||||
Grafana: https://grafana.com/docs/grafana/latest/setup-grafana/installation/
|
||||
|
||||
## Records retention
|
||||
|
||||
Starting in version 5.0.0, `parsedmarc` stores data in a separate
|
||||
index for each day to make it easy to comply with records
|
||||
retention regulations such as GDPR.
|
||||
232
docs/source/output.md
Normal file
@@ -0,0 +1,232 @@
|
||||
# Sample outputs
|
||||
|
||||
## Sample aggregate report output
|
||||
|
||||
Here are the results from parsing the [example](https://dmarc.org/wiki/FAQ#I_need_to_implement_aggregate_reports.2C_what_do_they_look_like.3F)
|
||||
report from the dmarc.org wiki. It's actually an older draft of
|
||||
the 1.0 report schema standardized in
|
||||
[RFC 7480 Appendix C](https://tools.ietf.org/html/rfc7489#appendix-C).
|
||||
This draft schema is still in wide use.
|
||||
|
||||
`parsedmarc` produces consistent, normalized output, regardless
|
||||
of the report schema.
|
||||
|
||||
### JSON aggregate report
|
||||
|
||||
```json
|
||||
{
|
||||
"xml_schema": "draft",
|
||||
"report_metadata": {
|
||||
"org_name": "acme.com",
|
||||
"org_email": "noreply-dmarc-support@acme.com",
|
||||
"org_extra_contact_info": "http://acme.com/dmarc/support",
|
||||
"report_id": "9391651994964116463",
|
||||
"begin_date": "2012-04-27 20:00:00",
|
||||
"end_date": "2012-04-28 19:59:59",
|
||||
"errors": []
|
||||
},
|
||||
"policy_published": {
|
||||
"domain": "example.com",
|
||||
"adkim": "r",
|
||||
"aspf": "r",
|
||||
"p": "none",
|
||||
"sp": "none",
|
||||
"pct": "100",
|
||||
"fo": "0"
|
||||
},
|
||||
"records": [
|
||||
{
|
||||
"source": {
|
||||
"ip_address": "72.150.241.94",
|
||||
"country": "US",
|
||||
"reverse_dns": "adsl-72-150-241-94.shv.bellsouth.net",
|
||||
"base_domain": "bellsouth.net"
|
||||
},
|
||||
"count": 2,
|
||||
"alignment": {
|
||||
"spf": true,
|
||||
"dkim": false,
|
||||
"dmarc": true
|
||||
},
|
||||
"policy_evaluated": {
|
||||
"disposition": "none",
|
||||
"dkim": "fail",
|
||||
"spf": "pass",
|
||||
"policy_override_reasons": []
|
||||
},
|
||||
"identifiers": {
|
||||
"header_from": "example.com",
|
||||
"envelope_from": "example.com",
|
||||
"envelope_to": null
|
||||
},
|
||||
"auth_results": {
|
||||
"dkim": [
|
||||
{
|
||||
"domain": "example.com",
|
||||
"selector": "none",
|
||||
"result": "fail"
|
||||
}
|
||||
],
|
||||
"spf": [
|
||||
{
|
||||
"domain": "example.com",
|
||||
"scope": "mfrom",
|
||||
"result": "pass"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### CSV aggregate report
|
||||
|
||||
```text
|
||||
xml_schema,org_name,org_email,org_extra_contact_info,report_id,begin_date,end_date,errors,domain,adkim,aspf,p,sp,pct,fo,source_ip_address,source_country,source_reverse_dns,source_base_domain,count,spf_aligned,dkim_aligned,dmarc_aligned,disposition,policy_override_reasons,policy_override_comments,envelope_from,header_from,envelope_to,dkim_domains,dkim_selectors,dkim_results,spf_domains,spf_scopes,spf_results
|
||||
draft,acme.com,noreply-dmarc-support@acme.com,http://acme.com/dmarc/support,9391651994964116463,2012-04-27 20:00:00,2012-04-28 19:59:59,,example.com,r,r,none,none,100,0,72.150.241.94,US,adsl-72-150-241-94.shv.bellsouth.net,bellsouth.net,2,True,False,True,none,,,example.com,example.com,,example.com,none,fail,example.com,mfrom,pass
|
||||
```
|
||||
|
||||
## Sample forensic report output
|
||||
|
||||
Thanks to GitHub user [xennn](https://github.com/xennn) for the anonymized
|
||||
[forensic report email sample](<https://github.com/domainaware/parsedmarc/raw/master/samples/forensic/DMARC%20Failure%20Report%20for%20domain.de%20(mail-from%3Dsharepoint%40domain.de%2C%20ip%3D10.10.10.10).eml>).
|
||||
|
||||
### JSON forensic report
|
||||
|
||||
```json
|
||||
{
|
||||
"feedback_type": "auth-failure",
|
||||
"user_agent": "Lua/1.0",
|
||||
"version": "1.0",
|
||||
"original_mail_from": "sharepoint@domain.de",
|
||||
"original_rcpt_to": "peter.pan@domain.de",
|
||||
"arrival_date": "Mon, 01 Oct 2018 11:20:27 +0200",
|
||||
"message_id": "<38.E7.30937.BD6E1BB5@ mailrelay.de>",
|
||||
"authentication_results": "dmarc=fail (p=none, dis=none) header.from=domain.de",
|
||||
"delivery_result": "policy",
|
||||
"auth_failure": [
|
||||
"dmarc"
|
||||
],
|
||||
"reported_domain": "domain.de",
|
||||
"arrival_date_utc": "2018-10-01 09:20:27",
|
||||
"source": {
|
||||
"ip_address": "10.10.10.10",
|
||||
"country": null,
|
||||
"reverse_dns": null,
|
||||
"base_domain": null
|
||||
},
|
||||
"authentication_mechanisms": [],
|
||||
"original_envelope_id": null,
|
||||
"dkim_domain": null,
|
||||
"sample_headers_only": false,
|
||||
"sample": "Received: from Servernameone.domain.local (Servernameone.domain.local [10.10.10.10])\n\tby mailrelay.de (mail.DOMAIN.de) with SMTP id 38.E7.30937.BD6E1BB5; Mon, 1 Oct 2018 11:20:27 +0200 (CEST)\nDate: 01 Oct 2018 11:20:27 +0200\nMessage-ID: <38.E7.30937.BD6E1BB5@ mailrelay.de>\nTo: <peter.pan@domain.de>\nfrom: \"=?utf-8?B?SW50ZXJha3RpdmUgV2V0dGJld2VyYmVyLcOcYmVyc2ljaHQ=?=\" <sharepoint@domain.de>\nSubject: Subject\nMIME-Version: 1.0\nX-Mailer: Microsoft SharePoint Foundation 2010\nContent-Type: text/html; charset=utf-8\nContent-Transfer-Encoding: quoted-printable\n\n<html><head><base href=3D'\nwettbewerb' /></head><body><!DOCTYPE HTML PUBLIC \"-//W3C//DTD HTML 3.2//EN\"=\n><HTML><HEAD><META NAME=3D\"Generator\" CONTENT=3D\"MS Exchange Server version=\n 08.01.0240.003\"></html>\n",
|
||||
"parsed_sample": {
|
||||
"from": {
|
||||
"display_name": "Interaktive Wettbewerber-Übersicht",
|
||||
"address": "sharepoint@domain.de",
|
||||
"local": "sharepoint",
|
||||
"domain": "domain.de"
|
||||
},
|
||||
"to_domains": [
|
||||
"domain.de"
|
||||
],
|
||||
"to": [
|
||||
{
|
||||
"display_name": null,
|
||||
"address": "peter.pan@domain.de",
|
||||
"local": "peter.pan",
|
||||
"domain": "domain.de"
|
||||
}
|
||||
],
|
||||
"subject": "Subject",
|
||||
"timezone": "+2",
|
||||
"mime-version": "1.0",
|
||||
"date": "2018-10-01 09:20:27",
|
||||
"content-type": "text/html; charset=utf-8",
|
||||
"x-mailer": "Microsoft SharePoint Foundation 2010",
|
||||
"body": "<html><head><base href='\nwettbewerb' /></head><body><!DOCTYPE HTML PUBLIC \"-//W3C//DTD HTML 3.2//EN\"><HTML><HEAD><META NAME=\"Generator\" CONTENT=\"MS Exchange Server version 08.01.0240.003\"></html>",
|
||||
"received": [
|
||||
{
|
||||
"from": "Servernameone.domain.local Servernameone.domain.local 10.10.10.10",
|
||||
"by": "mailrelay.de mail.DOMAIN.de",
|
||||
"with": "SMTP id 38.E7.30937.BD6E1BB5",
|
||||
"date": "Mon, 1 Oct 2018 11:20:27 +0200 CEST",
|
||||
"hop": 1,
|
||||
"date_utc": "2018-10-01 09:20:27",
|
||||
"delay": 0
|
||||
}
|
||||
],
|
||||
"content-transfer-encoding": "quoted-printable",
|
||||
"message-id": "<38.E7.30937.BD6E1BB5@ mailrelay.de>",
|
||||
"has_defects": false,
|
||||
"headers": {
|
||||
"Received": "from Servernameone.domain.local (Servernameone.domain.local [10.10.10.10])\n\tby mailrelay.de (mail.DOMAIN.de) with SMTP id 38.E7.30937.BD6E1BB5; Mon, 1 Oct 2018 11:20:27 +0200 (CEST)",
|
||||
"Date": "01 Oct 2018 11:20:27 +0200",
|
||||
"Message-ID": "<38.E7.30937.BD6E1BB5@ mailrelay.de>",
|
||||
"To": "<peter.pan@domain.de>",
|
||||
"from": "\"Interaktive Wettbewerber-Übersicht\" <sharepoint@domain.de>",
|
||||
"Subject": "Subject",
|
||||
"MIME-Version": "1.0",
|
||||
"X-Mailer": "Microsoft SharePoint Foundation 2010",
|
||||
"Content-Type": "text/html; charset=utf-8",
|
||||
"Content-Transfer-Encoding": "quoted-printable"
|
||||
},
|
||||
"reply_to": [],
|
||||
"cc": [],
|
||||
"bcc": [],
|
||||
"attachments": [],
|
||||
"filename_safe_subject": "Subject"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### CSV forensic report
|
||||
|
||||
```text
|
||||
feedback_type,user_agent,version,original_envelope_id,original_mail_from,original_rcpt_to,arrival_date,arrival_date_utc,subject,message_id,authentication_results,dkim_domain,source_ip_address,source_country,source_reverse_dns,source_base_domain,delivery_result,auth_failure,reported_domain,authentication_mechanisms,sample_headers_only
|
||||
auth-failure,Lua/1.0,1.0,,sharepoint@domain.de,peter.pan@domain.de,"Mon, 01 Oct 2018 11:20:27 +0200",2018-10-01 09:20:27,Subject,<38.E7.30937.BD6E1BB5@ mailrelay.de>,"dmarc=fail (p=none, dis=none) header.from=domain.de",,10.10.10.10,,,,policy,dmarc,domain.de,,False
|
||||
```
|
||||
|
||||
### JSON SMTP TLS report
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"organization_name": "Example Inc.",
|
||||
"begin_date": "2024-01-09T00:00:00Z",
|
||||
"end_date": "2024-01-09T23:59:59Z",
|
||||
"report_id": "2024-01-09T00:00:00Z_example.com",
|
||||
"policies": [
|
||||
{
|
||||
"policy_domain": "example.com",
|
||||
"policy_type": "sts",
|
||||
"policy_strings": [
|
||||
"version: STSv1",
|
||||
"mode: testing",
|
||||
"mx: example.com",
|
||||
"max_age: 86400"
|
||||
],
|
||||
"successful_session_count": 0,
|
||||
"failed_session_count": 3,
|
||||
"failure_details": [
|
||||
{
|
||||
"result_type": "validation-failure",
|
||||
"failed_session_count": 2,
|
||||
"sending_mta_ip": "209.85.222.201",
|
||||
"receiving_ip": "173.212.201.41",
|
||||
"receiving_mx_hostname": "example.com"
|
||||
},
|
||||
{
|
||||
"result_type": "validation-failure",
|
||||
"failed_session_count": 1,
|
||||
"sending_mta_ip": "209.85.208.176",
|
||||
"receiving_ip": "173.212.201.41",
|
||||
"receiving_mx_hostname": "example.com"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
```
|
||||
22
docs/source/splunk.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# Splunk
|
||||
|
||||
Starting in version 4.3.0 `parsedmarc` supports sending aggregate and/or
|
||||
forensic DMARC data to a Splunk [HTTP Event collector (HEC)].
|
||||
|
||||
The project repository contains [XML files] for premade Splunk
|
||||
dashboards for aggregate and forensic DMARC reports.
|
||||
|
||||
Copy and paste the contents of each file into a separate Splunk
|
||||
dashboard XML editor.
|
||||
|
||||
:::{warning}
|
||||
Change all occurrences of `index="email"` in the XML to
|
||||
match your own index name.
|
||||
:::
|
||||
|
||||
The Splunk dashboards display the same content and layout as the
|
||||
Kibana dashboards, although the Kibana dashboards have slightly
|
||||
easier and more flexible filtering options.
|
||||
|
||||
[xml files]: https://github.com/domainaware/parsedmarc/tree/master/splunk
|
||||
[http event collector (hec)]: http://docs.splunk.com/Documentation/Splunk/latest/Data/AboutHEC
|
||||
526
docs/source/usage.md
Normal file
@@ -0,0 +1,526 @@
|
||||
# Using parsedmarc
|
||||
|
||||
## CLI help
|
||||
|
||||
```text
|
||||
usage: parsedmarc [-h] [-c CONFIG_FILE] [--strip-attachment-payloads] [-o OUTPUT]
|
||||
[--aggregate-json-filename AGGREGATE_JSON_FILENAME]
|
||||
[--forensic-json-filename FORENSIC_JSON_FILENAME]
|
||||
[--aggregate-csv-filename AGGREGATE_CSV_FILENAME]
|
||||
[--forensic-csv-filename FORENSIC_CSV_FILENAME]
|
||||
[-n NAMESERVERS [NAMESERVERS ...]] [-t DNS_TIMEOUT] [--offline]
|
||||
[-s] [--verbose] [--debug] [--log-file LOG_FILE] [-v]
|
||||
[file_path ...]
|
||||
|
||||
Parses DMARC reports
|
||||
|
||||
positional arguments:
|
||||
file_path one or more paths to aggregate or forensic report
|
||||
files, emails, or mbox files'
|
||||
|
||||
optional arguments:
|
||||
-h, --help show this help message and exit
|
||||
-c CONFIG_FILE, --config-file CONFIG_FILE
|
||||
a path to a configuration file (--silent implied)
|
||||
--strip-attachment-payloads
|
||||
remove attachment payloads from forensic report output
|
||||
-o OUTPUT, --output OUTPUT
|
||||
write output files to the given directory
|
||||
--aggregate-json-filename AGGREGATE_JSON_FILENAME
|
||||
filename for the aggregate JSON output file
|
||||
--forensic-json-filename FORENSIC_JSON_FILENAME
|
||||
filename for the forensic JSON output file
|
||||
--aggregate-csv-filename AGGREGATE_CSV_FILENAME
|
||||
filename for the aggregate CSV output file
|
||||
--forensic-csv-filename FORENSIC_CSV_FILENAME
|
||||
filename for the forensic CSV output file
|
||||
-n NAMESERVERS [NAMESERVERS ...], --nameservers NAMESERVERS [NAMESERVERS ...]
|
||||
nameservers to query
|
||||
-t DNS_TIMEOUT, --dns_timeout DNS_TIMEOUT
|
||||
number of seconds to wait for an answer from DNS
|
||||
(default: 2.0)
|
||||
--offline do not make online queries for geolocation or DNS
|
||||
-s, --silent only print errors and warnings
|
||||
--verbose more verbose output
|
||||
--debug print debugging information
|
||||
--log-file LOG_FILE output logging to a file
|
||||
-v, --version show program's version number and exit
|
||||
```
|
||||
|
||||
:::{note}
|
||||
Starting in `parsedmarc` 6.0.0, most CLI options were moved to a
|
||||
configuration file, described below.
|
||||
:::
|
||||
|
||||
## Configuration file
|
||||
|
||||
`parsedmarc` can be configured by supplying the path to an INI file
|
||||
|
||||
```bash
|
||||
parsedmarc -c /etc/parsedmarc.ini
|
||||
```
|
||||
|
||||
For example
|
||||
|
||||
```ini
|
||||
# This is an example comment
|
||||
|
||||
[general]
|
||||
save_aggregate = True
|
||||
save_forensic = True
|
||||
|
||||
[imap]
|
||||
host = imap.example.com
|
||||
user = dmarcresports@example.com
|
||||
password = $uperSecure
|
||||
|
||||
[mailbox]
|
||||
watch = True
|
||||
delete = False
|
||||
|
||||
[elasticsearch]
|
||||
hosts = 127.0.0.1:9200
|
||||
ssl = False
|
||||
|
||||
[opensearch]
|
||||
hosts = https://admin:admin@127.0.0.1:9200
|
||||
ssl = True
|
||||
|
||||
[splunk_hec]
|
||||
url = https://splunkhec.example.com
|
||||
token = HECTokenGoesHere
|
||||
index = email
|
||||
|
||||
[s3]
|
||||
bucket = my-bucket
|
||||
path = parsedmarc
|
||||
|
||||
[syslog]
|
||||
server = localhost
|
||||
port = 514
|
||||
|
||||
[gelf]
|
||||
host = logger
|
||||
port = 12201
|
||||
mode = tcp
|
||||
|
||||
[webhook]
|
||||
aggregate_url = https://aggregate_url.example.com
|
||||
forensic_url = https://forensic_url.example.com
|
||||
smtp_tls_url = https://smtp_tls_url.example.com
|
||||
timeout = 60
|
||||
```
|
||||
|
||||
The full set of configuration options are:
|
||||
|
||||
- `general`
|
||||
- `save_aggregate` - bool: Save aggregate report data to
|
||||
Elasticsearch, Splunk and/or S3
|
||||
- `save_forensic` - bool: Save forensic report data to
|
||||
Elasticsearch, Splunk and/or S3
|
||||
- `save_smtp_tls` - bool: Save SMTP-STS report data to
|
||||
Elasticsearch, Splunk and/or S3
|
||||
- `strip_attachment_payloads` - bool: Remove attachment
|
||||
payloads from results
|
||||
- `output` - str: Directory to place JSON and CSV files in. This is required if you set either of the JSON output file options.
|
||||
- `aggregate_json_filename` - str: filename for the aggregate
|
||||
JSON output file
|
||||
- `forensic_json_filename` - str: filename for the forensic
|
||||
JSON output file
|
||||
- `ip_db_path` - str: An optional custom path to a MMDB file
|
||||
from MaxMind or DBIP
|
||||
- `offline` - bool: Do not use online queries for geolocation
|
||||
or DNS
|
||||
- `always_use_local_files` - Disables the download of the reverse DNS map
|
||||
- `local_reverse_dns_map_path` - Overrides the default local file path to use for the reverse DNS map
|
||||
- `reverse_dns_map_url` - Overrides the default download URL for the reverse DNS map
|
||||
- `nameservers` - str: A comma separated list of
|
||||
DNS resolvers (Default: `[Cloudflare's public resolvers]`)
|
||||
- `dns_test_address` - str: a dummy address used for DNS pre-flight checks
|
||||
(Default: 1.1.1.1)
|
||||
- `dns_timeout` - float: DNS timeout period
|
||||
- `debug` - bool: Print debugging messages
|
||||
- `silent` - bool: Only print errors (Default: `True`)
|
||||
- `log_file` - str: Write log messages to a file at this path
|
||||
- `n_procs` - int: Number of process to run in parallel when
|
||||
parsing in CLI mode (Default: `1`)
|
||||
|
||||
:::{note}
|
||||
Setting this to a number larger than one can improve
|
||||
performance when processing thousands of files
|
||||
:::
|
||||
|
||||
- `mailbox`
|
||||
- `reports_folder` - str: The mailbox folder (or label for
|
||||
Gmail) where the incoming reports can be found
|
||||
(Default: `INBOX`)
|
||||
- `archive_folder` - str: The mailbox folder (or label for
|
||||
Gmail) to sort processed emails into (Default: `Archive`)
|
||||
- `watch` - bool: Use the IMAP `IDLE` command to process
|
||||
messages as they arrive or poll MS Graph for new messages
|
||||
- `delete` - bool: Delete messages after processing them,
|
||||
instead of archiving them
|
||||
- `test` - bool: Do not move or delete messages
|
||||
- `batch_size` - int: Number of messages to read and process
|
||||
before saving. Default `10`. Use `0` for no limit.
|
||||
- `check_timeout` - int: Number of seconds to wait for a IMAP
|
||||
IDLE response or the number of seconds until the next
|
||||
mail check (Default: `30`)
|
||||
- `since` - str: Search for messages since certain time. (Examples: `5m|3h|2d|1w`)
|
||||
Acceptable units - {"m":"minutes", "h":"hours", "d":"days", "w":"weeks"}).
|
||||
Defaults to `1d` if incorrect value is provided.
|
||||
- `imap`
|
||||
- `host` - str: The IMAP server hostname or IP address
|
||||
- `port` - int: The IMAP server port (Default: `993`)
|
||||
|
||||
:::{note}
|
||||
`%` characters must be escaped with another `%` character,
|
||||
so use `%%` wherever a `%` character is used.
|
||||
:::
|
||||
|
||||
:::{note}
|
||||
Starting in version 8.0.0, most options from the `imap`
|
||||
section have been moved to the `mailbox` section.
|
||||
:::
|
||||
|
||||
:::{note}
|
||||
If your host recommends another port, still try 993
|
||||
:::
|
||||
|
||||
- `ssl` - bool: Use an encrypted SSL/TLS connection
|
||||
(Default: `True`)
|
||||
- `skip_certificate_verification` - bool: Skip certificate
|
||||
verification (not recommended)
|
||||
- `user` - str: The IMAP user
|
||||
- `password` - str: The IMAP password
|
||||
- `msgraph`
|
||||
- `auth_method` - str: Authentication method, valid types are
|
||||
`UsernamePassword`, `DeviceCode`, or `ClientSecret`
|
||||
(Default: `UsernamePassword`).
|
||||
- `user` - str: The M365 user, required when the auth method is
|
||||
UsernamePassword
|
||||
- `password` - str: The user password, required when the auth
|
||||
method is UsernamePassword
|
||||
- `client_id` - str: The app registration's client ID
|
||||
- `client_secret` - str: The app registration's secret
|
||||
- `tenant_id` - str: The Azure AD tenant ID. This is required
|
||||
for all auth methods except UsernamePassword.
|
||||
- `mailbox` - str: The mailbox name. This defaults to the
|
||||
current user if using the UsernamePassword auth method, but
|
||||
could be a shared mailbox if the user has access to the mailbox
|
||||
- `graph_url` - str: Microsoft Graph URL. Allows for use of National Clouds (ex Azure Gov)
|
||||
(Default: https://graph.microsoft.com)
|
||||
- `token_file` - str: Path to save the token file
|
||||
(Default: `.token`)
|
||||
- `allow_unencrypted_storage` - bool: Allows the Azure Identity
|
||||
module to fall back to unencrypted token cache (Default: `False`).
|
||||
Even if enabled, the cache will always try encrypted storage first.
|
||||
|
||||
:::{note}
|
||||
You must create an app registration in Azure AD and have an
|
||||
admin grant the Microsoft Graph `Mail.ReadWrite`
|
||||
(delegated) permission to the app. If you are using
|
||||
`UsernamePassword` auth and the mailbox is different from the
|
||||
username, you must grant the app `Mail.ReadWrite.Shared`.
|
||||
:::
|
||||
|
||||
:::{warning}
|
||||
If you are using the `ClientSecret` auth method, you need to
|
||||
grant the `Mail.ReadWrite` (application) permission to the
|
||||
app. You must also restrict the application's access to a
|
||||
specific mailbox since it allows all mailboxes by default.
|
||||
Use the `New-ApplicationAccessPolicy` command in the
|
||||
Exchange PowerShell module. If you need to scope the policy to
|
||||
shared mailboxes, you can add them to a mail enabled security
|
||||
group and use that as the group id.
|
||||
|
||||
```powershell
|
||||
New-ApplicationAccessPolicy -AccessRight RestrictAccess
|
||||
-AppId "<CLIENT_ID>" -PolicyScopeGroupId "<MAILBOX>"
|
||||
-Description "Restrict access to dmarc reports mailbox."
|
||||
```
|
||||
|
||||
:::
|
||||
- `elasticsearch`
|
||||
- `hosts` - str: A comma separated list of hostnames and ports
|
||||
or URLs (e.g. `127.0.0.1:9200` or
|
||||
`https://user:secret@localhost`)
|
||||
|
||||
:::{note}
|
||||
Special characters in the username or password must be
|
||||
[URL encoded].
|
||||
:::
|
||||
- `user` - str: Basic auth username
|
||||
- `password` - str: Basic auth password
|
||||
- `apiKey` - str: API key
|
||||
- `ssl` - bool: Use an encrypted SSL/TLS connection
|
||||
(Default: `True`)
|
||||
- `timeout` - float: Timeout in seconds (Default: 60)
|
||||
- `cert_path` - str: Path to a trusted certificates
|
||||
- `index_suffix` - str: A suffix to apply to the index names
|
||||
- `index_prefix` - str: A prefix to apply to the index names
|
||||
- `monthly_indexes` - bool: Use monthly indexes instead of daily indexes
|
||||
- `number_of_shards` - int: The number of shards to use when
|
||||
creating the index (Default: `1`)
|
||||
- `number_of_replicas` - int: The number of replicas to use when
|
||||
creating the index (Default: `0`)
|
||||
- `opensearch`
|
||||
- `hosts` - str: A comma separated list of hostnames and ports
|
||||
or URLs (e.g. `127.0.0.1:9200` or
|
||||
`https://user:secret@localhost`)
|
||||
|
||||
:::{note}
|
||||
Special characters in the username or password must be
|
||||
[URL encoded].
|
||||
:::
|
||||
- `user` - str: Basic auth username
|
||||
- `password` - str: Basic auth password
|
||||
- `apiKey` - str: API key
|
||||
- `ssl` - bool: Use an encrypted SSL/TLS connection
|
||||
(Default: `True`)
|
||||
- `timeout` - float: Timeout in seconds (Default: 60)
|
||||
- `cert_path` - str: Path to a trusted certificates
|
||||
- `index_suffix` - str: A suffix to apply to the index names
|
||||
- `index_prefix` - str: A prefix to apply to the index names
|
||||
- `monthly_indexes` - bool: Use monthly indexes instead of daily indexes
|
||||
- `number_of_shards` - int: The number of shards to use when
|
||||
creating the index (Default: `1`)
|
||||
- `number_of_replicas` - int: The number of replicas to use when
|
||||
creating the index (Default: `0`)
|
||||
- `splunk_hec`
|
||||
- `url` - str: The URL of the Splunk HTTP Events Collector (HEC)
|
||||
- `token` - str: The HEC token
|
||||
- `index` - str: The Splunk index to use
|
||||
- `skip_certificate_verification` - bool: Skip certificate
|
||||
verification (not recommended)
|
||||
- `kafka`
|
||||
- `hosts` - str: A comma separated list of Kafka hosts
|
||||
- `user` - str: The Kafka user
|
||||
- `passsword` - str: The Kafka password
|
||||
- `ssl` - bool: Use an encrypted SSL/TLS connection (Default: `True`)
|
||||
- `skip_certificate_verification` - bool: Skip certificate
|
||||
verification (not recommended)
|
||||
- `aggregate_topic` - str: The Kafka topic for aggregate reports
|
||||
- `forensic_topic` - str: The Kafka topic for forensic reports
|
||||
- `smtp`
|
||||
- `host` - str: The SMTP hostname
|
||||
- `port` - int: The SMTP port (Default: `25`)
|
||||
- `ssl` - bool: Require SSL/TLS instead of using STARTTLS
|
||||
- `skip_certificate_verification` - bool: Skip certificate
|
||||
verification (not recommended)
|
||||
- `user` - str: the SMTP username
|
||||
- `password` - str: the SMTP password
|
||||
- `from` - str: The From header to use in the email
|
||||
- `to` - list: A list of email addresses to send to
|
||||
- `subject` - str: The Subject header to use in the email
|
||||
(Default: `parsedmarc report`)
|
||||
- `attachment` - str: The ZIP attachment filenames
|
||||
- `message` - str: The email message
|
||||
(Default: `Please see the attached parsedmarc report.`)
|
||||
|
||||
:::{note}
|
||||
`%` characters must be escaped with another `%` character,
|
||||
so use `%%` wherever a `%` character is used.
|
||||
:::
|
||||
- `s3`
|
||||
- `bucket` - str: The S3 bucket name
|
||||
- `path` - str: The path to upload reports to (Default: `/`)
|
||||
- `region_name` - str: The region name (Optional)
|
||||
- `endpoint_url` - str: The endpoint URL (Optional)
|
||||
- `access_key_id` - str: The access key id (Optional)
|
||||
- `secret_access_key` - str: The secret access key (Optional)
|
||||
- `syslog`
|
||||
- `server` - str: The Syslog server name or IP address
|
||||
- `port` - int: The UDP port to use (Default: `514`)
|
||||
- `gmail_api`
|
||||
- `credentials_file` - str: Path to file containing the
|
||||
credentials, None to disable (Default: `None`)
|
||||
- `token_file` - str: Path to save the token file
|
||||
(Default: `.token`)
|
||||
|
||||
:::{note}
|
||||
credentials_file and token_file can be got with [quickstart](https://developers.google.com/gmail/api/quickstart/python).Please change the scope to `https://www.googleapis.com/auth/gmail.modify`.
|
||||
:::
|
||||
- `include_spam_trash` - bool: Include messages in Spam and
|
||||
Trash when searching reports (Default: `False`)
|
||||
- `scopes` - str: Comma separated list of scopes to use when
|
||||
acquiring credentials
|
||||
(Default: `https://www.googleapis.com/auth/gmail.modify`)
|
||||
- `oauth2_port` - int: The TCP port for the local server to
|
||||
listen on for the OAuth2 response (Default: `8080`)
|
||||
- `paginate_messages` - bool: When `True`, fetch all applicable Gmail messages.
|
||||
When `False`, only fetch up to 100 new messages per run (Default: `True`)
|
||||
- `log_analytics`
|
||||
- `client_id` - str: The app registration's client ID
|
||||
- `client_secret` - str: The app registration's client secret
|
||||
- `tenant_id` - str: The tenant id where the app registration resides
|
||||
- `dce` - str: The Data Collection Endpoint (DCE). Example: `https://{DCE-NAME}.{REGION}.ingest.monitor.azure.com`.
|
||||
- `dcr_immutable_id` - str: The immutable ID of the Data Collection Rule (DCR)
|
||||
- `dcr_aggregate_stream` - str: The stream name for aggregate reports in the DCR
|
||||
- `dcr_forensic_stream` - str: The stream name for the forensic reports in the DCR
|
||||
- `dcr_smtp_tls_stream` - str: The stream name for the SMTP TLS reports in the DCR
|
||||
|
||||
:::{note}
|
||||
Information regarding the setup of the Data Collection Rule can be found [here](https://learn.microsoft.com/en-us/azure/azure-monitor/logs/tutorial-logs-ingestion-portal).
|
||||
:::
|
||||
- `gelf`
|
||||
- `host` - str: The GELF server name or IP address
|
||||
- `port` - int: The port to use
|
||||
- `mode` - str: The GELF transport type to use. Valid modes: `tcp`, `udp`, `tls`
|
||||
|
||||
- `maildir`
|
||||
- `reports_folder` - str: Full path for mailbox maidir location (Default: `INBOX`)
|
||||
- `maildir_create` - bool: Create maildir if not present (Default: False)
|
||||
|
||||
- `webhook` - Post the individual reports to a webhook url with the report as the JSON body
|
||||
- `aggregate_url` - str: URL of the webhook which should receive the aggregate reports
|
||||
- `forensic_url` - str: URL of the webhook which should receive the forensic reports
|
||||
- `smtp_tls_url` - str: URL of the webhook which should receive the smtp_tls reports
|
||||
- `timeout` - int: Interval in which the webhook call should timeout
|
||||
|
||||
:::{warning}
|
||||
It is **strongly recommended** to **not** use the `nameservers`
|
||||
setting. By default, `parsedmarc` uses
|
||||
[Cloudflare's public resolvers], which are much faster and more
|
||||
reliable than Google, Cisco OpenDNS, or even most local resolvers.
|
||||
|
||||
The `nameservers` option should only be used if your network
|
||||
blocks DNS requests to outside resolvers.
|
||||
:::
|
||||
|
||||
:::{note}
|
||||
`save_aggregate` and `save_forensic` are separate options
|
||||
because you may not want to save forensic reports
|
||||
(also known as failure reports) to your Elasticsearch instance,
|
||||
particularly if you are in a highly-regulated industry that
|
||||
handles sensitive data, such as healthcare or finance. If your
|
||||
legitimate outgoing email fails DMARC, it is possible
|
||||
that email may appear later in a forensic report.
|
||||
|
||||
Forensic reports contain the original headers of an email that
|
||||
failed a DMARC check, and sometimes may also include the
|
||||
full message body, depending on the policy of the reporting
|
||||
organization.
|
||||
|
||||
Most reporting organizations do not send forensic reports of any
|
||||
kind for privacy reasons. While aggregate DMARC reports are sent
|
||||
at least daily, it is normal to receive very few forensic reports.
|
||||
|
||||
An alternative approach is to still collect forensic/failure/ruf
|
||||
reports in your DMARC inbox, but run `parsedmarc` with
|
||||
```save_forensic = True``` manually on a separate IMAP folder (using
|
||||
the ```reports_folder``` option), after you have manually moved
|
||||
known samples you want to save to that folder
|
||||
(e.g. malicious samples and non-sensitive legitimate samples).
|
||||
:::
|
||||
|
||||
:::{warning}
|
||||
Elasticsearch 8 change limits policy for shards, restricting by
|
||||
default to 1000. parsedmarc use a shard per analyzed day. If you
|
||||
have more than ~3 years of data, you will need to update this
|
||||
limit.
|
||||
Check current usage (from Management -> Dev Tools -> Console):
|
||||
|
||||
```text
|
||||
GET /_cluster/health?pretty
|
||||
{
|
||||
...
|
||||
"active_primary_shards": 932,
|
||||
"active_shards": 932,
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
Update the limit to 2k per example:
|
||||
|
||||
```text
|
||||
PUT _cluster/settings
|
||||
{
|
||||
"persistent" : {
|
||||
"cluster.max_shards_per_node" : 2000
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Increasing this value increases resource usage.
|
||||
:::
|
||||
|
||||
## Running parsedmarc as a systemd service
|
||||
|
||||
Use systemd to run `parsedmarc` as a service and process reports as
|
||||
they arrive.
|
||||
|
||||
Protect the `parsedmarc` configuration file from prying eyes
|
||||
|
||||
```bash
|
||||
sudo chown root:parsedmarc /etc/parsedmarc.ini
|
||||
sudo chmod u=rw,g=r,o= /etc/parsedmarc.ini
|
||||
```
|
||||
|
||||
Create the service configuration file
|
||||
|
||||
```bash
|
||||
sudo nano /etc/systemd/system/parsedmarc.service
|
||||
```
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=parsedmarc mailbox watcher
|
||||
Documentation=https://domainaware.github.io/parsedmarc/
|
||||
Wants=network-online.target
|
||||
After=network.target network-online.target elasticsearch.service
|
||||
|
||||
[Service]
|
||||
ExecStart=/opt/parsedmarc/venv/bin/parsedmarc -c /etc/parsedmarc.ini
|
||||
User=parsedmarc
|
||||
Group=parsedmarc
|
||||
Restart=always
|
||||
RestartSec=5m
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
Then, enable the service
|
||||
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable parsedmarc.service
|
||||
sudo service parsedmarc restart
|
||||
```
|
||||
|
||||
:::{note}
|
||||
You must also run the above commands whenever you edit
|
||||
`parsedmarc.service`.
|
||||
:::
|
||||
|
||||
:::{warning}
|
||||
Always restart the service every time you upgrade to a new version of
|
||||
`parsedmarc`:
|
||||
|
||||
```bash
|
||||
sudo service parsedmarc restart
|
||||
```
|
||||
|
||||
:::
|
||||
|
||||
To check the status of the service, run:
|
||||
|
||||
```bash
|
||||
service parsedmarc status
|
||||
```
|
||||
|
||||
:::{note}
|
||||
In the event of a crash, systemd will restart the service after 10
|
||||
minutes, but the `service parsedmarc status` command will only show
|
||||
the logs for the current process. To view the logs for previous runs
|
||||
as well as the current process (newest to oldest), run:
|
||||
|
||||
```bash
|
||||
journalctl -u parsedmarc.service -r
|
||||
```
|
||||
|
||||
:::
|
||||
|
||||
[cloudflare's public resolvers]: https://1.1.1.1/
|
||||
[url encoded]: https://en.wikipedia.org/wiki/Percent-encoding#Percent-encoding_reserved_characters
|
||||
4362
grafana/Grafana-DMARC_Reports.json
Normal file
5901
grafana/Grafana-DMARC_Reports.json-new_panel.json
Normal file
1
grafana/README.rst
Normal file
@@ -0,0 +1 @@
|
||||
Dashboards contributed by Github user Bhozar.
|
||||
BIN
grafana/grafana-dmarc-reports00.png
Normal file
|
After Width: | Height: | Size: 136 KiB |
BIN
grafana/grafana-dmarc-reports01.png
Normal file
|
After Width: | Height: | Size: 116 KiB |
BIN
grafana/grafana-dmarc-reports02.png
Normal file
|
After Width: | Height: | Size: 172 KiB |
BIN
grafana/grafana-dmarc-reports03.png
Normal file
|
After Width: | Height: | Size: 311 KiB |
BIN
grafana/grafana-dmarc-reports04.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
grafana/grafana-dmarc-reports05.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
25
kibana/export.ndjson
Normal file
@@ -1,4 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
. ~/venv/domainaware/bin/activate
|
||||
cd docs && make html && cp -r build/html/* ../../parsedmarc-docs/
|
||||
629
parsedmarc.py
@@ -1,629 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""A Python module and CLI for parsing aggregate DMARC reports"""
|
||||
|
||||
from __future__ import unicode_literals, print_function, absolute_import
|
||||
|
||||
import logging
|
||||
from sys import version_info
|
||||
from os import path, stat
|
||||
import json
|
||||
from datetime import datetime
|
||||
from collections import OrderedDict
|
||||
from datetime import timedelta
|
||||
from io import BytesIO, StringIO
|
||||
from gzip import GzipFile
|
||||
import tarfile
|
||||
from zipfile import ZipFile
|
||||
from csv import DictWriter
|
||||
import shutil
|
||||
from argparse import ArgumentParser
|
||||
from glob import glob
|
||||
|
||||
import publicsuffix
|
||||
import xmltodict
|
||||
import dns.reversename
|
||||
import dns.resolver
|
||||
import dns.exception
|
||||
from requests import get
|
||||
import geoip2.database
|
||||
import geoip2.errors
|
||||
|
||||
__version__ = "1.0.0"
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.setLevel(logging.WARNING)
|
||||
|
||||
|
||||
# Python 2 comparability hack
|
||||
if version_info[0] >= 3:
|
||||
unicode = str
|
||||
|
||||
|
||||
class InvalidAggregateReport(Exception):
|
||||
"""Raised when an invalid DMARC aggregate report is encountered"""
|
||||
|
||||
|
||||
def _get_base_domain(domain):
|
||||
"""
|
||||
Gets the base domain name for the given domain
|
||||
|
||||
.. note::
|
||||
Results are based on a list of public domain suffixes at
|
||||
https://publicsuffix.org/list/public_suffix_list.dat.
|
||||
|
||||
This file is saved to the current working directory,
|
||||
where it is used as a cache file for 24 hours.
|
||||
|
||||
Args:
|
||||
domain (str): A domain or subdomain
|
||||
|
||||
Returns:
|
||||
str: The base domain of the given domain
|
||||
|
||||
"""
|
||||
psl_path = "public_suffix_list.dat"
|
||||
|
||||
def download_psl():
|
||||
fresh_psl = publicsuffix.fetch()
|
||||
with open(psl_path, "w", encoding="utf-8") as fresh_psl_file:
|
||||
fresh_psl_file.write(fresh_psl.read())
|
||||
|
||||
return publicsuffix.PublicSuffixList(fresh_psl)
|
||||
|
||||
if not path.exists(psl_path):
|
||||
psl = download_psl()
|
||||
else:
|
||||
psl_age = datetime.now() - datetime.fromtimestamp(
|
||||
stat(psl_path).st_mtime)
|
||||
if psl_age > timedelta(hours=24):
|
||||
psl = download_psl()
|
||||
else:
|
||||
with open(psl_path, encoding="utf-8") as psl_file:
|
||||
psl = publicsuffix.PublicSuffixList(psl_file)
|
||||
|
||||
return psl.get_public_suffix(domain)
|
||||
|
||||
|
||||
def _query_dns(domain, record_type, nameservers=None, timeout=6.0):
|
||||
"""
|
||||
Queries DNS
|
||||
|
||||
Args:
|
||||
domain (str): The domain or subdomain to query about
|
||||
record_type (str): The record type to query for
|
||||
nameservers (list): A list of one or more nameservers to use
|
||||
timeout (float): Sets the DNS timeout in seconds
|
||||
|
||||
Returns:
|
||||
list: A list of answers
|
||||
"""
|
||||
resolver = dns.resolver.Resolver()
|
||||
timeout = float(timeout)
|
||||
if nameservers:
|
||||
resolver.nameservers = nameservers
|
||||
resolver.timeout = timeout
|
||||
resolver.lifetime = timeout
|
||||
return list(map(
|
||||
lambda r: r.to_text().replace(' "', '').replace('"', '').rstrip("."),
|
||||
resolver.query(domain, record_type, tcp=True)))
|
||||
|
||||
|
||||
def _get_reverse_dns(ip_address, nameservers=None, timeout=6.0):
|
||||
"""
|
||||
Resolves an IP address to a hostname using a reverse DNS query
|
||||
|
||||
Args:
|
||||
ip_address (str): The IP address to resolve
|
||||
nameservers (list): A list of nameservers to query
|
||||
timeout (float): Sets the DNS query timeout in seconds
|
||||
|
||||
Returns:
|
||||
|
||||
"""
|
||||
hostname = None
|
||||
try:
|
||||
address = dns.reversename.from_address(ip_address)
|
||||
hostname = _query_dns(address, "PTR",
|
||||
nameservers=nameservers,
|
||||
timeout=timeout)[0]
|
||||
|
||||
except dns.exception.DNSException:
|
||||
pass
|
||||
|
||||
return hostname
|
||||
|
||||
|
||||
def _timestamp_to_datetime(timestamp):
|
||||
"""
|
||||
Converts a UNIX/DMARC timestamp to a Python ``DateTime`` object
|
||||
|
||||
Args:
|
||||
timestamp: The timestamp
|
||||
|
||||
Returns:
|
||||
DateTime: The converted timestamp as a Python ``DateTime`` object
|
||||
"""
|
||||
return datetime.fromtimestamp(int(timestamp))
|
||||
|
||||
|
||||
def _timestamp_to_human(timestamp):
|
||||
"""
|
||||
Converts a UNIX/DMARC timestamp to a human-readable string
|
||||
|
||||
Args:
|
||||
timestamp: The timestamp
|
||||
|
||||
Returns:
|
||||
str: The converted timestamp in ``YYYY-MM-DD HH:MM:SS`` format
|
||||
"""
|
||||
return _timestamp_to_datetime(timestamp).strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
|
||||
def _human_timestamp_to_datetime(human_timestamp):
|
||||
"""
|
||||
Converts a human-readable timestamp into a Python ``DateTime`` object
|
||||
|
||||
Args:
|
||||
human_timestamp (str): A timestamp in `YYYY-MM-DD HH:MM:SS`` format
|
||||
|
||||
Returns:
|
||||
DateTime: The converted timestamp
|
||||
"""
|
||||
return datetime.strptime(human_timestamp, "%Y-%m-%d %H:%M:%S")
|
||||
|
||||
|
||||
def _get_ip_address_country(ip_address):
|
||||
"""
|
||||
Uses the MaxMind Geolite2 Country database to return the ISO code for the
|
||||
country associated with the given IPv4 or IPv6 address
|
||||
|
||||
Args:
|
||||
ip_address (str): The IP address to query for
|
||||
|
||||
Returns:
|
||||
str: And ISO country code associated with the given IP address
|
||||
"""
|
||||
db_filename = "GeoLite2-Country.mmdb"
|
||||
|
||||
def download_country_database():
|
||||
"""Downloads the MaxMind Geolite2 Country database to the current
|
||||
working directory"""
|
||||
url = "https://geolite.maxmind.com/download/geoip/database/" \
|
||||
"GeoLite2-Country.tar.gz"
|
||||
tar_file = tarfile.open(fileobj=BytesIO(get(url).content), mode="r:gz")
|
||||
tar_dir = tar_file.getnames()[0]
|
||||
tar_path = "{0}/{1}".format(tar_dir, db_filename)
|
||||
tar_file.extract(tar_path)
|
||||
shutil.move(tar_path, ".")
|
||||
shutil.rmtree(tar_dir)
|
||||
|
||||
system_paths = ["/usr/local/share/GeoIP/GeoLite2-Country.mmdb",
|
||||
"/usr/share/GeoIP/GeoLite2-Country.mmdb"]
|
||||
db_path = ""
|
||||
|
||||
for system_path in system_paths:
|
||||
if path.exists(system_path):
|
||||
db_path = system_path
|
||||
break
|
||||
|
||||
if db_path == "":
|
||||
if not path.exists(db_filename):
|
||||
download_country_database()
|
||||
else:
|
||||
db_age = datetime.now() - datetime.fromtimestamp(
|
||||
stat(db_filename).st_mtime)
|
||||
if db_age > timedelta(days=60):
|
||||
shutil.rmtree(db_path)
|
||||
download_country_database()
|
||||
db_path = db_filename
|
||||
|
||||
db_reader = geoip2.database.Reader(db_path)
|
||||
|
||||
country = None
|
||||
|
||||
try:
|
||||
country = db_reader.country(ip_address).country.iso_code
|
||||
except geoip2.errors.AddressNotFoundError:
|
||||
pass
|
||||
|
||||
return country
|
||||
|
||||
|
||||
def _parse_report_record(record, nameservers=None, timeout=6.0):
|
||||
"""
|
||||
Converts a record from a DMARC aggregate report into a more consistent
|
||||
format
|
||||
|
||||
Args:
|
||||
record (OrderedDict): The record to convert
|
||||
nameservers (list): A list of one or more nameservers to use
|
||||
timeout (float): Sets the DNS timeout in seconds
|
||||
|
||||
Returns:
|
||||
OrderedDict: The converted record
|
||||
"""
|
||||
record = record.copy()
|
||||
new_record = OrderedDict()
|
||||
new_record["source"] = OrderedDict()
|
||||
new_record["source"]["ip_address"] = record["row"]["source_ip"]
|
||||
reverse_dns = _get_reverse_dns(new_record["source"]["ip_address"],
|
||||
nameservers=nameservers,
|
||||
timeout=timeout)
|
||||
country = _get_ip_address_country(new_record["source"]["ip_address"])
|
||||
new_record["source"]["country"] = country
|
||||
new_record["source"]["reverse_dns"] = reverse_dns
|
||||
new_record["source"]["base_domain"] = None
|
||||
if new_record["source"]["reverse_dns"] is not None:
|
||||
base_domain = _get_base_domain(new_record["source"]["reverse_dns"])
|
||||
new_record["source"]["base_domain"] = base_domain
|
||||
new_record["count"] = int(record["row"]["count"])
|
||||
policy_evaluated = record["row"]["policy_evaluated"].copy()
|
||||
new_policy_evaluated = OrderedDict([("disposition", "none"),
|
||||
("dkim", "fail"),
|
||||
("spf", "fail"),
|
||||
("policy_override_reasons", [])
|
||||
])
|
||||
if "disposition" in policy_evaluated:
|
||||
new_policy_evaluated["disposition"] = policy_evaluated["disposition"]
|
||||
if "dkim" in policy_evaluated:
|
||||
new_policy_evaluated["dkim"] = policy_evaluated["dkim"]
|
||||
if "spf" in policy_evaluated:
|
||||
new_policy_evaluated["spf"] = policy_evaluated["spf"]
|
||||
reasons = []
|
||||
if "reason" in policy_evaluated:
|
||||
if type(policy_evaluated["reason"]) == list:
|
||||
reasons = policy_evaluated["reason"]
|
||||
else:
|
||||
reasons = [policy_evaluated["reason"]]
|
||||
for reason in reasons:
|
||||
if "comment" not in reason:
|
||||
reason["comment"] = "none"
|
||||
reasons.append(reason)
|
||||
new_policy_evaluated["policy_override_reasons"] = reasons
|
||||
new_record["policy_evaluated"] = new_policy_evaluated
|
||||
new_record["identifiers"] = record["identifiers"].copy()
|
||||
new_record["auth_results"] = OrderedDict([("dkim", []), ("spf", [])])
|
||||
auth_results = record["auth_results"].copy()
|
||||
if "dkim" in auth_results:
|
||||
if type(auth_results["dkim"]) != list:
|
||||
auth_results["dkim"] = [auth_results["dkim"]]
|
||||
for result in auth_results["dkim"]:
|
||||
if "domain" in result and result["domain"] is not None:
|
||||
new_result = OrderedDict([("domain", result["domain"])])
|
||||
if "selector" in result and result["selector"] is not None:
|
||||
new_result["selector"] = result["selector"]
|
||||
else:
|
||||
new_result["selector"] = "none"
|
||||
if "result" in result and result["result"] is not None:
|
||||
new_result["result"] = result["result"]
|
||||
else:
|
||||
new_result["result"] = "none"
|
||||
new_record["auth_results"]["dkim"].append(new_result)
|
||||
if type(auth_results["spf"]) != list:
|
||||
auth_results["spf"] = [auth_results["spf"]]
|
||||
for result in auth_results["spf"]:
|
||||
new_result = OrderedDict([("domain", result["domain"])])
|
||||
if "scope" in result and result["scope"] is not None:
|
||||
new_result["scope"] = result["scope"]
|
||||
else:
|
||||
new_result["scope"] = "mfrom"
|
||||
if "result" in result and result["result"] is not None:
|
||||
new_result["result"] = result["result"]
|
||||
else:
|
||||
new_result["result"] = "none"
|
||||
new_record["auth_results"]["spf"].append(new_result)
|
||||
|
||||
if "envelope_from" not in new_record["identifiers"]:
|
||||
envelope_from = new_record["auth_results"]["spf"][-1]["domain"].lower()
|
||||
new_record["identifiers"]["envelope_from"] = envelope_from
|
||||
|
||||
elif new_record["identifiers"]["envelope_from"] is None:
|
||||
envelope_from = new_record["auth_results"]["spf"][-1]["domain"].lower()
|
||||
new_record["identifiers"]["envelope_from"] = envelope_from
|
||||
|
||||
envelope_to = None
|
||||
if "envelope_to" in new_record["identifiers"]:
|
||||
envelope_to = new_record["identifiers"]["envelope_to"]
|
||||
del new_record["identifiers"]["envelope_to"]
|
||||
|
||||
new_record["identifiers"]["envelope_to"] = envelope_to
|
||||
|
||||
return new_record
|
||||
|
||||
|
||||
def parse_aggregate_report_xml(xml, nameservers=None, timeout=6.0):
|
||||
"""Parses a DMARC XML report string and returns a consistent OrderedDict
|
||||
|
||||
Args:
|
||||
xml (str): A string of DMARC aggregate report XML
|
||||
nameservers (list): A list of one or more nameservers to use
|
||||
timeout (float): Sets the DNS timeout in seconds
|
||||
|
||||
Returns:
|
||||
OrderedDict: The parsed aggregate DMARC report
|
||||
"""
|
||||
try:
|
||||
report = xmltodict.parse(xml)["feedback"]
|
||||
report_metadata = report["report_metadata"]
|
||||
schema = "draft"
|
||||
if "version" in report:
|
||||
schema = report["version"]
|
||||
new_report = OrderedDict([("xml_schema", schema)])
|
||||
new_report_metadata = OrderedDict()
|
||||
new_report_metadata["org_name"] = report_metadata["org_name"]
|
||||
new_report_metadata["org_email"] = report_metadata["email"]
|
||||
extra = None
|
||||
if "extra_contact_info" in report_metadata:
|
||||
extra = report_metadata["extra_contact_info"]
|
||||
new_report_metadata["org_extra_contact_info"] = extra
|
||||
new_report_metadata["report_id"] = report_metadata["report_id"]
|
||||
date_range = report["report_metadata"]["date_range"]
|
||||
date_range["begin"] = _timestamp_to_human(date_range["begin"])
|
||||
date_range["end"] = _timestamp_to_human(date_range["end"])
|
||||
new_report_metadata["begin_date"] = date_range["begin"]
|
||||
new_report_metadata["end_date"] = date_range["end"]
|
||||
errors = []
|
||||
if "error" in report["report_metadata"]:
|
||||
if type(report["report_metadata"]["error"]) != list:
|
||||
errors = [report["report_metadata"]["error"]]
|
||||
else:
|
||||
errors = report["report_metadata"]["error"]
|
||||
new_report_metadata["errors"] = errors
|
||||
new_report["report_metadata"] = new_report_metadata
|
||||
records = []
|
||||
policy_published = report["policy_published"]
|
||||
new_policy_published = OrderedDict()
|
||||
new_policy_published["domain"] = policy_published["domain"]
|
||||
adkim = "r"
|
||||
if "adkim" in policy_published:
|
||||
if policy_published["adkim"] is not None:
|
||||
adkim = policy_published["adkim"]
|
||||
new_policy_published["adkim"] = adkim
|
||||
aspf = "r"
|
||||
if "aspf" in policy_published:
|
||||
if policy_published["aspf"] is not None:
|
||||
aspf = policy_published["aspf"]
|
||||
new_policy_published["aspf"] = aspf
|
||||
new_policy_published["p"] = policy_published["p"]
|
||||
sp = new_policy_published["p"]
|
||||
if "sp" in policy_published:
|
||||
if policy_published["sp"] is not None:
|
||||
sp = report["policy_published"]["sp"]
|
||||
new_policy_published["sp"] = sp
|
||||
pct = "100"
|
||||
if "pct" in policy_published:
|
||||
if policy_published["pct"] is not None:
|
||||
pct = report["policy_published"]["pct"]
|
||||
new_policy_published["pct"] = pct
|
||||
fo = "0"
|
||||
if "fo" in policy_published:
|
||||
if policy_published["fo"] is not None:
|
||||
fo = report["policy_published"]["fo"]
|
||||
new_policy_published["fo"] = fo
|
||||
new_report["policy_published"] = new_policy_published
|
||||
|
||||
if type(report["record"]) == list:
|
||||
for record in report["record"]:
|
||||
records.append(_parse_report_record(record,
|
||||
nameservers=nameservers,
|
||||
timeout=timeout))
|
||||
|
||||
else:
|
||||
records.append(_parse_report_record(report["record"]))
|
||||
|
||||
new_report["records"] = records
|
||||
|
||||
return new_report
|
||||
|
||||
except KeyError as error:
|
||||
raise InvalidAggregateReport("Missing field: "
|
||||
"{0}".format(error.__str__()))
|
||||
|
||||
|
||||
def parse_aggregate_report_file(_input, nameservers=None, timeout=6.0):
|
||||
"""Parses a file at the given path, a file-like object. or bytes as a
|
||||
aggregate DMARC report
|
||||
|
||||
Args:
|
||||
_input: A path to a file, a file like object, or bytes
|
||||
nameservers (list): A list of one or more nameservers to use
|
||||
timeout (float): Sets the DNS timeout in seconds
|
||||
|
||||
Returns:
|
||||
OrderedDict: The parsed DMARC aggregate report
|
||||
"""
|
||||
if type(_input) == str or type(_input) == unicode:
|
||||
file_object = open(_input, "rb")
|
||||
elif type(_input) == bytes:
|
||||
file_object = BytesIO(_input)
|
||||
else:
|
||||
file_object = _input
|
||||
try:
|
||||
header = file_object.read(6)
|
||||
file_object.seek(0)
|
||||
if header.startswith(b"\x50\x4B\x03\x04"):
|
||||
_zip = ZipFile(file_object)
|
||||
xml = _zip.open(_zip.namelist()[0]).read().decode()
|
||||
elif header.startswith(b"\x1F\x8B"):
|
||||
xml = GzipFile(fileobj=file_object).read().decode()
|
||||
elif header.startswith(b"\x3c\x3f\x78\x6d\x6c\x20"):
|
||||
xml = file_object.read().decode()
|
||||
else:
|
||||
file_object.close()
|
||||
raise InvalidAggregateReport("Not a valid zip, gzip, or xml file")
|
||||
|
||||
file_object.close()
|
||||
except UnicodeDecodeError:
|
||||
raise InvalidAggregateReport("File objects must be opened in binary "
|
||||
"(rb) mode")
|
||||
|
||||
return parse_aggregate_report_xml(xml,
|
||||
nameservers=nameservers,
|
||||
timeout=timeout)
|
||||
|
||||
|
||||
def parsed_aggregate_report_to_csv(_input):
|
||||
"""
|
||||
Converts one or more parsed aggregate reports to flat CSV format, including
|
||||
headers
|
||||
|
||||
Args:
|
||||
_input: A parsed aggregate report or list of parsed aggregate reports
|
||||
|
||||
Returns:
|
||||
str: Parsed aggregate report data in flat CSV format, including headers
|
||||
"""
|
||||
fields = ["xml_schema", "org_name", "org_email",
|
||||
"org_extra_contact_info", "report_id", "begin_date", "end_date",
|
||||
"errors", "domain", "adkim", "aspf", "p", "sp", "pct", "fo",
|
||||
"source_ip_address", "source_country", "source_reverse_dns",
|
||||
"source_base_domain", "count", "disposition", "dkim_alignment",
|
||||
"spf_alignment", "policy_override_reasons",
|
||||
"policy_override_comments", "envelope_from", "header_from",
|
||||
"envelope_to", "dkim_domains", "dkim_selectors", "dkim_results",
|
||||
"spf_domains", "spf_scopes", "spf_results"]
|
||||
|
||||
csv_file_object = StringIO()
|
||||
writer = DictWriter(csv_file_object, fields)
|
||||
writer.writeheader()
|
||||
|
||||
if type(_input) == OrderedDict:
|
||||
_input = [_input]
|
||||
|
||||
for report in _input:
|
||||
xml_schema = report["xml_schema"]
|
||||
org_name = report["report_metadata"]["org_name"]
|
||||
org_email = report["report_metadata"]["org_email"]
|
||||
org_extra_contact = report["report_metadata"]["org_extra_contact_info"]
|
||||
report_id = report["report_metadata"]["report_id"]
|
||||
begin_date = report["report_metadata"]["begin_date"]
|
||||
end_date = report["report_metadata"]["end_date"]
|
||||
errors = report["report_metadata"]["errors"]
|
||||
domain = report["policy_published"]["domain"]
|
||||
adkim = report["policy_published"]["adkim"]
|
||||
aspf = report["policy_published"]["aspf"]
|
||||
p = report["policy_published"]["p"]
|
||||
sp = report["policy_published"]["sp"]
|
||||
pct = report["policy_published"]["pct"]
|
||||
fo = report["policy_published"]["fo"]
|
||||
|
||||
report_dict = dict(xml_schema=xml_schema, org_name=org_name,
|
||||
org_email=org_email,
|
||||
org_extra_contact_info=org_extra_contact,
|
||||
report_id=report_id, begin_date=begin_date,
|
||||
end_date=end_date, errors=errors, domain=domain,
|
||||
adkim=adkim, aspf=aspf, p=p, sp=sp, pct=pct, fo=fo)
|
||||
|
||||
for record in report["records"]:
|
||||
row = report_dict
|
||||
row["source_ip_address"] = record["source"]["ip_address"]
|
||||
row["source_country"] = record["source"]["country"]
|
||||
row["source_reverse_dns"] = record["source"]["reverse_dns"]
|
||||
row["source_base_domain"] = record["source"]["base_domain"]
|
||||
row["count"] = record["count"]
|
||||
row["disposition"] = record["policy_evaluated"]["disposition"]
|
||||
row["spf_alignment"] = record["policy_evaluated"]["spf"]
|
||||
row["dkim_alignment"] = record["policy_evaluated"]["dkim"]
|
||||
policy_override_reasons = list(map(lambda r: r["type"],
|
||||
record["policy_evaluated"]
|
||||
["policy_override_reasons"]))
|
||||
policy_override_comments = list(map(lambda r: r["comment"],
|
||||
record["policy_evaluated"]
|
||||
["policy_override_reasons"]))
|
||||
row["policy_override_reasons"] = ",".join(
|
||||
policy_override_reasons)
|
||||
row["policy_override_comments"] = "|".join(
|
||||
policy_override_comments)
|
||||
row["envelope_from"] = record["identifiers"]["envelope_from"]
|
||||
row["header_from"] = record["identifiers"]["header_from"]
|
||||
envelope_to = record["identifiers"]["envelope_to"]
|
||||
row["envelope_to"] = envelope_to
|
||||
dkim_domains = []
|
||||
dkim_selectors = []
|
||||
dkim_results = []
|
||||
for dkim_result in record["auth_results"]["dkim"]:
|
||||
dkim_domains.append(dkim_result["domain"])
|
||||
if "selector" in dkim_result:
|
||||
dkim_selectors.append(dkim_result["selector"])
|
||||
dkim_results.append(dkim_result["result"])
|
||||
row["dkim_domains"] = ",".join(dkim_domains)
|
||||
row["dkim_selectors"] = ",".join(dkim_selectors)
|
||||
row["dkim_results"] = ",".join(dkim_results)
|
||||
spf_domains = []
|
||||
spf_scopes = []
|
||||
spf_results = []
|
||||
for spf_result in record["auth_results"]["spf"]:
|
||||
spf_domains.append(spf_result["domain"])
|
||||
spf_scopes.append(spf_result["scope"])
|
||||
spf_results.append(spf_result["result"])
|
||||
row["spf_domains"] = ",".join(spf_domains)
|
||||
row["spf_scopes"] = ",".join(spf_scopes)
|
||||
row["spf_results"] = ",".join(spf_results)
|
||||
|
||||
writer.writerow(row)
|
||||
csv_file_object.flush()
|
||||
|
||||
return csv_file_object.getvalue()
|
||||
|
||||
|
||||
def _main():
|
||||
"""Called when the module in executed"""
|
||||
arg_parser = ArgumentParser(description="Parses aggregate DMARC reports")
|
||||
arg_parser.add_argument("file_path", nargs="+",
|
||||
help="one or more paths of aggregate report "
|
||||
"files (compressed or uncompressed)")
|
||||
arg_parser.add_argument("-f", "--format", default="json",
|
||||
help="specify JSON or CSV output format")
|
||||
arg_parser.add_argument("-o", "--output",
|
||||
help="output to a file path rather than "
|
||||
"printing to the screen")
|
||||
arg_parser.add_argument("-n", "--nameserver", nargs="+",
|
||||
help="nameservers to query")
|
||||
arg_parser.add_argument("-t", "--timeout",
|
||||
help="number of seconds to wait for an answer "
|
||||
"from DNS (default 6.0)",
|
||||
type=float,
|
||||
default=6.0)
|
||||
arg_parser.add_argument("-v", "--version", action="version",
|
||||
version=__version__)
|
||||
|
||||
args = arg_parser.parse_args()
|
||||
file_paths = []
|
||||
for file_path in args.file_path:
|
||||
file_paths += glob(file_path)
|
||||
file_paths = list(set(file_paths))
|
||||
|
||||
parsed_reports = []
|
||||
for file_path in file_paths:
|
||||
try:
|
||||
report = parse_aggregate_report_file(file_path,
|
||||
nameservers=args.nameserver,
|
||||
timeout=args.timeout)
|
||||
parsed_reports.append(report)
|
||||
except InvalidAggregateReport as error:
|
||||
logger.error("Unable to parse {0}: {1}".format(file_path,
|
||||
error.__str__()))
|
||||
output = ""
|
||||
if args.format.lower() == "json":
|
||||
if len(parsed_reports) == 1:
|
||||
parsed_reports = parsed_reports[0]
|
||||
output = json.dumps(parsed_reports,
|
||||
ensure_ascii=False,
|
||||
indent=2)
|
||||
elif args.format.lower() == "csv":
|
||||
output = parsed_aggregate_report_to_csv(parsed_reports)
|
||||
else:
|
||||
logger.error("Invalid output format: {0}".format(args.format))
|
||||
exit(-1)
|
||||
|
||||
if args.output:
|
||||
with open(args.output, "w", encoding="utf-8", newline="\n") as file:
|
||||
file.write(output)
|
||||
else:
|
||||
print(output)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
_main()
|
||||
2114
parsedmarc/__init__.py
Normal file
1622
parsedmarc/cli.py
Normal file
834
parsedmarc/elastic.py
Normal file
@@ -0,0 +1,834 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from collections import OrderedDict
|
||||
|
||||
from elasticsearch_dsl.search import Q
|
||||
from elasticsearch_dsl import (
|
||||
connections,
|
||||
Object,
|
||||
Document,
|
||||
Index,
|
||||
Nested,
|
||||
InnerDoc,
|
||||
Integer,
|
||||
Text,
|
||||
Boolean,
|
||||
Ip,
|
||||
Date,
|
||||
Search,
|
||||
)
|
||||
from elasticsearch.helpers import reindex
|
||||
|
||||
from parsedmarc.log import logger
|
||||
from parsedmarc.utils import human_timestamp_to_datetime
|
||||
from parsedmarc import InvalidForensicReport
|
||||
|
||||
|
||||
class ElasticsearchError(Exception):
|
||||
"""Raised when an Elasticsearch error occurs"""
|
||||
|
||||
|
||||
class _PolicyOverride(InnerDoc):
|
||||
type = Text()
|
||||
comment = Text()
|
||||
|
||||
|
||||
class _PublishedPolicy(InnerDoc):
|
||||
domain = Text()
|
||||
adkim = Text()
|
||||
aspf = Text()
|
||||
p = Text()
|
||||
sp = Text()
|
||||
pct = Integer()
|
||||
fo = Text()
|
||||
|
||||
|
||||
class _DKIMResult(InnerDoc):
|
||||
domain = Text()
|
||||
selector = Text()
|
||||
result = Text()
|
||||
|
||||
|
||||
class _SPFResult(InnerDoc):
|
||||
domain = Text()
|
||||
scope = Text()
|
||||
results = Text()
|
||||
|
||||
|
||||
class _AggregateReportDoc(Document):
|
||||
class Index:
|
||||
name = "dmarc_aggregate"
|
||||
|
||||
xml_schema = Text()
|
||||
org_name = Text()
|
||||
org_email = Text()
|
||||
org_extra_contact_info = Text()
|
||||
report_id = Text()
|
||||
date_range = Date()
|
||||
date_begin = Date()
|
||||
date_end = Date()
|
||||
errors = Text()
|
||||
published_policy = Object(_PublishedPolicy)
|
||||
source_ip_address = Ip()
|
||||
source_country = Text()
|
||||
source_reverse_dns = Text()
|
||||
source_base_domain = Text()
|
||||
source_type = Text()
|
||||
source_name = Text()
|
||||
message_count = Integer
|
||||
disposition = Text()
|
||||
dkim_aligned = Boolean()
|
||||
spf_aligned = Boolean()
|
||||
passed_dmarc = Boolean()
|
||||
policy_overrides = Nested(_PolicyOverride)
|
||||
header_from = Text()
|
||||
envelope_from = Text()
|
||||
envelope_to = Text()
|
||||
dkim_results = Nested(_DKIMResult)
|
||||
spf_results = Nested(_SPFResult)
|
||||
|
||||
def add_policy_override(self, type_, comment):
|
||||
self.policy_overrides.append(_PolicyOverride(type=type_, comment=comment))
|
||||
|
||||
def add_dkim_result(self, domain, selector, result):
|
||||
self.dkim_results.append(
|
||||
_DKIMResult(domain=domain, selector=selector, result=result)
|
||||
)
|
||||
|
||||
def add_spf_result(self, domain, scope, result):
|
||||
self.spf_results.append(_SPFResult(domain=domain, scope=scope, result=result))
|
||||
|
||||
def save(self, **kwargs):
|
||||
self.passed_dmarc = False
|
||||
self.passed_dmarc = self.spf_aligned or self.dkim_aligned
|
||||
|
||||
return super().save(**kwargs)
|
||||
|
||||
|
||||
class _EmailAddressDoc(InnerDoc):
|
||||
display_name = Text()
|
||||
address = Text()
|
||||
|
||||
|
||||
class _EmailAttachmentDoc(Document):
|
||||
filename = Text()
|
||||
content_type = Text()
|
||||
sha256 = Text()
|
||||
|
||||
|
||||
class _ForensicSampleDoc(InnerDoc):
|
||||
raw = Text()
|
||||
headers = Object()
|
||||
headers_only = Boolean()
|
||||
to = Nested(_EmailAddressDoc)
|
||||
subject = Text()
|
||||
filename_safe_subject = Text()
|
||||
_from = Object(_EmailAddressDoc)
|
||||
date = Date()
|
||||
reply_to = Nested(_EmailAddressDoc)
|
||||
cc = Nested(_EmailAddressDoc)
|
||||
bcc = Nested(_EmailAddressDoc)
|
||||
body = Text()
|
||||
attachments = Nested(_EmailAttachmentDoc)
|
||||
|
||||
def add_to(self, display_name, address):
|
||||
self.to.append(_EmailAddressDoc(display_name=display_name, address=address))
|
||||
|
||||
def add_reply_to(self, display_name, address):
|
||||
self.reply_to.append(
|
||||
_EmailAddressDoc(display_name=display_name, address=address)
|
||||
)
|
||||
|
||||
def add_cc(self, display_name, address):
|
||||
self.cc.append(_EmailAddressDoc(display_name=display_name, address=address))
|
||||
|
||||
def add_bcc(self, display_name, address):
|
||||
self.bcc.append(_EmailAddressDoc(display_name=display_name, address=address))
|
||||
|
||||
def add_attachment(self, filename, content_type, sha256):
|
||||
self.attachments.append(
|
||||
_EmailAttachmentDoc(
|
||||
filename=filename, content_type=content_type, sha256=sha256
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class _ForensicReportDoc(Document):
|
||||
class Index:
|
||||
name = "dmarc_forensic"
|
||||
|
||||
feedback_type = Text()
|
||||
user_agent = Text()
|
||||
version = Text()
|
||||
original_mail_from = Text()
|
||||
arrival_date = Date()
|
||||
domain = Text()
|
||||
original_envelope_id = Text()
|
||||
authentication_results = Text()
|
||||
delivery_results = Text()
|
||||
source_ip_address = Ip()
|
||||
source_country = Text()
|
||||
source_reverse_dns = Text()
|
||||
source_authentication_mechanisms = Text()
|
||||
source_auth_failures = Text()
|
||||
dkim_domain = Text()
|
||||
original_rcpt_to = Text()
|
||||
sample = Object(_ForensicSampleDoc)
|
||||
|
||||
|
||||
class _SMTPTLSFailureDetailsDoc(InnerDoc):
|
||||
result_type = Text()
|
||||
sending_mta_ip = Ip()
|
||||
receiving_mx_helo = Text()
|
||||
receiving_ip = Ip()
|
||||
failed_session_count = Integer()
|
||||
additional_information_uri = Text()
|
||||
failure_reason_code = Text()
|
||||
|
||||
|
||||
class _SMTPTLSPolicyDoc(InnerDoc):
|
||||
policy_domain = Text()
|
||||
policy_type = Text()
|
||||
policy_strings = Text()
|
||||
mx_host_patterns = Text()
|
||||
successful_session_count = Integer()
|
||||
failed_session_count = Integer()
|
||||
failure_details = Nested(_SMTPTLSFailureDetailsDoc)
|
||||
|
||||
def add_failure_details(
|
||||
self,
|
||||
result_type,
|
||||
ip_address,
|
||||
receiving_ip,
|
||||
receiving_mx_helo,
|
||||
failed_session_count,
|
||||
sending_mta_ip=None,
|
||||
receiving_mx_hostname=None,
|
||||
additional_information_uri=None,
|
||||
failure_reason_code=None,
|
||||
):
|
||||
_details = _SMTPTLSFailureDetailsDoc(
|
||||
result_type=result_type,
|
||||
ip_address=ip_address,
|
||||
sending_mta_ip=sending_mta_ip,
|
||||
receiving_mx_hostname=receiving_mx_hostname,
|
||||
receiving_mx_helo=receiving_mx_helo,
|
||||
receiving_ip=receiving_ip,
|
||||
failed_session_count=failed_session_count,
|
||||
additional_information=additional_information_uri,
|
||||
failure_reason_code=failure_reason_code,
|
||||
)
|
||||
self.failure_details.append(_details)
|
||||
|
||||
|
||||
class _SMTPTLSReportDoc(Document):
|
||||
class Index:
|
||||
name = "smtp_tls"
|
||||
|
||||
organization_name = Text()
|
||||
date_range = Date()
|
||||
date_begin = Date()
|
||||
date_end = Date()
|
||||
contact_info = Text()
|
||||
report_id = Text()
|
||||
policies = Nested(_SMTPTLSPolicyDoc)
|
||||
|
||||
def add_policy(
|
||||
self,
|
||||
policy_type,
|
||||
policy_domain,
|
||||
successful_session_count,
|
||||
failed_session_count,
|
||||
policy_string=None,
|
||||
mx_host_patterns=None,
|
||||
failure_details=None,
|
||||
):
|
||||
self.policies.append(
|
||||
policy_type=policy_type,
|
||||
policy_domain=policy_domain,
|
||||
successful_session_count=successful_session_count,
|
||||
failed_session_count=failed_session_count,
|
||||
policy_string=policy_string,
|
||||
mx_host_patterns=mx_host_patterns,
|
||||
failure_details=failure_details,
|
||||
)
|
||||
|
||||
|
||||
class AlreadySaved(ValueError):
|
||||
"""Raised when a report to be saved matches an existing report"""
|
||||
|
||||
|
||||
def set_hosts(
|
||||
hosts,
|
||||
use_ssl=False,
|
||||
ssl_cert_path=None,
|
||||
username=None,
|
||||
password=None,
|
||||
apiKey=None,
|
||||
timeout=60.0,
|
||||
):
|
||||
"""
|
||||
Sets the Elasticsearch hosts to use
|
||||
|
||||
Args:
|
||||
hosts (str): A single hostname or URL, or list of hostnames or URLs
|
||||
use_ssl (bool): Use a HTTPS connection to the server
|
||||
ssl_cert_path (str): Path to the certificate chain
|
||||
username (str): The username to use for authentication
|
||||
password (str): The password to use for authentication
|
||||
apiKey (str): The Base64 encoded API key to use for authentication
|
||||
timeout (float): Timeout in seconds
|
||||
"""
|
||||
if not isinstance(hosts, list):
|
||||
hosts = [hosts]
|
||||
conn_params = {"hosts": hosts, "timeout": timeout}
|
||||
if use_ssl:
|
||||
conn_params["use_ssl"] = True
|
||||
if ssl_cert_path:
|
||||
conn_params["verify_certs"] = True
|
||||
conn_params["ca_certs"] = ssl_cert_path
|
||||
else:
|
||||
conn_params["verify_certs"] = False
|
||||
if username:
|
||||
conn_params["http_auth"] = username + ":" + password
|
||||
if apiKey:
|
||||
conn_params["api_key"] = apiKey
|
||||
connections.create_connection(**conn_params)
|
||||
|
||||
|
||||
def create_indexes(names, settings=None):
|
||||
"""
|
||||
Create Elasticsearch indexes
|
||||
|
||||
Args:
|
||||
names (list): A list of index names
|
||||
settings (dict): Index settings
|
||||
|
||||
"""
|
||||
for name in names:
|
||||
index = Index(name)
|
||||
try:
|
||||
if not index.exists():
|
||||
logger.debug("Creating Elasticsearch index: {0}".format(name))
|
||||
if settings is None:
|
||||
index.settings(number_of_shards=1, number_of_replicas=0)
|
||||
else:
|
||||
index.settings(**settings)
|
||||
index.create()
|
||||
except Exception as e:
|
||||
raise ElasticsearchError("Elasticsearch error: {0}".format(e.__str__()))
|
||||
|
||||
|
||||
def migrate_indexes(aggregate_indexes=None, forensic_indexes=None):
|
||||
"""
|
||||
Updates index mappings
|
||||
|
||||
Args:
|
||||
aggregate_indexes (list): A list of aggregate index names
|
||||
forensic_indexes (list): A list of forensic index names
|
||||
"""
|
||||
version = 2
|
||||
if aggregate_indexes is None:
|
||||
aggregate_indexes = []
|
||||
if forensic_indexes is None:
|
||||
forensic_indexes = []
|
||||
for aggregate_index_name in aggregate_indexes:
|
||||
if not Index(aggregate_index_name).exists():
|
||||
continue
|
||||
aggregate_index = Index(aggregate_index_name)
|
||||
doc = "doc"
|
||||
fo_field = "published_policy.fo"
|
||||
fo = "fo"
|
||||
fo_mapping = aggregate_index.get_field_mapping(fields=[fo_field])
|
||||
fo_mapping = fo_mapping[list(fo_mapping.keys())[0]]["mappings"]
|
||||
if doc not in fo_mapping:
|
||||
continue
|
||||
|
||||
fo_mapping = fo_mapping[doc][fo_field]["mapping"][fo]
|
||||
fo_type = fo_mapping["type"]
|
||||
if fo_type == "long":
|
||||
new_index_name = "{0}-v{1}".format(aggregate_index_name, version)
|
||||
body = {
|
||||
"properties": {
|
||||
"published_policy.fo": {
|
||||
"type": "text",
|
||||
"fields": {"keyword": {"type": "keyword", "ignore_above": 256}},
|
||||
}
|
||||
}
|
||||
}
|
||||
Index(new_index_name).create()
|
||||
Index(new_index_name).put_mapping(doc_type=doc, body=body)
|
||||
reindex(connections.get_connection(), aggregate_index_name, new_index_name)
|
||||
Index(aggregate_index_name).delete()
|
||||
|
||||
for forensic_index in forensic_indexes:
|
||||
pass
|
||||
|
||||
|
||||
def save_aggregate_report_to_elasticsearch(
|
||||
aggregate_report,
|
||||
index_suffix=None,
|
||||
index_prefix=None,
|
||||
monthly_indexes=False,
|
||||
number_of_shards=1,
|
||||
number_of_replicas=0,
|
||||
):
|
||||
"""
|
||||
Saves a parsed DMARC aggregate report to Elasticsearch
|
||||
|
||||
Args:
|
||||
aggregate_report (OrderedDict): A parsed forensic report
|
||||
index_suffix (str): The suffix of the name of the index to save to
|
||||
index_prefix (str): The prefix of the name of the index to save to
|
||||
monthly_indexes (bool): Use monthly indexes instead of daily indexes
|
||||
number_of_shards (int): The number of shards to use in the index
|
||||
number_of_replicas (int): The number of replicas to use in the index
|
||||
|
||||
Raises:
|
||||
AlreadySaved
|
||||
"""
|
||||
logger.info("Saving aggregate report to Elasticsearch")
|
||||
aggregate_report = aggregate_report.copy()
|
||||
metadata = aggregate_report["report_metadata"]
|
||||
org_name = metadata["org_name"]
|
||||
report_id = metadata["report_id"]
|
||||
domain = aggregate_report["policy_published"]["domain"]
|
||||
begin_date = human_timestamp_to_datetime(metadata["begin_date"], to_utc=True)
|
||||
end_date = human_timestamp_to_datetime(metadata["end_date"], to_utc=True)
|
||||
begin_date_human = begin_date.strftime("%Y-%m-%d %H:%M:%SZ")
|
||||
end_date_human = end_date.strftime("%Y-%m-%d %H:%M:%SZ")
|
||||
if monthly_indexes:
|
||||
index_date = begin_date.strftime("%Y-%m")
|
||||
else:
|
||||
index_date = begin_date.strftime("%Y-%m-%d")
|
||||
aggregate_report["begin_date"] = begin_date
|
||||
aggregate_report["end_date"] = end_date
|
||||
date_range = [aggregate_report["begin_date"], aggregate_report["end_date"]]
|
||||
|
||||
org_name_query = Q(dict(match_phrase=dict(org_name=org_name)))
|
||||
report_id_query = Q(dict(match_phrase=dict(report_id=report_id)))
|
||||
domain_query = Q(dict(match_phrase={"published_policy.domain": domain}))
|
||||
begin_date_query = Q(dict(match=dict(date_begin=begin_date)))
|
||||
end_date_query = Q(dict(match=dict(date_end=end_date)))
|
||||
|
||||
if index_suffix is not None:
|
||||
search_index = "dmarc_aggregate_{0}*".format(index_suffix)
|
||||
else:
|
||||
search_index = "dmarc_aggregate*"
|
||||
if index_prefix is not None:
|
||||
search_index = "{0}{1}".format(index_prefix, search_index)
|
||||
search = Search(index=search_index)
|
||||
query = org_name_query & report_id_query & domain_query
|
||||
query = query & begin_date_query & end_date_query
|
||||
search.query = query
|
||||
|
||||
try:
|
||||
existing = search.execute()
|
||||
except Exception as error_:
|
||||
raise ElasticsearchError(
|
||||
"Elasticsearch's search for existing report \
|
||||
error: {}".format(error_.__str__())
|
||||
)
|
||||
|
||||
if len(existing) > 0:
|
||||
raise AlreadySaved(
|
||||
"An aggregate report ID {0} from {1} about {2} "
|
||||
"with a date range of {3} UTC to {4} UTC already "
|
||||
"exists in "
|
||||
"Elasticsearch".format(
|
||||
report_id, org_name, domain, begin_date_human, end_date_human
|
||||
)
|
||||
)
|
||||
published_policy = _PublishedPolicy(
|
||||
domain=aggregate_report["policy_published"]["domain"],
|
||||
adkim=aggregate_report["policy_published"]["adkim"],
|
||||
aspf=aggregate_report["policy_published"]["aspf"],
|
||||
p=aggregate_report["policy_published"]["p"],
|
||||
sp=aggregate_report["policy_published"]["sp"],
|
||||
pct=aggregate_report["policy_published"]["pct"],
|
||||
fo=aggregate_report["policy_published"]["fo"],
|
||||
)
|
||||
|
||||
for record in aggregate_report["records"]:
|
||||
agg_doc = _AggregateReportDoc(
|
||||
xml_schema=aggregate_report["xml_schema"],
|
||||
org_name=metadata["org_name"],
|
||||
org_email=metadata["org_email"],
|
||||
org_extra_contact_info=metadata["org_extra_contact_info"],
|
||||
report_id=metadata["report_id"],
|
||||
date_range=date_range,
|
||||
date_begin=aggregate_report["begin_date"],
|
||||
date_end=aggregate_report["end_date"],
|
||||
errors=metadata["errors"],
|
||||
published_policy=published_policy,
|
||||
source_ip_address=record["source"]["ip_address"],
|
||||
source_country=record["source"]["country"],
|
||||
source_reverse_dns=record["source"]["reverse_dns"],
|
||||
source_base_domain=record["source"]["base_domain"],
|
||||
source_type=record["source"]["type"],
|
||||
source_name=record["source"]["name"],
|
||||
message_count=record["count"],
|
||||
disposition=record["policy_evaluated"]["disposition"],
|
||||
dkim_aligned=record["policy_evaluated"]["dkim"] is not None
|
||||
and record["policy_evaluated"]["dkim"].lower() == "pass",
|
||||
spf_aligned=record["policy_evaluated"]["spf"] is not None
|
||||
and record["policy_evaluated"]["spf"].lower() == "pass",
|
||||
header_from=record["identifiers"]["header_from"],
|
||||
envelope_from=record["identifiers"]["envelope_from"],
|
||||
envelope_to=record["identifiers"]["envelope_to"],
|
||||
)
|
||||
|
||||
for override in record["policy_evaluated"]["policy_override_reasons"]:
|
||||
agg_doc.add_policy_override(
|
||||
type_=override["type"], comment=override["comment"]
|
||||
)
|
||||
|
||||
for dkim_result in record["auth_results"]["dkim"]:
|
||||
agg_doc.add_dkim_result(
|
||||
domain=dkim_result["domain"],
|
||||
selector=dkim_result["selector"],
|
||||
result=dkim_result["result"],
|
||||
)
|
||||
|
||||
for spf_result in record["auth_results"]["spf"]:
|
||||
agg_doc.add_spf_result(
|
||||
domain=spf_result["domain"],
|
||||
scope=spf_result["scope"],
|
||||
result=spf_result["result"],
|
||||
)
|
||||
|
||||
index = "dmarc_aggregate"
|
||||
if index_suffix:
|
||||
index = "{0}_{1}".format(index, index_suffix)
|
||||
if index_prefix:
|
||||
index = "{0}{1}".format(index_prefix, index)
|
||||
|
||||
index = "{0}-{1}".format(index, index_date)
|
||||
index_settings = dict(
|
||||
number_of_shards=number_of_shards, number_of_replicas=number_of_replicas
|
||||
)
|
||||
create_indexes([index], index_settings)
|
||||
agg_doc.meta.index = index
|
||||
|
||||
try:
|
||||
agg_doc.save()
|
||||
except Exception as e:
|
||||
raise ElasticsearchError("Elasticsearch error: {0}".format(e.__str__()))
|
||||
|
||||
|
||||
def save_forensic_report_to_elasticsearch(
|
||||
forensic_report,
|
||||
index_suffix=None,
|
||||
index_prefix=None,
|
||||
monthly_indexes=False,
|
||||
number_of_shards=1,
|
||||
number_of_replicas=0,
|
||||
):
|
||||
"""
|
||||
Saves a parsed DMARC forensic report to Elasticsearch
|
||||
|
||||
Args:
|
||||
forensic_report (OrderedDict): A parsed forensic report
|
||||
index_suffix (str): The suffix of the name of the index to save to
|
||||
index_prefix (str): The prefix of the name of the index to save to
|
||||
monthly_indexes (bool): Use monthly indexes instead of daily
|
||||
indexes
|
||||
number_of_shards (int): The number of shards to use in the index
|
||||
number_of_replicas (int): The number of replicas to use in the
|
||||
index
|
||||
|
||||
Raises:
|
||||
AlreadySaved
|
||||
|
||||
"""
|
||||
logger.info("Saving forensic report to Elasticsearch")
|
||||
forensic_report = forensic_report.copy()
|
||||
sample_date = None
|
||||
if forensic_report["parsed_sample"]["date"] is not None:
|
||||
sample_date = forensic_report["parsed_sample"]["date"]
|
||||
sample_date = human_timestamp_to_datetime(sample_date)
|
||||
original_headers = forensic_report["parsed_sample"]["headers"]
|
||||
headers = OrderedDict()
|
||||
for original_header in original_headers:
|
||||
headers[original_header.lower()] = original_headers[original_header]
|
||||
|
||||
arrival_date = human_timestamp_to_datetime(forensic_report["arrival_date_utc"])
|
||||
arrival_date_epoch_milliseconds = int(arrival_date.timestamp() * 1000)
|
||||
|
||||
if index_suffix is not None:
|
||||
search_index = "dmarc_forensic_{0}*".format(index_suffix)
|
||||
else:
|
||||
search_index = "dmarc_forensic*"
|
||||
if index_prefix is not None:
|
||||
search_index = "{0}{1}".format(index_prefix, search_index)
|
||||
search = Search(index=search_index)
|
||||
q = Q(dict(match=dict(arrival_date=arrival_date_epoch_milliseconds)))
|
||||
|
||||
from_ = None
|
||||
to_ = None
|
||||
subject = None
|
||||
if "from" in headers:
|
||||
# We convert the FROM header from a string list to a flat string.
|
||||
headers["from"] = headers["from"][0]
|
||||
if headers["from"][0] == "":
|
||||
headers["from"] = headers["from"][1]
|
||||
else:
|
||||
headers["from"] = " <".join(headers["from"]) + ">"
|
||||
|
||||
from_ = dict()
|
||||
from_["sample.headers.from"] = headers["from"]
|
||||
from_query = Q(dict(match_phrase=from_))
|
||||
q = q & from_query
|
||||
if "to" in headers:
|
||||
# We convert the TO header from a string list to a flat string.
|
||||
headers["to"] = headers["to"][0]
|
||||
if headers["to"][0] == "":
|
||||
headers["to"] = headers["to"][1]
|
||||
else:
|
||||
headers["to"] = " <".join(headers["to"]) + ">"
|
||||
|
||||
to_ = dict()
|
||||
to_["sample.headers.to"] = headers["to"]
|
||||
to_query = Q(dict(match_phrase=to_))
|
||||
q = q & to_query
|
||||
if "subject" in headers:
|
||||
subject = headers["subject"]
|
||||
subject_query = {"match_phrase": {"sample.headers.subject": subject}}
|
||||
q = q & Q(subject_query)
|
||||
|
||||
search.query = q
|
||||
existing = search.execute()
|
||||
|
||||
if len(existing) > 0:
|
||||
raise AlreadySaved(
|
||||
"A forensic sample to {0} from {1} "
|
||||
"with a subject of {2} and arrival date of {3} "
|
||||
"already exists in "
|
||||
"Elasticsearch".format(
|
||||
to_, from_, subject, forensic_report["arrival_date_utc"]
|
||||
)
|
||||
)
|
||||
|
||||
parsed_sample = forensic_report["parsed_sample"]
|
||||
sample = _ForensicSampleDoc(
|
||||
raw=forensic_report["sample"],
|
||||
headers=headers,
|
||||
headers_only=forensic_report["sample_headers_only"],
|
||||
date=sample_date,
|
||||
subject=forensic_report["parsed_sample"]["subject"],
|
||||
filename_safe_subject=parsed_sample["filename_safe_subject"],
|
||||
body=forensic_report["parsed_sample"]["body"],
|
||||
)
|
||||
|
||||
for address in forensic_report["parsed_sample"]["to"]:
|
||||
sample.add_to(display_name=address["display_name"], address=address["address"])
|
||||
for address in forensic_report["parsed_sample"]["reply_to"]:
|
||||
sample.add_reply_to(
|
||||
display_name=address["display_name"], address=address["address"]
|
||||
)
|
||||
for address in forensic_report["parsed_sample"]["cc"]:
|
||||
sample.add_cc(display_name=address["display_name"], address=address["address"])
|
||||
for address in forensic_report["parsed_sample"]["bcc"]:
|
||||
sample.add_bcc(display_name=address["display_name"], address=address["address"])
|
||||
for attachment in forensic_report["parsed_sample"]["attachments"]:
|
||||
sample.add_attachment(
|
||||
filename=attachment["filename"],
|
||||
content_type=attachment["mail_content_type"],
|
||||
sha256=attachment["sha256"],
|
||||
)
|
||||
try:
|
||||
forensic_doc = _ForensicReportDoc(
|
||||
feedback_type=forensic_report["feedback_type"],
|
||||
user_agent=forensic_report["user_agent"],
|
||||
version=forensic_report["version"],
|
||||
original_mail_from=forensic_report["original_mail_from"],
|
||||
arrival_date=arrival_date_epoch_milliseconds,
|
||||
domain=forensic_report["reported_domain"],
|
||||
original_envelope_id=forensic_report["original_envelope_id"],
|
||||
authentication_results=forensic_report["authentication_results"],
|
||||
delivery_results=forensic_report["delivery_result"],
|
||||
source_ip_address=forensic_report["source"]["ip_address"],
|
||||
source_country=forensic_report["source"]["country"],
|
||||
source_reverse_dns=forensic_report["source"]["reverse_dns"],
|
||||
source_base_domain=forensic_report["source"]["base_domain"],
|
||||
authentication_mechanisms=forensic_report["authentication_mechanisms"],
|
||||
auth_failure=forensic_report["auth_failure"],
|
||||
dkim_domain=forensic_report["dkim_domain"],
|
||||
original_rcpt_to=forensic_report["original_rcpt_to"],
|
||||
sample=sample,
|
||||
)
|
||||
|
||||
index = "dmarc_forensic"
|
||||
if index_suffix:
|
||||
index = "{0}_{1}".format(index, index_suffix)
|
||||
if index_prefix:
|
||||
index = "{0}{1}".format(index_prefix, index)
|
||||
if monthly_indexes:
|
||||
index_date = arrival_date.strftime("%Y-%m")
|
||||
else:
|
||||
index_date = arrival_date.strftime("%Y-%m-%d")
|
||||
index = "{0}-{1}".format(index, index_date)
|
||||
index_settings = dict(
|
||||
number_of_shards=number_of_shards, number_of_replicas=number_of_replicas
|
||||
)
|
||||
create_indexes([index], index_settings)
|
||||
forensic_doc.meta.index = index
|
||||
try:
|
||||
forensic_doc.save()
|
||||
except Exception as e:
|
||||
raise ElasticsearchError("Elasticsearch error: {0}".format(e.__str__()))
|
||||
except KeyError as e:
|
||||
raise InvalidForensicReport(
|
||||
"Forensic report missing required field: {0}".format(e.__str__())
|
||||
)
|
||||
|
||||
|
||||
def save_smtp_tls_report_to_elasticsearch(
|
||||
report,
|
||||
index_suffix=None,
|
||||
index_prefix=None,
|
||||
monthly_indexes=False,
|
||||
number_of_shards=1,
|
||||
number_of_replicas=0,
|
||||
):
|
||||
"""
|
||||
Saves a parsed SMTP TLS report to Elasticsearch
|
||||
|
||||
Args:
|
||||
report (OrderedDict): A parsed SMTP TLS report
|
||||
index_suffix (str): The suffix of the name of the index to save to
|
||||
index_prefix (str): The prefix of the name of the index to save to
|
||||
monthly_indexes (bool): Use monthly indexes instead of daily indexes
|
||||
number_of_shards (int): The number of shards to use in the index
|
||||
number_of_replicas (int): The number of replicas to use in the index
|
||||
|
||||
Raises:
|
||||
AlreadySaved
|
||||
"""
|
||||
logger.info("Saving smtp tls report to Elasticsearch")
|
||||
org_name = report["organization_name"]
|
||||
report_id = report["report_id"]
|
||||
begin_date = human_timestamp_to_datetime(report["begin_date"], to_utc=True)
|
||||
end_date = human_timestamp_to_datetime(report["end_date"], to_utc=True)
|
||||
begin_date_human = begin_date.strftime("%Y-%m-%d %H:%M:%SZ")
|
||||
end_date_human = end_date.strftime("%Y-%m-%d %H:%M:%SZ")
|
||||
if monthly_indexes:
|
||||
index_date = begin_date.strftime("%Y-%m")
|
||||
else:
|
||||
index_date = begin_date.strftime("%Y-%m-%d")
|
||||
report["begin_date"] = begin_date
|
||||
report["end_date"] = end_date
|
||||
|
||||
org_name_query = Q(dict(match_phrase=dict(org_name=org_name)))
|
||||
report_id_query = Q(dict(match_phrase=dict(report_id=report_id)))
|
||||
begin_date_query = Q(dict(match=dict(date_begin=begin_date)))
|
||||
end_date_query = Q(dict(match=dict(date_end=end_date)))
|
||||
|
||||
if index_suffix is not None:
|
||||
search_index = "smtp_tls_{0}*".format(index_suffix)
|
||||
else:
|
||||
search_index = "smtp_tls*"
|
||||
if index_prefix is not None:
|
||||
search_index = "{0}{1}".format(index_prefix, search_index)
|
||||
search = Search(index=search_index)
|
||||
query = org_name_query & report_id_query
|
||||
query = query & begin_date_query & end_date_query
|
||||
search.query = query
|
||||
|
||||
try:
|
||||
existing = search.execute()
|
||||
except Exception as error_:
|
||||
raise ElasticsearchError(
|
||||
"Elasticsearch's search for existing report \
|
||||
error: {}".format(error_.__str__())
|
||||
)
|
||||
|
||||
if len(existing) > 0:
|
||||
raise AlreadySaved(
|
||||
f"An SMTP TLS report ID {report_id} from "
|
||||
f" {org_name} with a date range of "
|
||||
f"{begin_date_human} UTC to "
|
||||
f"{end_date_human} UTC already "
|
||||
"exists in Elasticsearch"
|
||||
)
|
||||
|
||||
index = "smtp_tls"
|
||||
if index_suffix:
|
||||
index = "{0}_{1}".format(index, index_suffix)
|
||||
if index_prefix:
|
||||
index = "{0}{1}".format(index_prefix, index)
|
||||
index = "{0}-{1}".format(index, index_date)
|
||||
index_settings = dict(
|
||||
number_of_shards=number_of_shards, number_of_replicas=number_of_replicas
|
||||
)
|
||||
|
||||
smtp_tls_doc = _SMTPTLSReportDoc(
|
||||
org_name=report["organization_name"],
|
||||
date_range=[report["begin_date"], report["end_date"]],
|
||||
date_begin=report["begin_date"],
|
||||
date_end=report["end_date"],
|
||||
contact_info=report["contact_info"],
|
||||
report_id=report["report_id"],
|
||||
)
|
||||
|
||||
for policy in report["policies"]:
|
||||
policy_strings = None
|
||||
mx_host_patterns = None
|
||||
if "policy_strings" in policy:
|
||||
policy_strings = policy["policy_strings"]
|
||||
if "mx_host_patterns" in policy:
|
||||
mx_host_patterns = policy["mx_host_patterns"]
|
||||
policy_doc = _SMTPTLSPolicyDoc(
|
||||
policy_domain=policy["policy_domain"],
|
||||
policy_type=policy["policy_type"],
|
||||
succesful_session_count=policy["successful_session_count"],
|
||||
failed_session_count=policy["failed_session_count"],
|
||||
policy_string=policy_strings,
|
||||
mx_host_patterns=mx_host_patterns,
|
||||
)
|
||||
if "failure_details" in policy:
|
||||
for failure_detail in policy["failure_details"]:
|
||||
receiving_mx_hostname = None
|
||||
additional_information_uri = None
|
||||
failure_reason_code = None
|
||||
ip_address = None
|
||||
receiving_ip = None
|
||||
receiving_mx_helo = None
|
||||
sending_mta_ip = None
|
||||
|
||||
if "receiving_mx_hostname" in failure_detail:
|
||||
receiving_mx_hostname = failure_detail["receiving_mx_hostname"]
|
||||
if "additional_information_uri" in failure_detail:
|
||||
additional_information_uri = failure_detail[
|
||||
"additional_information_uri"
|
||||
]
|
||||
if "failure_reason_code" in failure_detail:
|
||||
failure_reason_code = failure_detail["failure_reason_code"]
|
||||
if "ip_address" in failure_detail:
|
||||
ip_address = failure_detail["ip_address"]
|
||||
if "receiving_ip" in failure_detail:
|
||||
receiving_ip = failure_detail["receiving_ip"]
|
||||
if "receiving_mx_helo" in failure_detail:
|
||||
receiving_mx_helo = failure_detail["receiving_mx_helo"]
|
||||
if "sending_mta_ip" in failure_detail:
|
||||
sending_mta_ip = failure_detail["sending_mta_ip"]
|
||||
policy_doc.add_failure_details(
|
||||
result_type=failure_detail["result_type"],
|
||||
ip_address=ip_address,
|
||||
receiving_ip=receiving_ip,
|
||||
receiving_mx_helo=receiving_mx_helo,
|
||||
failed_session_count=failure_detail["failed_session_count"],
|
||||
sending_mta_ip=sending_mta_ip,
|
||||
receiving_mx_hostname=receiving_mx_hostname,
|
||||
additional_information_uri=additional_information_uri,
|
||||
failure_reason_code=failure_reason_code,
|
||||
)
|
||||
smtp_tls_doc.policies.append(policy_doc)
|
||||
|
||||
create_indexes([index], index_settings)
|
||||
smtp_tls_doc.meta.index = index
|
||||
|
||||
try:
|
||||
smtp_tls_doc.save()
|
||||
except Exception as e:
|
||||
raise ElasticsearchError("Elasticsearch error: {0}".format(e.__str__()))
|
||||
67
parsedmarc/gelf.py
Normal file
@@ -0,0 +1,67 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import logging
|
||||
import logging.handlers
|
||||
import json
|
||||
import threading
|
||||
|
||||
from parsedmarc import (
|
||||
parsed_aggregate_reports_to_csv_rows,
|
||||
parsed_forensic_reports_to_csv_rows,
|
||||
parsed_smtp_tls_reports_to_csv_rows,
|
||||
)
|
||||
from pygelf import GelfTcpHandler, GelfUdpHandler, GelfTlsHandler
|
||||
|
||||
|
||||
log_context_data = threading.local()
|
||||
|
||||
|
||||
class ContextFilter(logging.Filter):
|
||||
def filter(self, record):
|
||||
record.parsedmarc = log_context_data.parsedmarc
|
||||
return True
|
||||
|
||||
|
||||
class GelfClient(object):
|
||||
"""A client for the Graylog Extended Log Format"""
|
||||
|
||||
def __init__(self, host, port, mode):
|
||||
"""
|
||||
Initializes the GelfClient
|
||||
Args:
|
||||
host (str): The GELF host
|
||||
port (int): The GELF port
|
||||
mode (str): The GELF transport mode
|
||||
"""
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.logger = logging.getLogger("parsedmarc_syslog")
|
||||
self.logger.setLevel(logging.INFO)
|
||||
self.logger.addFilter(ContextFilter())
|
||||
self.gelf_mode = {
|
||||
"udp": GelfUdpHandler,
|
||||
"tcp": GelfTcpHandler,
|
||||
"tls": GelfTlsHandler,
|
||||
}
|
||||
self.handler = self.gelf_mode[mode](
|
||||
host=self.host, port=self.port, include_extra_fields=True
|
||||
)
|
||||
self.logger.addHandler(self.handler)
|
||||
|
||||
def save_aggregate_report_to_gelf(self, aggregate_reports):
|
||||
rows = parsed_aggregate_reports_to_csv_rows(aggregate_reports)
|
||||
for row in rows:
|
||||
log_context_data.parsedmarc = row
|
||||
self.logger.info("parsedmarc aggregate report")
|
||||
|
||||
log_context_data.parsedmarc = None
|
||||
|
||||
def save_forensic_report_to_gelf(self, forensic_reports):
|
||||
rows = parsed_forensic_reports_to_csv_rows(forensic_reports)
|
||||
for row in rows:
|
||||
self.logger.info(json.dumps(row))
|
||||
|
||||
def save_smtp_tls_report_to_gelf(self, smtp_tls_reports):
|
||||
rows = parsed_smtp_tls_reports_to_csv_rows(smtp_tls_reports)
|
||||
for row in rows:
|
||||
self.logger.info(json.dumps(row))
|
||||
190
parsedmarc/kafkaclient.py
Normal file
@@ -0,0 +1,190 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import json
|
||||
from ssl import create_default_context
|
||||
|
||||
from kafka import KafkaProducer
|
||||
from kafka.errors import NoBrokersAvailable, UnknownTopicOrPartitionError
|
||||
from collections import OrderedDict
|
||||
from parsedmarc.utils import human_timestamp_to_datetime
|
||||
|
||||
from parsedmarc import __version__
|
||||
from parsedmarc.log import logger
|
||||
|
||||
|
||||
class KafkaError(RuntimeError):
|
||||
"""Raised when a Kafka error occurs"""
|
||||
|
||||
|
||||
class KafkaClient(object):
|
||||
def __init__(
|
||||
self, kafka_hosts, ssl=False, username=None, password=None, ssl_context=None
|
||||
):
|
||||
"""
|
||||
Initializes the Kafka client
|
||||
Args:
|
||||
kafka_hosts (list): A list of Kafka hostnames
|
||||
(with optional port numbers)
|
||||
ssl (bool): Use a SSL/TLS connection
|
||||
username (str): An optional username
|
||||
password (str): An optional password
|
||||
ssl_context: SSL context options
|
||||
|
||||
Notes:
|
||||
``use_ssl=True`` is implied when a username or password are
|
||||
supplied.
|
||||
|
||||
When using Azure Event Hubs, the username is literally
|
||||
``$ConnectionString``, and the password is the
|
||||
Azure Event Hub connection string.
|
||||
"""
|
||||
config = dict(
|
||||
value_serializer=lambda v: json.dumps(v).encode("utf-8"),
|
||||
bootstrap_servers=kafka_hosts,
|
||||
client_id="parsedmarc-{0}".format(__version__),
|
||||
)
|
||||
if ssl or username or password:
|
||||
config["security_protocol"] = "SSL"
|
||||
config["ssl_context"] = ssl_context or create_default_context()
|
||||
if username or password:
|
||||
config["sasl_plain_username"] = username or ""
|
||||
config["sasl_plain_password"] = password or ""
|
||||
try:
|
||||
self.producer = KafkaProducer(**config)
|
||||
except NoBrokersAvailable:
|
||||
raise KafkaError("No Kafka brokers available")
|
||||
|
||||
@staticmethod
|
||||
def strip_metadata(report):
|
||||
"""
|
||||
Duplicates org_name, org_email and report_id into JSON root
|
||||
and removes report_metadata key to bring it more inline
|
||||
with Elastic output.
|
||||
"""
|
||||
report["org_name"] = report["report_metadata"]["org_name"]
|
||||
report["org_email"] = report["report_metadata"]["org_email"]
|
||||
report["report_id"] = report["report_metadata"]["report_id"]
|
||||
report.pop("report_metadata")
|
||||
|
||||
return report
|
||||
|
||||
@staticmethod
|
||||
def generate_daterange(report):
|
||||
"""
|
||||
Creates a date_range timestamp with format YYYY-MM-DD-T-HH:MM:SS
|
||||
based on begin and end dates for easier parsing in Kibana.
|
||||
|
||||
Move to utils to avoid duplication w/ elastic?
|
||||
"""
|
||||
|
||||
metadata = report["report_metadata"]
|
||||
begin_date = human_timestamp_to_datetime(metadata["begin_date"])
|
||||
end_date = human_timestamp_to_datetime(metadata["end_date"])
|
||||
begin_date_human = begin_date.strftime("%Y-%m-%dT%H:%M:%S")
|
||||
end_date_human = end_date.strftime("%Y-%m-%dT%H:%M:%S")
|
||||
date_range = [begin_date_human, end_date_human]
|
||||
logger.debug("date_range is {}".format(date_range))
|
||||
return date_range
|
||||
|
||||
def save_aggregate_reports_to_kafka(self, aggregate_reports, aggregate_topic):
|
||||
"""
|
||||
Saves aggregate DMARC reports to Kafka
|
||||
|
||||
Args:
|
||||
aggregate_reports (list): A list of aggregate report dictionaries
|
||||
to save to Kafka
|
||||
aggregate_topic (str): The name of the Kafka topic
|
||||
|
||||
"""
|
||||
if isinstance(aggregate_reports, dict) or isinstance(
|
||||
aggregate_reports, OrderedDict
|
||||
):
|
||||
aggregate_reports = [aggregate_reports]
|
||||
|
||||
if len(aggregate_reports) < 1:
|
||||
return
|
||||
|
||||
for report in aggregate_reports:
|
||||
report["date_range"] = self.generate_daterange(report)
|
||||
report = self.strip_metadata(report)
|
||||
|
||||
for slice in report["records"]:
|
||||
slice["date_range"] = report["date_range"]
|
||||
slice["org_name"] = report["org_name"]
|
||||
slice["org_email"] = report["org_email"]
|
||||
slice["policy_published"] = report["policy_published"]
|
||||
slice["report_id"] = report["report_id"]
|
||||
logger.debug("Sending slice.")
|
||||
try:
|
||||
logger.debug("Saving aggregate report to Kafka")
|
||||
self.producer.send(aggregate_topic, slice)
|
||||
except UnknownTopicOrPartitionError:
|
||||
raise KafkaError(
|
||||
"Kafka error: Unknown topic or partition on broker"
|
||||
)
|
||||
except Exception as e:
|
||||
raise KafkaError("Kafka error: {0}".format(e.__str__()))
|
||||
try:
|
||||
self.producer.flush()
|
||||
except Exception as e:
|
||||
raise KafkaError("Kafka error: {0}".format(e.__str__()))
|
||||
|
||||
def save_forensic_reports_to_kafka(self, forensic_reports, forensic_topic):
|
||||
"""
|
||||
Saves forensic DMARC reports to Kafka, sends individual
|
||||
records (slices) since Kafka requires messages to be <= 1MB
|
||||
by default.
|
||||
|
||||
Args:
|
||||
forensic_reports (list): A list of forensic report dicts
|
||||
to save to Kafka
|
||||
forensic_topic (str): The name of the Kafka topic
|
||||
|
||||
"""
|
||||
if isinstance(forensic_reports, dict):
|
||||
forensic_reports = [forensic_reports]
|
||||
|
||||
if len(forensic_reports) < 1:
|
||||
return
|
||||
|
||||
try:
|
||||
logger.debug("Saving forensic reports to Kafka")
|
||||
self.producer.send(forensic_topic, forensic_reports)
|
||||
except UnknownTopicOrPartitionError:
|
||||
raise KafkaError("Kafka error: Unknown topic or partition on broker")
|
||||
except Exception as e:
|
||||
raise KafkaError("Kafka error: {0}".format(e.__str__()))
|
||||
try:
|
||||
self.producer.flush()
|
||||
except Exception as e:
|
||||
raise KafkaError("Kafka error: {0}".format(e.__str__()))
|
||||
|
||||
def save_smtp_tls_reports_to_kafka(self, smtp_tls_reports, smtp_tls_topic):
|
||||
"""
|
||||
Saves SMTP TLS reports to Kafka, sends individual
|
||||
records (slices) since Kafka requires messages to be <= 1MB
|
||||
by default.
|
||||
|
||||
Args:
|
||||
smtp_tls_reports (list): A list of forensic report dicts
|
||||
to save to Kafka
|
||||
smtp_tls_topic (str): The name of the Kafka topic
|
||||
|
||||
"""
|
||||
if isinstance(smtp_tls_reports, dict):
|
||||
smtp_tls_reports = [smtp_tls_reports]
|
||||
|
||||
if len(smtp_tls_reports) < 1:
|
||||
return
|
||||
|
||||
try:
|
||||
logger.debug("Saving forensic reports to Kafka")
|
||||
self.producer.send(smtp_tls_topic, smtp_tls_reports)
|
||||
except UnknownTopicOrPartitionError:
|
||||
raise KafkaError("Kafka error: Unknown topic or partition on broker")
|
||||
except Exception as e:
|
||||
raise KafkaError("Kafka error: {0}".format(e.__str__()))
|
||||
try:
|
||||
self.producer.flush()
|
||||
except Exception as e:
|
||||
raise KafkaError("Kafka error: {0}".format(e.__str__()))
|
||||
4
parsedmarc/log.py
Normal file
@@ -0,0 +1,4 @@
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.addHandler(logging.NullHandler())
|
||||
181
parsedmarc/loganalytics.py
Normal file
@@ -0,0 +1,181 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from parsedmarc.log import logger
|
||||
from azure.core.exceptions import HttpResponseError
|
||||
from azure.identity import ClientSecretCredential
|
||||
from azure.monitor.ingestion import LogsIngestionClient
|
||||
|
||||
|
||||
class LogAnalyticsException(Exception):
|
||||
"""Raised when an Elasticsearch error occurs"""
|
||||
|
||||
|
||||
class LogAnalyticsConfig:
|
||||
"""
|
||||
The LogAnalyticsConfig class is used to define the configuration
|
||||
for the Log Analytics Client.
|
||||
|
||||
Properties:
|
||||
client_id (str):
|
||||
The client ID of the service principle.
|
||||
client_secret (str):
|
||||
The client secret of the service principle.
|
||||
tenant_id (str):
|
||||
The tenant ID where
|
||||
the service principle resides.
|
||||
dce (str):
|
||||
The Data Collection Endpoint (DCE)
|
||||
used by the Data Collection Rule (DCR).
|
||||
dcr_immutable_id (str):
|
||||
The immutable ID of
|
||||
the Data Collection Rule (DCR).
|
||||
dcr_aggregate_stream (str):
|
||||
The Stream name where
|
||||
the Aggregate DMARC reports
|
||||
need to be pushed.
|
||||
dcr_forensic_stream (str):
|
||||
The Stream name where
|
||||
the Forensic DMARC reports
|
||||
need to be pushed.
|
||||
dcr_smtp_tls_stream (str):
|
||||
The Stream name where
|
||||
the SMTP TLS Reports
|
||||
need to be pushed.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
client_id: str,
|
||||
client_secret: str,
|
||||
tenant_id: str,
|
||||
dce: str,
|
||||
dcr_immutable_id: str,
|
||||
dcr_aggregate_stream: str,
|
||||
dcr_forensic_stream: str,
|
||||
dcr_smtp_tls_stream: str,
|
||||
):
|
||||
self.client_id = client_id
|
||||
self.client_secret = client_secret
|
||||
self.tenant_id = tenant_id
|
||||
self.dce = dce
|
||||
self.dcr_immutable_id = dcr_immutable_id
|
||||
self.dcr_aggregate_stream = dcr_aggregate_stream
|
||||
self.dcr_forensic_stream = dcr_forensic_stream
|
||||
self.dcr_smtp_tls_stream = dcr_smtp_tls_stream
|
||||
|
||||
|
||||
class LogAnalyticsClient(object):
|
||||
"""
|
||||
The LogAnalyticsClient is used to push
|
||||
the generated DMARC reports to Log Analytics
|
||||
via Data Collection Rules.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
client_id: str,
|
||||
client_secret: str,
|
||||
tenant_id: str,
|
||||
dce: str,
|
||||
dcr_immutable_id: str,
|
||||
dcr_aggregate_stream: str,
|
||||
dcr_forensic_stream: str,
|
||||
dcr_smtp_tls_stream: str,
|
||||
):
|
||||
self.conf = LogAnalyticsConfig(
|
||||
client_id=client_id,
|
||||
client_secret=client_secret,
|
||||
tenant_id=tenant_id,
|
||||
dce=dce,
|
||||
dcr_immutable_id=dcr_immutable_id,
|
||||
dcr_aggregate_stream=dcr_aggregate_stream,
|
||||
dcr_forensic_stream=dcr_forensic_stream,
|
||||
dcr_smtp_tls_stream=dcr_smtp_tls_stream,
|
||||
)
|
||||
if (
|
||||
not self.conf.client_id
|
||||
or not self.conf.client_secret
|
||||
or not self.conf.tenant_id
|
||||
or not self.conf.dce
|
||||
or not self.conf.dcr_immutable_id
|
||||
):
|
||||
raise LogAnalyticsException(
|
||||
"Invalid configuration. " + "One or more required settings are missing."
|
||||
)
|
||||
|
||||
def publish_json(self, results, logs_client: LogsIngestionClient, dcr_stream: str):
|
||||
"""
|
||||
Background function to publish given
|
||||
DMARC report to specific Data Collection Rule.
|
||||
|
||||
Args:
|
||||
results (list):
|
||||
The results generated by parsedmarc.
|
||||
logs_client (LogsIngestionClient):
|
||||
The client used to send the DMARC reports.
|
||||
dcr_stream (str):
|
||||
The stream name where the DMARC reports needs to be pushed.
|
||||
"""
|
||||
try:
|
||||
logs_client.upload(self.conf.dcr_immutable_id, dcr_stream, results)
|
||||
except HttpResponseError as e:
|
||||
raise LogAnalyticsException("Upload failed: {error}".format(error=e))
|
||||
|
||||
def publish_results(
|
||||
self, results, save_aggregate: bool, save_forensic: bool, save_smtp_tls: bool
|
||||
):
|
||||
"""
|
||||
Function to publish DMARC and/or SMTP TLS reports to Log Analytics
|
||||
via Data Collection Rules (DCR).
|
||||
Look below for docs:
|
||||
https://learn.microsoft.com/en-us/azure/azure-monitor/logs/logs-ingestion-api-overview
|
||||
|
||||
Args:
|
||||
results (list):
|
||||
The DMARC reports (Aggregate & Forensic)
|
||||
save_aggregate (bool):
|
||||
Whether Aggregate reports can be saved into Log Analytics
|
||||
save_forensic (bool):
|
||||
Whether Forensic reports can be saved into Log Analytics
|
||||
save_smtp_tls (bool):
|
||||
Whether Forensic reports can be saved into Log Analytics
|
||||
"""
|
||||
conf = self.conf
|
||||
credential = ClientSecretCredential(
|
||||
tenant_id=conf.tenant_id,
|
||||
client_id=conf.client_id,
|
||||
client_secret=conf.client_secret,
|
||||
)
|
||||
logs_client = LogsIngestionClient(conf.dce, credential=credential)
|
||||
if (
|
||||
results["aggregate_reports"]
|
||||
and conf.dcr_aggregate_stream
|
||||
and len(results["aggregate_reports"]) > 0
|
||||
and save_aggregate
|
||||
):
|
||||
logger.info("Publishing aggregate reports.")
|
||||
self.publish_json(
|
||||
results["aggregate_reports"], logs_client, conf.dcr_aggregate_stream
|
||||
)
|
||||
logger.info("Successfully pushed aggregate reports.")
|
||||
if (
|
||||
results["forensic_reports"]
|
||||
and conf.dcr_forensic_stream
|
||||
and len(results["forensic_reports"]) > 0
|
||||
and save_forensic
|
||||
):
|
||||
logger.info("Publishing forensic reports.")
|
||||
self.publish_json(
|
||||
results["forensic_reports"], logs_client, conf.dcr_forensic_stream
|
||||
)
|
||||
logger.info("Successfully pushed forensic reports.")
|
||||
if (
|
||||
results["smtp_tls_reports"]
|
||||
and conf.dcr_smtp_tls_stream
|
||||
and len(results["smtp_tls_reports"]) > 0
|
||||
and save_smtp_tls
|
||||
):
|
||||
logger.info("Publishing SMTP TLS reports.")
|
||||
self.publish_json(
|
||||
results["smtp_tls_reports"], logs_client, conf.dcr_smtp_tls_stream
|
||||
)
|
||||
logger.info("Successfully pushed SMTP TLS reports.")
|
||||
13
parsedmarc/mail/__init__.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from parsedmarc.mail.mailbox_connection import MailboxConnection
|
||||
from parsedmarc.mail.graph import MSGraphConnection
|
||||
from parsedmarc.mail.gmail import GmailConnection
|
||||
from parsedmarc.mail.imap import IMAPConnection
|
||||
from parsedmarc.mail.maildir import MaildirConnection
|
||||
|
||||
__all__ = [
|
||||
"MailboxConnection",
|
||||
"MSGraphConnection",
|
||||
"GmailConnection",
|
||||
"IMAPConnection",
|
||||
"MaildirConnection",
|
||||
]
|
||||
154
parsedmarc/mail/gmail.py
Normal file
@@ -0,0 +1,154 @@
|
||||
from base64 import urlsafe_b64decode
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from time import sleep
|
||||
from typing import List
|
||||
|
||||
from google.auth.transport.requests import Request
|
||||
from google.oauth2.credentials import Credentials
|
||||
from google_auth_oauthlib.flow import InstalledAppFlow
|
||||
from googleapiclient.discovery import build
|
||||
from googleapiclient.errors import HttpError
|
||||
|
||||
from parsedmarc.log import logger
|
||||
from parsedmarc.mail.mailbox_connection import MailboxConnection
|
||||
|
||||
|
||||
def _get_creds(token_file, credentials_file, scopes, oauth2_port):
|
||||
creds = None
|
||||
|
||||
if Path(token_file).exists():
|
||||
creds = Credentials.from_authorized_user_file(token_file, scopes)
|
||||
|
||||
# If there are no (valid) credentials available, let the user log in.
|
||||
if not creds or not creds.valid:
|
||||
if creds and creds.expired and creds.refresh_token:
|
||||
creds.refresh(Request())
|
||||
else:
|
||||
flow = InstalledAppFlow.from_client_secrets_file(credentials_file, scopes)
|
||||
creds = flow.run_local_server(open_browser=False, oauth2_port=oauth2_port)
|
||||
# Save the credentials for the next run
|
||||
with Path(token_file).open("w") as token:
|
||||
token.write(creds.to_json())
|
||||
return creds
|
||||
|
||||
|
||||
class GmailConnection(MailboxConnection):
|
||||
def __init__(
|
||||
self,
|
||||
token_file: str,
|
||||
credentials_file: str,
|
||||
scopes: List[str],
|
||||
include_spam_trash: bool,
|
||||
reports_folder: str,
|
||||
oauth2_port: int,
|
||||
paginate_messages: bool,
|
||||
):
|
||||
creds = _get_creds(token_file, credentials_file, scopes, oauth2_port)
|
||||
self.service = build("gmail", "v1", credentials=creds)
|
||||
self.include_spam_trash = include_spam_trash
|
||||
self.reports_label_id = self._find_label_id_for_label(reports_folder)
|
||||
self.paginate_messages = paginate_messages
|
||||
|
||||
def create_folder(self, folder_name: str):
|
||||
# Gmail doesn't support the name Archive
|
||||
if folder_name == "Archive":
|
||||
return
|
||||
|
||||
logger.debug(f"Creating label {folder_name}")
|
||||
request_body = {"name": folder_name, "messageListVisibility": "show"}
|
||||
try:
|
||||
self.service.users().labels().create(
|
||||
userId="me", body=request_body
|
||||
).execute()
|
||||
except HttpError as e:
|
||||
if e.status_code == 409:
|
||||
logger.debug(f"Folder {folder_name} already exists, skipping creation")
|
||||
else:
|
||||
raise e
|
||||
|
||||
def _fetch_all_message_ids(self, reports_label_id, page_token=None, since=None):
|
||||
if since:
|
||||
results = (
|
||||
self.service.users()
|
||||
.messages()
|
||||
.list(
|
||||
userId="me",
|
||||
includeSpamTrash=self.include_spam_trash,
|
||||
labelIds=[reports_label_id],
|
||||
pageToken=page_token,
|
||||
q=f"after:{since}",
|
||||
)
|
||||
.execute()
|
||||
)
|
||||
else:
|
||||
results = (
|
||||
self.service.users()
|
||||
.messages()
|
||||
.list(
|
||||
userId="me",
|
||||
includeSpamTrash=self.include_spam_trash,
|
||||
labelIds=[reports_label_id],
|
||||
pageToken=page_token,
|
||||
)
|
||||
.execute()
|
||||
)
|
||||
messages = results.get("messages", [])
|
||||
for message in messages:
|
||||
yield message["id"]
|
||||
|
||||
if "nextPageToken" in results and self.paginate_messages:
|
||||
yield from self._fetch_all_message_ids(
|
||||
reports_label_id, results["nextPageToken"]
|
||||
)
|
||||
|
||||
def fetch_messages(self, reports_folder: str, **kwargs) -> List[str]:
|
||||
reports_label_id = self._find_label_id_for_label(reports_folder)
|
||||
since = kwargs.get("since")
|
||||
if since:
|
||||
return [
|
||||
id for id in self._fetch_all_message_ids(reports_label_id, since=since)
|
||||
]
|
||||
else:
|
||||
return [id for id in self._fetch_all_message_ids(reports_label_id)]
|
||||
|
||||
def fetch_message(self, message_id):
|
||||
msg = (
|
||||
self.service.users()
|
||||
.messages()
|
||||
.get(userId="me", id=message_id, format="raw")
|
||||
.execute()
|
||||
)
|
||||
return urlsafe_b64decode(msg["raw"])
|
||||
|
||||
def delete_message(self, message_id: str):
|
||||
self.service.users().messages().delete(userId="me", id=message_id)
|
||||
|
||||
def move_message(self, message_id: str, folder_name: str):
|
||||
label_id = self._find_label_id_for_label(folder_name)
|
||||
logger.debug(f"Moving message UID {message_id} to {folder_name}")
|
||||
request_body = {
|
||||
"addLabelIds": [label_id],
|
||||
"removeLabelIds": [self.reports_label_id],
|
||||
}
|
||||
self.service.users().messages().modify(
|
||||
userId="me", id=message_id, body=request_body
|
||||
).execute()
|
||||
|
||||
def keepalive(self):
|
||||
# Not needed
|
||||
pass
|
||||
|
||||
def watch(self, check_callback, check_timeout):
|
||||
"""Checks the mailbox for new messages every n seconds"""
|
||||
while True:
|
||||
sleep(check_timeout)
|
||||
check_callback(self)
|
||||
|
||||
@lru_cache(maxsize=10)
|
||||
def _find_label_id_for_label(self, label_name: str) -> str:
|
||||
results = self.service.users().labels().list(userId="me").execute()
|
||||
labels = results.get("labels", [])
|
||||
for label in labels:
|
||||
if label_name == label["id"] or label_name == label["name"]:
|
||||
return label["id"]
|
||||
265
parsedmarc/mail/graph.py
Normal file
@@ -0,0 +1,265 @@
|
||||
from enum import Enum
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from time import sleep
|
||||
from typing import List, Optional
|
||||
|
||||
from azure.identity import (
|
||||
UsernamePasswordCredential,
|
||||
DeviceCodeCredential,
|
||||
ClientSecretCredential,
|
||||
TokenCachePersistenceOptions,
|
||||
AuthenticationRecord,
|
||||
)
|
||||
from msgraph.core import GraphClient
|
||||
|
||||
from parsedmarc.log import logger
|
||||
from parsedmarc.mail.mailbox_connection import MailboxConnection
|
||||
|
||||
|
||||
class AuthMethod(Enum):
|
||||
DeviceCode = 1
|
||||
UsernamePassword = 2
|
||||
ClientSecret = 3
|
||||
|
||||
|
||||
def _get_cache_args(token_path: Path, allow_unencrypted_storage):
|
||||
cache_args = {
|
||||
"cache_persistence_options": TokenCachePersistenceOptions(
|
||||
name="parsedmarc", allow_unencrypted_storage=allow_unencrypted_storage
|
||||
)
|
||||
}
|
||||
auth_record = _load_token(token_path)
|
||||
if auth_record:
|
||||
cache_args["authentication_record"] = AuthenticationRecord.deserialize(
|
||||
auth_record
|
||||
)
|
||||
return cache_args
|
||||
|
||||
|
||||
def _load_token(token_path: Path) -> Optional[str]:
|
||||
if not token_path.exists():
|
||||
return None
|
||||
with token_path.open() as token_file:
|
||||
return token_file.read()
|
||||
|
||||
|
||||
def _cache_auth_record(record: AuthenticationRecord, token_path: Path):
|
||||
token = record.serialize()
|
||||
with token_path.open("w") as token_file:
|
||||
token_file.write(token)
|
||||
|
||||
|
||||
def _generate_credential(auth_method: str, token_path: Path, **kwargs):
|
||||
if auth_method == AuthMethod.DeviceCode.name:
|
||||
credential = DeviceCodeCredential(
|
||||
client_id=kwargs["client_id"],
|
||||
disable_automatic_authentication=True,
|
||||
tenant_id=kwargs["tenant_id"],
|
||||
**_get_cache_args(
|
||||
token_path,
|
||||
allow_unencrypted_storage=kwargs["allow_unencrypted_storage"],
|
||||
),
|
||||
)
|
||||
elif auth_method == AuthMethod.UsernamePassword.name:
|
||||
credential = UsernamePasswordCredential(
|
||||
client_id=kwargs["client_id"],
|
||||
client_credential=kwargs["client_secret"],
|
||||
disable_automatic_authentication=True,
|
||||
username=kwargs["username"],
|
||||
password=kwargs["password"],
|
||||
**_get_cache_args(
|
||||
token_path,
|
||||
allow_unencrypted_storage=kwargs["allow_unencrypted_storage"],
|
||||
),
|
||||
)
|
||||
elif auth_method == AuthMethod.ClientSecret.name:
|
||||
credential = ClientSecretCredential(
|
||||
client_id=kwargs["client_id"],
|
||||
tenant_id=kwargs["tenant_id"],
|
||||
client_secret=kwargs["client_secret"],
|
||||
)
|
||||
else:
|
||||
raise RuntimeError(f"Auth method {auth_method} not found")
|
||||
return credential
|
||||
|
||||
|
||||
class MSGraphConnection(MailboxConnection):
|
||||
def __init__(
|
||||
self,
|
||||
auth_method: str,
|
||||
mailbox: str,
|
||||
graph_url: str,
|
||||
client_id: str,
|
||||
client_secret: str,
|
||||
username: str,
|
||||
password: str,
|
||||
tenant_id: str,
|
||||
token_file: str,
|
||||
allow_unencrypted_storage: bool,
|
||||
):
|
||||
token_path = Path(token_file)
|
||||
credential = _generate_credential(
|
||||
auth_method,
|
||||
client_id=client_id,
|
||||
client_secret=client_secret,
|
||||
username=username,
|
||||
password=password,
|
||||
tenant_id=tenant_id,
|
||||
token_path=token_path,
|
||||
allow_unencrypted_storage=allow_unencrypted_storage,
|
||||
)
|
||||
client_params = {
|
||||
"credential": credential,
|
||||
"cloud": graph_url,
|
||||
}
|
||||
if not isinstance(credential, ClientSecretCredential):
|
||||
scopes = ["Mail.ReadWrite"]
|
||||
# Detect if mailbox is shared
|
||||
if mailbox and username != mailbox:
|
||||
scopes = ["Mail.ReadWrite.Shared"]
|
||||
auth_record = credential.authenticate(scopes=scopes)
|
||||
_cache_auth_record(auth_record, token_path)
|
||||
client_params["scopes"] = scopes
|
||||
|
||||
self._client = GraphClient(**client_params)
|
||||
self.mailbox_name = mailbox
|
||||
|
||||
def create_folder(self, folder_name: str):
|
||||
sub_url = ""
|
||||
path_parts = folder_name.split("/")
|
||||
if len(path_parts) > 1: # Folder is a subFolder
|
||||
parent_folder_id = None
|
||||
for folder in path_parts[:-1]:
|
||||
parent_folder_id = self._find_folder_id_with_parent(
|
||||
folder, parent_folder_id
|
||||
)
|
||||
sub_url = f"/{parent_folder_id}/childFolders"
|
||||
folder_name = path_parts[-1]
|
||||
|
||||
request_body = {"displayName": folder_name}
|
||||
request_url = f"/users/{self.mailbox_name}/mailFolders{sub_url}"
|
||||
resp = self._client.post(request_url, json=request_body)
|
||||
if resp.status_code == 409:
|
||||
logger.debug(f"Folder {folder_name} already exists, skipping creation")
|
||||
elif resp.status_code == 201:
|
||||
logger.debug(f"Created folder {folder_name}")
|
||||
else:
|
||||
logger.warning(f"Unknown response {resp.status_code} {resp.json()}")
|
||||
|
||||
def fetch_messages(self, folder_name: str, **kwargs) -> List[str]:
|
||||
"""Returns a list of message UIDs in the specified folder"""
|
||||
folder_id = self._find_folder_id_from_folder_path(folder_name)
|
||||
url = f"/users/{self.mailbox_name}/mailFolders/{folder_id}/messages"
|
||||
since = kwargs.get("since")
|
||||
if not since:
|
||||
since = None
|
||||
batch_size = kwargs.get("batch_size")
|
||||
if not batch_size:
|
||||
batch_size = 0
|
||||
emails = self._get_all_messages(url, batch_size, since)
|
||||
return [email["id"] for email in emails]
|
||||
|
||||
def _get_all_messages(self, url, batch_size, since):
|
||||
messages: list
|
||||
params = {"$select": "id"}
|
||||
if since:
|
||||
params["$filter"] = f"receivedDateTime ge {since}"
|
||||
if batch_size and batch_size > 0:
|
||||
params["$top"] = batch_size
|
||||
else:
|
||||
params["$top"] = 100
|
||||
result = self._client.get(url, params=params)
|
||||
if result.status_code != 200:
|
||||
raise RuntimeError(f"Failed to fetch messages {result.text}")
|
||||
messages = result.json()["value"]
|
||||
# Loop if next page is present and not obtained message limit.
|
||||
while "@odata.nextLink" in result.json() and (
|
||||
since is not None or (batch_size == 0 or batch_size - len(messages) > 0)
|
||||
):
|
||||
result = self._client.get(result.json()["@odata.nextLink"])
|
||||
if result.status_code != 200:
|
||||
raise RuntimeError(f"Failed to fetch messages {result.text}")
|
||||
messages.extend(result.json()["value"])
|
||||
return messages
|
||||
|
||||
def mark_message_read(self, message_id: str):
|
||||
"""Marks a message as read"""
|
||||
url = f"/users/{self.mailbox_name}/messages/{message_id}"
|
||||
resp = self._client.patch(url, json={"isRead": "true"})
|
||||
if resp.status_code != 200:
|
||||
raise RuntimeWarning(
|
||||
f"Failed to mark message read{resp.status_code}: {resp.json()}"
|
||||
)
|
||||
|
||||
def fetch_message(self, message_id: str, **kwargs):
|
||||
url = f"/users/{self.mailbox_name}/messages/{message_id}/$value"
|
||||
result = self._client.get(url)
|
||||
if result.status_code != 200:
|
||||
raise RuntimeWarning(
|
||||
f"Failed to fetch message{result.status_code}: {result.json()}"
|
||||
)
|
||||
mark_read = kwargs.get("mark_read")
|
||||
if mark_read:
|
||||
self.mark_message_read(message_id)
|
||||
return result.text
|
||||
|
||||
def delete_message(self, message_id: str):
|
||||
url = f"/users/{self.mailbox_name}/messages/{message_id}"
|
||||
resp = self._client.delete(url)
|
||||
if resp.status_code != 204:
|
||||
raise RuntimeWarning(
|
||||
f"Failed to delete message {resp.status_code}: {resp.json()}"
|
||||
)
|
||||
|
||||
def move_message(self, message_id: str, folder_name: str):
|
||||
folder_id = self._find_folder_id_from_folder_path(folder_name)
|
||||
request_body = {"destinationId": folder_id}
|
||||
url = f"/users/{self.mailbox_name}/messages/{message_id}/move"
|
||||
resp = self._client.post(url, json=request_body)
|
||||
if resp.status_code != 201:
|
||||
raise RuntimeWarning(
|
||||
f"Failed to move message {resp.status_code}: {resp.json()}"
|
||||
)
|
||||
|
||||
def keepalive(self):
|
||||
# Not needed
|
||||
pass
|
||||
|
||||
def watch(self, check_callback, check_timeout):
|
||||
"""Checks the mailbox for new messages every n seconds"""
|
||||
while True:
|
||||
sleep(check_timeout)
|
||||
check_callback(self)
|
||||
|
||||
@lru_cache(maxsize=10)
|
||||
def _find_folder_id_from_folder_path(self, folder_name: str) -> str:
|
||||
path_parts = folder_name.split("/")
|
||||
parent_folder_id = None
|
||||
if len(path_parts) > 1:
|
||||
for folder in path_parts[:-1]:
|
||||
folder_id = self._find_folder_id_with_parent(folder, parent_folder_id)
|
||||
parent_folder_id = folder_id
|
||||
return self._find_folder_id_with_parent(path_parts[-1], parent_folder_id)
|
||||
else:
|
||||
return self._find_folder_id_with_parent(folder_name, None)
|
||||
|
||||
def _find_folder_id_with_parent(
|
||||
self, folder_name: str, parent_folder_id: Optional[str]
|
||||
):
|
||||
sub_url = ""
|
||||
if parent_folder_id is not None:
|
||||
sub_url = f"/{parent_folder_id}/childFolders"
|
||||
url = f"/users/{self.mailbox_name}/mailFolders{sub_url}"
|
||||
filter = f"?$filter=displayName eq '{folder_name}'"
|
||||
folders_resp = self._client.get(url + filter)
|
||||
if folders_resp.status_code != 200:
|
||||
raise RuntimeWarning(f"Failed to list folders.{folders_resp.json()}")
|
||||
folders: list = folders_resp.json()["value"]
|
||||
matched_folders = [
|
||||
folder for folder in folders if folder["displayName"] == folder_name
|
||||
]
|
||||
if len(matched_folders) == 0:
|
||||
raise RuntimeError(f"folder {folder_name} not found")
|
||||
selected_folder = matched_folders[0]
|
||||
return selected_folder["id"]
|
||||
89
parsedmarc/mail/imap.py
Normal file
@@ -0,0 +1,89 @@
|
||||
from time import sleep
|
||||
|
||||
from imapclient.exceptions import IMAPClientError
|
||||
from mailsuite.imap import IMAPClient
|
||||
from socket import timeout
|
||||
|
||||
from parsedmarc.log import logger
|
||||
from parsedmarc.mail.mailbox_connection import MailboxConnection
|
||||
|
||||
|
||||
class IMAPConnection(MailboxConnection):
|
||||
def __init__(
|
||||
self,
|
||||
host=None,
|
||||
user=None,
|
||||
password=None,
|
||||
port=None,
|
||||
ssl=True,
|
||||
verify=True,
|
||||
timeout=30,
|
||||
max_retries=4,
|
||||
):
|
||||
self._username = user
|
||||
self._password = password
|
||||
self._verify = verify
|
||||
self._client = IMAPClient(
|
||||
host,
|
||||
user,
|
||||
password,
|
||||
port=port,
|
||||
ssl=ssl,
|
||||
verify=verify,
|
||||
timeout=timeout,
|
||||
max_retries=max_retries,
|
||||
)
|
||||
|
||||
def create_folder(self, folder_name: str):
|
||||
self._client.create_folder(folder_name)
|
||||
|
||||
def fetch_messages(self, reports_folder: str, **kwargs):
|
||||
self._client.select_folder(reports_folder)
|
||||
since = kwargs.get("since")
|
||||
if since:
|
||||
return self._client.search(["SINCE", since])
|
||||
else:
|
||||
return self._client.search()
|
||||
|
||||
def fetch_message(self, message_id):
|
||||
return self._client.fetch_message(message_id, parse=False)
|
||||
|
||||
def delete_message(self, message_id: str):
|
||||
self._client.delete_messages([message_id])
|
||||
|
||||
def move_message(self, message_id: str, folder_name: str):
|
||||
self._client.move_messages([message_id], folder_name)
|
||||
|
||||
def keepalive(self):
|
||||
self._client.noop()
|
||||
|
||||
def watch(self, check_callback, check_timeout):
|
||||
"""
|
||||
Use an IDLE IMAP connection to parse incoming emails,
|
||||
and pass the results to a callback function
|
||||
"""
|
||||
|
||||
# IDLE callback sends IMAPClient object,
|
||||
# send back the imap connection object instead
|
||||
def idle_callback_wrapper(client: IMAPClient):
|
||||
self._client = client
|
||||
check_callback(self)
|
||||
|
||||
while True:
|
||||
try:
|
||||
IMAPClient(
|
||||
host=self._client.host,
|
||||
username=self._username,
|
||||
password=self._password,
|
||||
port=self._client.port,
|
||||
ssl=self._client.ssl,
|
||||
verify=self._verify,
|
||||
idle_callback=idle_callback_wrapper,
|
||||
idle_timeout=check_timeout,
|
||||
)
|
||||
except (timeout, IMAPClientError):
|
||||
logger.warning("IMAP connection timeout. Reconnecting...")
|
||||
sleep(check_timeout)
|
||||
except Exception as e:
|
||||
logger.warning("IMAP connection error. {0}. Reconnecting...".format(e))
|
||||
sleep(check_timeout)
|
||||
29
parsedmarc/mail/mailbox_connection.py
Normal file
@@ -0,0 +1,29 @@
|
||||
from abc import ABC
|
||||
from typing import List
|
||||
|
||||
|
||||
class MailboxConnection(ABC):
|
||||
"""
|
||||
Interface for a mailbox connection
|
||||
"""
|
||||
|
||||
def create_folder(self, folder_name: str):
|
||||
raise NotImplementedError
|
||||
|
||||
def fetch_messages(self, reports_folder: str, **kwargs) -> List[str]:
|
||||
raise NotImplementedError
|
||||
|
||||
def fetch_message(self, message_id) -> str:
|
||||
raise NotImplementedError
|
||||
|
||||
def delete_message(self, message_id: str):
|
||||
raise NotImplementedError
|
||||
|
||||
def move_message(self, message_id: str, folder_name: str):
|
||||
raise NotImplementedError
|
||||
|
||||
def keepalive(self):
|
||||
raise NotImplementedError
|
||||
|
||||
def watch(self, check_callback, check_timeout):
|
||||
raise NotImplementedError
|
||||
63
parsedmarc/mail/maildir.py
Normal file
@@ -0,0 +1,63 @@
|
||||
from time import sleep
|
||||
|
||||
from parsedmarc.log import logger
|
||||
from parsedmarc.mail.mailbox_connection import MailboxConnection
|
||||
import mailbox
|
||||
import os
|
||||
|
||||
|
||||
class MaildirConnection(MailboxConnection):
|
||||
def __init__(
|
||||
self,
|
||||
maildir_path=None,
|
||||
maildir_create=False,
|
||||
):
|
||||
self._maildir_path = maildir_path
|
||||
self._maildir_create = maildir_create
|
||||
maildir_owner = os.stat(maildir_path).st_uid
|
||||
if os.getuid() != maildir_owner:
|
||||
if os.getuid() == 0:
|
||||
logger.warning(
|
||||
"Switching uid to {} to access Maildir".format(maildir_owner)
|
||||
)
|
||||
os.setuid(maildir_owner)
|
||||
else:
|
||||
ex = "runtime uid {} differ from maildir {} owner {}".format(
|
||||
os.getuid(), maildir_path, maildir_owner
|
||||
)
|
||||
raise Exception(ex)
|
||||
self._client = mailbox.Maildir(maildir_path, create=maildir_create)
|
||||
self._subfolder_client = {}
|
||||
|
||||
def create_folder(self, folder_name: str):
|
||||
self._subfolder_client[folder_name] = self._client.add_folder(folder_name)
|
||||
self._client.add_folder(folder_name)
|
||||
|
||||
def fetch_messages(self, reports_folder: str, **kwargs):
|
||||
return self._client.keys()
|
||||
|
||||
def fetch_message(self, message_id):
|
||||
return self._client.get(message_id).as_string()
|
||||
|
||||
def delete_message(self, message_id: str):
|
||||
self._client.remove(message_id)
|
||||
|
||||
def move_message(self, message_id: str, folder_name: str):
|
||||
message_data = self._client.get(message_id)
|
||||
if folder_name not in self._subfolder_client.keys():
|
||||
self._subfolder_client = mailbox.Maildir(
|
||||
os.join(self.maildir_path, folder_name), create=self.maildir_create
|
||||
)
|
||||
self._subfolder_client[folder_name].add(message_data)
|
||||
self._client.remove(message_id)
|
||||
|
||||
def keepalive(self):
|
||||
return
|
||||
|
||||
def watch(self, check_callback, check_timeout):
|
||||
while True:
|
||||
try:
|
||||
check_callback(self)
|
||||
except Exception as e:
|
||||
logger.warning("Maildir init error. {0}".format(e))
|
||||
sleep(check_timeout)
|
||||
834
parsedmarc/opensearch.py
Normal file
@@ -0,0 +1,834 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from collections import OrderedDict
|
||||
|
||||
from opensearchpy import (
|
||||
Q,
|
||||
connections,
|
||||
Object,
|
||||
Document,
|
||||
Index,
|
||||
Nested,
|
||||
InnerDoc,
|
||||
Integer,
|
||||
Text,
|
||||
Boolean,
|
||||
Ip,
|
||||
Date,
|
||||
Search,
|
||||
)
|
||||
from opensearchpy.helpers import reindex
|
||||
|
||||
from parsedmarc.log import logger
|
||||
from parsedmarc.utils import human_timestamp_to_datetime
|
||||
from parsedmarc import InvalidForensicReport
|
||||
|
||||
|
||||
class OpenSearchError(Exception):
|
||||
"""Raised when an OpenSearch error occurs"""
|
||||
|
||||
|
||||
class _PolicyOverride(InnerDoc):
|
||||
type = Text()
|
||||
comment = Text()
|
||||
|
||||
|
||||
class _PublishedPolicy(InnerDoc):
|
||||
domain = Text()
|
||||
adkim = Text()
|
||||
aspf = Text()
|
||||
p = Text()
|
||||
sp = Text()
|
||||
pct = Integer()
|
||||
fo = Text()
|
||||
|
||||
|
||||
class _DKIMResult(InnerDoc):
|
||||
domain = Text()
|
||||
selector = Text()
|
||||
result = Text()
|
||||
|
||||
|
||||
class _SPFResult(InnerDoc):
|
||||
domain = Text()
|
||||
scope = Text()
|
||||
results = Text()
|
||||
|
||||
|
||||
class _AggregateReportDoc(Document):
|
||||
class Index:
|
||||
name = "dmarc_aggregate"
|
||||
|
||||
xml_schema = Text()
|
||||
org_name = Text()
|
||||
org_email = Text()
|
||||
org_extra_contact_info = Text()
|
||||
report_id = Text()
|
||||
date_range = Date()
|
||||
date_begin = Date()
|
||||
date_end = Date()
|
||||
errors = Text()
|
||||
published_policy = Object(_PublishedPolicy)
|
||||
source_ip_address = Ip()
|
||||
source_country = Text()
|
||||
source_reverse_dns = Text()
|
||||
source_base_domain = Text()
|
||||
source_type = Text()
|
||||
source_name = Text()
|
||||
message_count = Integer
|
||||
disposition = Text()
|
||||
dkim_aligned = Boolean()
|
||||
spf_aligned = Boolean()
|
||||
passed_dmarc = Boolean()
|
||||
policy_overrides = Nested(_PolicyOverride)
|
||||
header_from = Text()
|
||||
envelope_from = Text()
|
||||
envelope_to = Text()
|
||||
dkim_results = Nested(_DKIMResult)
|
||||
spf_results = Nested(_SPFResult)
|
||||
|
||||
def add_policy_override(self, type_, comment):
|
||||
self.policy_overrides.append(_PolicyOverride(type=type_, comment=comment))
|
||||
|
||||
def add_dkim_result(self, domain, selector, result):
|
||||
self.dkim_results.append(
|
||||
_DKIMResult(domain=domain, selector=selector, result=result)
|
||||
)
|
||||
|
||||
def add_spf_result(self, domain, scope, result):
|
||||
self.spf_results.append(_SPFResult(domain=domain, scope=scope, result=result))
|
||||
|
||||
def save(self, **kwargs):
|
||||
self.passed_dmarc = False
|
||||
self.passed_dmarc = self.spf_aligned or self.dkim_aligned
|
||||
|
||||
return super().save(**kwargs)
|
||||
|
||||
|
||||
class _EmailAddressDoc(InnerDoc):
|
||||
display_name = Text()
|
||||
address = Text()
|
||||
|
||||
|
||||
class _EmailAttachmentDoc(Document):
|
||||
filename = Text()
|
||||
content_type = Text()
|
||||
sha256 = Text()
|
||||
|
||||
|
||||
class _ForensicSampleDoc(InnerDoc):
|
||||
raw = Text()
|
||||
headers = Object()
|
||||
headers_only = Boolean()
|
||||
to = Nested(_EmailAddressDoc)
|
||||
subject = Text()
|
||||
filename_safe_subject = Text()
|
||||
_from = Object(_EmailAddressDoc)
|
||||
date = Date()
|
||||
reply_to = Nested(_EmailAddressDoc)
|
||||
cc = Nested(_EmailAddressDoc)
|
||||
bcc = Nested(_EmailAddressDoc)
|
||||
body = Text()
|
||||
attachments = Nested(_EmailAttachmentDoc)
|
||||
|
||||
def add_to(self, display_name, address):
|
||||
self.to.append(_EmailAddressDoc(display_name=display_name, address=address))
|
||||
|
||||
def add_reply_to(self, display_name, address):
|
||||
self.reply_to.append(
|
||||
_EmailAddressDoc(display_name=display_name, address=address)
|
||||
)
|
||||
|
||||
def add_cc(self, display_name, address):
|
||||
self.cc.append(_EmailAddressDoc(display_name=display_name, address=address))
|
||||
|
||||
def add_bcc(self, display_name, address):
|
||||
self.bcc.append(_EmailAddressDoc(display_name=display_name, address=address))
|
||||
|
||||
def add_attachment(self, filename, content_type, sha256):
|
||||
self.attachments.append(
|
||||
_EmailAttachmentDoc(
|
||||
filename=filename, content_type=content_type, sha256=sha256
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class _ForensicReportDoc(Document):
|
||||
class Index:
|
||||
name = "dmarc_forensic"
|
||||
|
||||
feedback_type = Text()
|
||||
user_agent = Text()
|
||||
version = Text()
|
||||
original_mail_from = Text()
|
||||
arrival_date = Date()
|
||||
domain = Text()
|
||||
original_envelope_id = Text()
|
||||
authentication_results = Text()
|
||||
delivery_results = Text()
|
||||
source_ip_address = Ip()
|
||||
source_country = Text()
|
||||
source_reverse_dns = Text()
|
||||
source_authentication_mechanisms = Text()
|
||||
source_auth_failures = Text()
|
||||
dkim_domain = Text()
|
||||
original_rcpt_to = Text()
|
||||
sample = Object(_ForensicSampleDoc)
|
||||
|
||||
|
||||
class _SMTPTLSFailureDetailsDoc(InnerDoc):
|
||||
result_type = Text()
|
||||
sending_mta_ip = Ip()
|
||||
receiving_mx_helo = Text()
|
||||
receiving_ip = Ip()
|
||||
failed_session_count = Integer()
|
||||
additional_information_uri = Text()
|
||||
failure_reason_code = Text()
|
||||
|
||||
|
||||
class _SMTPTLSPolicyDoc(InnerDoc):
|
||||
policy_domain = Text()
|
||||
policy_type = Text()
|
||||
policy_strings = Text()
|
||||
mx_host_patterns = Text()
|
||||
successful_session_count = Integer()
|
||||
failed_session_count = Integer()
|
||||
failure_details = Nested(_SMTPTLSFailureDetailsDoc)
|
||||
|
||||
def add_failure_details(
|
||||
self,
|
||||
result_type,
|
||||
ip_address,
|
||||
receiving_ip,
|
||||
receiving_mx_helo,
|
||||
failed_session_count,
|
||||
sending_mta_ip=None,
|
||||
receiving_mx_hostname=None,
|
||||
additional_information_uri=None,
|
||||
failure_reason_code=None,
|
||||
):
|
||||
_details = _SMTPTLSFailureDetailsDoc(
|
||||
result_type=result_type,
|
||||
ip_address=ip_address,
|
||||
sending_mta_ip=sending_mta_ip,
|
||||
receiving_mx_hostname=receiving_mx_hostname,
|
||||
receiving_mx_helo=receiving_mx_helo,
|
||||
receiving_ip=receiving_ip,
|
||||
failed_session_count=failed_session_count,
|
||||
additional_information=additional_information_uri,
|
||||
failure_reason_code=failure_reason_code,
|
||||
)
|
||||
self.failure_details.append(_details)
|
||||
|
||||
|
||||
class _SMTPTLSReportDoc(Document):
|
||||
class Index:
|
||||
name = "smtp_tls"
|
||||
|
||||
organization_name = Text()
|
||||
date_range = Date()
|
||||
date_begin = Date()
|
||||
date_end = Date()
|
||||
contact_info = Text()
|
||||
report_id = Text()
|
||||
policies = Nested(_SMTPTLSPolicyDoc)
|
||||
|
||||
def add_policy(
|
||||
self,
|
||||
policy_type,
|
||||
policy_domain,
|
||||
successful_session_count,
|
||||
failed_session_count,
|
||||
policy_string=None,
|
||||
mx_host_patterns=None,
|
||||
failure_details=None,
|
||||
):
|
||||
self.policies.append(
|
||||
policy_type=policy_type,
|
||||
policy_domain=policy_domain,
|
||||
successful_session_count=successful_session_count,
|
||||
failed_session_count=failed_session_count,
|
||||
policy_string=policy_string,
|
||||
mx_host_patterns=mx_host_patterns,
|
||||
failure_details=failure_details,
|
||||
)
|
||||
|
||||
|
||||
class AlreadySaved(ValueError):
|
||||
"""Raised when a report to be saved matches an existing report"""
|
||||
|
||||
|
||||
def set_hosts(
|
||||
hosts,
|
||||
use_ssl=False,
|
||||
ssl_cert_path=None,
|
||||
username=None,
|
||||
password=None,
|
||||
apiKey=None,
|
||||
timeout=60.0,
|
||||
):
|
||||
"""
|
||||
Sets the OpenSearch hosts to use
|
||||
|
||||
Args:
|
||||
hosts (str|list): A hostname or URL, or list of hostnames or URLs
|
||||
use_ssl (bool): Use an HTTPS connection to the server
|
||||
ssl_cert_path (str): Path to the certificate chain
|
||||
username (str): The username to use for authentication
|
||||
password (str): The password to use for authentication
|
||||
apiKey (str): The Base64 encoded API key to use for authentication
|
||||
timeout (float): Timeout in seconds
|
||||
"""
|
||||
if not isinstance(hosts, list):
|
||||
hosts = [hosts]
|
||||
conn_params = {"hosts": hosts, "timeout": timeout}
|
||||
if use_ssl:
|
||||
conn_params["use_ssl"] = True
|
||||
if ssl_cert_path:
|
||||
conn_params["verify_certs"] = True
|
||||
conn_params["ca_certs"] = ssl_cert_path
|
||||
else:
|
||||
conn_params["verify_certs"] = False
|
||||
if username:
|
||||
conn_params["http_auth"] = username + ":" + password
|
||||
if apiKey:
|
||||
conn_params["api_key"] = apiKey
|
||||
connections.create_connection(**conn_params)
|
||||
|
||||
|
||||
def create_indexes(names, settings=None):
|
||||
"""
|
||||
Create OpenSearch indexes
|
||||
|
||||
Args:
|
||||
names (list): A list of index names
|
||||
settings (dict): Index settings
|
||||
|
||||
"""
|
||||
for name in names:
|
||||
index = Index(name)
|
||||
try:
|
||||
if not index.exists():
|
||||
logger.debug("Creating OpenSearch index: {0}".format(name))
|
||||
if settings is None:
|
||||
index.settings(number_of_shards=1, number_of_replicas=0)
|
||||
else:
|
||||
index.settings(**settings)
|
||||
index.create()
|
||||
except Exception as e:
|
||||
raise OpenSearchError("OpenSearch error: {0}".format(e.__str__()))
|
||||
|
||||
|
||||
def migrate_indexes(aggregate_indexes=None, forensic_indexes=None):
|
||||
"""
|
||||
Updates index mappings
|
||||
|
||||
Args:
|
||||
aggregate_indexes (list): A list of aggregate index names
|
||||
forensic_indexes (list): A list of forensic index names
|
||||
"""
|
||||
version = 2
|
||||
if aggregate_indexes is None:
|
||||
aggregate_indexes = []
|
||||
if forensic_indexes is None:
|
||||
forensic_indexes = []
|
||||
for aggregate_index_name in aggregate_indexes:
|
||||
if not Index(aggregate_index_name).exists():
|
||||
continue
|
||||
aggregate_index = Index(aggregate_index_name)
|
||||
doc = "doc"
|
||||
fo_field = "published_policy.fo"
|
||||
fo = "fo"
|
||||
fo_mapping = aggregate_index.get_field_mapping(fields=[fo_field])
|
||||
fo_mapping = fo_mapping[list(fo_mapping.keys())[0]]["mappings"]
|
||||
if doc not in fo_mapping:
|
||||
continue
|
||||
|
||||
fo_mapping = fo_mapping[doc][fo_field]["mapping"][fo]
|
||||
fo_type = fo_mapping["type"]
|
||||
if fo_type == "long":
|
||||
new_index_name = "{0}-v{1}".format(aggregate_index_name, version)
|
||||
body = {
|
||||
"properties": {
|
||||
"published_policy.fo": {
|
||||
"type": "text",
|
||||
"fields": {"keyword": {"type": "keyword", "ignore_above": 256}},
|
||||
}
|
||||
}
|
||||
}
|
||||
Index(new_index_name).create()
|
||||
Index(new_index_name).put_mapping(doc_type=doc, body=body)
|
||||
reindex(connections.get_connection(), aggregate_index_name, new_index_name)
|
||||
Index(aggregate_index_name).delete()
|
||||
|
||||
for forensic_index in forensic_indexes:
|
||||
pass
|
||||
|
||||
|
||||
def save_aggregate_report_to_opensearch(
|
||||
aggregate_report,
|
||||
index_suffix=None,
|
||||
index_prefix=None,
|
||||
monthly_indexes=False,
|
||||
number_of_shards=1,
|
||||
number_of_replicas=0,
|
||||
):
|
||||
"""
|
||||
Saves a parsed DMARC aggregate report to OpenSearch
|
||||
|
||||
Args:
|
||||
aggregate_report (OrderedDict): A parsed forensic report
|
||||
index_suffix (str): The suffix of the name of the index to save to
|
||||
index_prefix (str): The prefix of the name of the index to save to
|
||||
monthly_indexes (bool): Use monthly indexes instead of daily indexes
|
||||
number_of_shards (int): The number of shards to use in the index
|
||||
number_of_replicas (int): The number of replicas to use in the index
|
||||
|
||||
Raises:
|
||||
AlreadySaved
|
||||
"""
|
||||
logger.info("Saving aggregate report to OpenSearch")
|
||||
aggregate_report = aggregate_report.copy()
|
||||
metadata = aggregate_report["report_metadata"]
|
||||
org_name = metadata["org_name"]
|
||||
report_id = metadata["report_id"]
|
||||
domain = aggregate_report["policy_published"]["domain"]
|
||||
begin_date = human_timestamp_to_datetime(metadata["begin_date"], to_utc=True)
|
||||
end_date = human_timestamp_to_datetime(metadata["end_date"], to_utc=True)
|
||||
begin_date_human = begin_date.strftime("%Y-%m-%d %H:%M:%SZ")
|
||||
end_date_human = end_date.strftime("%Y-%m-%d %H:%M:%SZ")
|
||||
if monthly_indexes:
|
||||
index_date = begin_date.strftime("%Y-%m")
|
||||
else:
|
||||
index_date = begin_date.strftime("%Y-%m-%d")
|
||||
aggregate_report["begin_date"] = begin_date
|
||||
aggregate_report["end_date"] = end_date
|
||||
date_range = [aggregate_report["begin_date"], aggregate_report["end_date"]]
|
||||
|
||||
org_name_query = Q(dict(match_phrase=dict(org_name=org_name)))
|
||||
report_id_query = Q(dict(match_phrase=dict(report_id=report_id)))
|
||||
domain_query = Q(dict(match_phrase={"published_policy.domain": domain}))
|
||||
begin_date_query = Q(dict(match=dict(date_begin=begin_date)))
|
||||
end_date_query = Q(dict(match=dict(date_end=end_date)))
|
||||
|
||||
if index_suffix is not None:
|
||||
search_index = "dmarc_aggregate_{0}*".format(index_suffix)
|
||||
else:
|
||||
search_index = "dmarc_aggregate*"
|
||||
if index_prefix is not None:
|
||||
search_index = "{0}{1}".format(index_prefix, search_index)
|
||||
search = Search(index=search_index)
|
||||
query = org_name_query & report_id_query & domain_query
|
||||
query = query & begin_date_query & end_date_query
|
||||
search.query = query
|
||||
|
||||
try:
|
||||
existing = search.execute()
|
||||
except Exception as error_:
|
||||
raise OpenSearchError(
|
||||
"OpenSearch's search for existing report \
|
||||
error: {}".format(error_.__str__())
|
||||
)
|
||||
|
||||
if len(existing) > 0:
|
||||
raise AlreadySaved(
|
||||
"An aggregate report ID {0} from {1} about {2} "
|
||||
"with a date range of {3} UTC to {4} UTC already "
|
||||
"exists in "
|
||||
"OpenSearch".format(
|
||||
report_id, org_name, domain, begin_date_human, end_date_human
|
||||
)
|
||||
)
|
||||
published_policy = _PublishedPolicy(
|
||||
domain=aggregate_report["policy_published"]["domain"],
|
||||
adkim=aggregate_report["policy_published"]["adkim"],
|
||||
aspf=aggregate_report["policy_published"]["aspf"],
|
||||
p=aggregate_report["policy_published"]["p"],
|
||||
sp=aggregate_report["policy_published"]["sp"],
|
||||
pct=aggregate_report["policy_published"]["pct"],
|
||||
fo=aggregate_report["policy_published"]["fo"],
|
||||
)
|
||||
|
||||
for record in aggregate_report["records"]:
|
||||
agg_doc = _AggregateReportDoc(
|
||||
xml_schema=aggregate_report["xml_schema"],
|
||||
org_name=metadata["org_name"],
|
||||
org_email=metadata["org_email"],
|
||||
org_extra_contact_info=metadata["org_extra_contact_info"],
|
||||
report_id=metadata["report_id"],
|
||||
date_range=date_range,
|
||||
date_begin=aggregate_report["begin_date"],
|
||||
date_end=aggregate_report["end_date"],
|
||||
errors=metadata["errors"],
|
||||
published_policy=published_policy,
|
||||
source_ip_address=record["source"]["ip_address"],
|
||||
source_country=record["source"]["country"],
|
||||
source_reverse_dns=record["source"]["reverse_dns"],
|
||||
source_base_domain=record["source"]["base_domain"],
|
||||
source_type=record["source"]["type"],
|
||||
source_name=record["source"]["name"],
|
||||
message_count=record["count"],
|
||||
disposition=record["policy_evaluated"]["disposition"],
|
||||
dkim_aligned=record["policy_evaluated"]["dkim"] is not None
|
||||
and record["policy_evaluated"]["dkim"].lower() == "pass",
|
||||
spf_aligned=record["policy_evaluated"]["spf"] is not None
|
||||
and record["policy_evaluated"]["spf"].lower() == "pass",
|
||||
header_from=record["identifiers"]["header_from"],
|
||||
envelope_from=record["identifiers"]["envelope_from"],
|
||||
envelope_to=record["identifiers"]["envelope_to"],
|
||||
)
|
||||
|
||||
for override in record["policy_evaluated"]["policy_override_reasons"]:
|
||||
agg_doc.add_policy_override(
|
||||
type_=override["type"], comment=override["comment"]
|
||||
)
|
||||
|
||||
for dkim_result in record["auth_results"]["dkim"]:
|
||||
agg_doc.add_dkim_result(
|
||||
domain=dkim_result["domain"],
|
||||
selector=dkim_result["selector"],
|
||||
result=dkim_result["result"],
|
||||
)
|
||||
|
||||
for spf_result in record["auth_results"]["spf"]:
|
||||
agg_doc.add_spf_result(
|
||||
domain=spf_result["domain"],
|
||||
scope=spf_result["scope"],
|
||||
result=spf_result["result"],
|
||||
)
|
||||
|
||||
index = "dmarc_aggregate"
|
||||
if index_suffix:
|
||||
index = "{0}_{1}".format(index, index_suffix)
|
||||
if index_prefix:
|
||||
index = "{0}{1}".format(index_prefix, index)
|
||||
|
||||
index = "{0}-{1}".format(index, index_date)
|
||||
index_settings = dict(
|
||||
number_of_shards=number_of_shards, number_of_replicas=number_of_replicas
|
||||
)
|
||||
create_indexes([index], index_settings)
|
||||
agg_doc.meta.index = index
|
||||
|
||||
try:
|
||||
agg_doc.save()
|
||||
except Exception as e:
|
||||
raise OpenSearchError("OpenSearch error: {0}".format(e.__str__()))
|
||||
|
||||
|
||||
def save_forensic_report_to_opensearch(
|
||||
forensic_report,
|
||||
index_suffix=None,
|
||||
index_prefix=None,
|
||||
monthly_indexes=False,
|
||||
number_of_shards=1,
|
||||
number_of_replicas=0,
|
||||
):
|
||||
"""
|
||||
Saves a parsed DMARC forensic report to OpenSearch
|
||||
|
||||
Args:
|
||||
forensic_report (OrderedDict): A parsed forensic report
|
||||
index_suffix (str): The suffix of the name of the index to save to
|
||||
index_prefix (str): The prefix of the name of the index to save to
|
||||
monthly_indexes (bool): Use monthly indexes instead of daily
|
||||
indexes
|
||||
number_of_shards (int): The number of shards to use in the index
|
||||
number_of_replicas (int): The number of replicas to use in the
|
||||
index
|
||||
|
||||
Raises:
|
||||
AlreadySaved
|
||||
|
||||
"""
|
||||
logger.info("Saving forensic report to OpenSearch")
|
||||
forensic_report = forensic_report.copy()
|
||||
sample_date = None
|
||||
if forensic_report["parsed_sample"]["date"] is not None:
|
||||
sample_date = forensic_report["parsed_sample"]["date"]
|
||||
sample_date = human_timestamp_to_datetime(sample_date)
|
||||
original_headers = forensic_report["parsed_sample"]["headers"]
|
||||
headers = OrderedDict()
|
||||
for original_header in original_headers:
|
||||
headers[original_header.lower()] = original_headers[original_header]
|
||||
|
||||
arrival_date = human_timestamp_to_datetime(forensic_report["arrival_date_utc"])
|
||||
arrival_date_epoch_milliseconds = int(arrival_date.timestamp() * 1000)
|
||||
|
||||
if index_suffix is not None:
|
||||
search_index = "dmarc_forensic_{0}*".format(index_suffix)
|
||||
else:
|
||||
search_index = "dmarc_forensic*"
|
||||
if index_prefix is not None:
|
||||
search_index = "{0}{1}".format(index_prefix, search_index)
|
||||
search = Search(index=search_index)
|
||||
q = Q(dict(match=dict(arrival_date=arrival_date_epoch_milliseconds)))
|
||||
|
||||
from_ = None
|
||||
to_ = None
|
||||
subject = None
|
||||
if "from" in headers:
|
||||
# We convert the FROM header from a string list to a flat string.
|
||||
headers["from"] = headers["from"][0]
|
||||
if headers["from"][0] == "":
|
||||
headers["from"] = headers["from"][1]
|
||||
else:
|
||||
headers["from"] = " <".join(headers["from"]) + ">"
|
||||
|
||||
from_ = dict()
|
||||
from_["sample.headers.from"] = headers["from"]
|
||||
from_query = Q(dict(match_phrase=from_))
|
||||
q = q & from_query
|
||||
if "to" in headers:
|
||||
# We convert the TO header from a string list to a flat string.
|
||||
headers["to"] = headers["to"][0]
|
||||
if headers["to"][0] == "":
|
||||
headers["to"] = headers["to"][1]
|
||||
else:
|
||||
headers["to"] = " <".join(headers["to"]) + ">"
|
||||
|
||||
to_ = dict()
|
||||
to_["sample.headers.to"] = headers["to"]
|
||||
to_query = Q(dict(match_phrase=to_))
|
||||
q = q & to_query
|
||||
if "subject" in headers:
|
||||
subject = headers["subject"]
|
||||
subject_query = {"match_phrase": {"sample.headers.subject": subject}}
|
||||
q = q & Q(subject_query)
|
||||
|
||||
search.query = q
|
||||
existing = search.execute()
|
||||
|
||||
if len(existing) > 0:
|
||||
raise AlreadySaved(
|
||||
"A forensic sample to {0} from {1} "
|
||||
"with a subject of {2} and arrival date of {3} "
|
||||
"already exists in "
|
||||
"OpenSearch".format(
|
||||
to_, from_, subject, forensic_report["arrival_date_utc"]
|
||||
)
|
||||
)
|
||||
|
||||
parsed_sample = forensic_report["parsed_sample"]
|
||||
sample = _ForensicSampleDoc(
|
||||
raw=forensic_report["sample"],
|
||||
headers=headers,
|
||||
headers_only=forensic_report["sample_headers_only"],
|
||||
date=sample_date,
|
||||
subject=forensic_report["parsed_sample"]["subject"],
|
||||
filename_safe_subject=parsed_sample["filename_safe_subject"],
|
||||
body=forensic_report["parsed_sample"]["body"],
|
||||
)
|
||||
|
||||
for address in forensic_report["parsed_sample"]["to"]:
|
||||
sample.add_to(display_name=address["display_name"], address=address["address"])
|
||||
for address in forensic_report["parsed_sample"]["reply_to"]:
|
||||
sample.add_reply_to(
|
||||
display_name=address["display_name"], address=address["address"]
|
||||
)
|
||||
for address in forensic_report["parsed_sample"]["cc"]:
|
||||
sample.add_cc(display_name=address["display_name"], address=address["address"])
|
||||
for address in forensic_report["parsed_sample"]["bcc"]:
|
||||
sample.add_bcc(display_name=address["display_name"], address=address["address"])
|
||||
for attachment in forensic_report["parsed_sample"]["attachments"]:
|
||||
sample.add_attachment(
|
||||
filename=attachment["filename"],
|
||||
content_type=attachment["mail_content_type"],
|
||||
sha256=attachment["sha256"],
|
||||
)
|
||||
try:
|
||||
forensic_doc = _ForensicReportDoc(
|
||||
feedback_type=forensic_report["feedback_type"],
|
||||
user_agent=forensic_report["user_agent"],
|
||||
version=forensic_report["version"],
|
||||
original_mail_from=forensic_report["original_mail_from"],
|
||||
arrival_date=arrival_date_epoch_milliseconds,
|
||||
domain=forensic_report["reported_domain"],
|
||||
original_envelope_id=forensic_report["original_envelope_id"],
|
||||
authentication_results=forensic_report["authentication_results"],
|
||||
delivery_results=forensic_report["delivery_result"],
|
||||
source_ip_address=forensic_report["source"]["ip_address"],
|
||||
source_country=forensic_report["source"]["country"],
|
||||
source_reverse_dns=forensic_report["source"]["reverse_dns"],
|
||||
source_base_domain=forensic_report["source"]["base_domain"],
|
||||
authentication_mechanisms=forensic_report["authentication_mechanisms"],
|
||||
auth_failure=forensic_report["auth_failure"],
|
||||
dkim_domain=forensic_report["dkim_domain"],
|
||||
original_rcpt_to=forensic_report["original_rcpt_to"],
|
||||
sample=sample,
|
||||
)
|
||||
|
||||
index = "dmarc_forensic"
|
||||
if index_suffix:
|
||||
index = "{0}_{1}".format(index, index_suffix)
|
||||
if index_prefix:
|
||||
index = "{0}{1}".format(index_prefix, index)
|
||||
if monthly_indexes:
|
||||
index_date = arrival_date.strftime("%Y-%m")
|
||||
else:
|
||||
index_date = arrival_date.strftime("%Y-%m-%d")
|
||||
index = "{0}-{1}".format(index, index_date)
|
||||
index_settings = dict(
|
||||
number_of_shards=number_of_shards, number_of_replicas=number_of_replicas
|
||||
)
|
||||
create_indexes([index], index_settings)
|
||||
forensic_doc.meta.index = index
|
||||
try:
|
||||
forensic_doc.save()
|
||||
except Exception as e:
|
||||
raise OpenSearchError("OpenSearch error: {0}".format(e.__str__()))
|
||||
except KeyError as e:
|
||||
raise InvalidForensicReport(
|
||||
"Forensic report missing required field: {0}".format(e.__str__())
|
||||
)
|
||||
|
||||
|
||||
def save_smtp_tls_report_to_opensearch(
|
||||
report,
|
||||
index_suffix=None,
|
||||
index_prefix=None,
|
||||
monthly_indexes=False,
|
||||
number_of_shards=1,
|
||||
number_of_replicas=0,
|
||||
):
|
||||
"""
|
||||
Saves a parsed SMTP TLS report to OpenSearch
|
||||
|
||||
Args:
|
||||
report (OrderedDict): A parsed SMTP TLS report
|
||||
index_suffix (str): The suffix of the name of the index to save to
|
||||
index_prefix (str): The prefix of the name of the index to save to
|
||||
monthly_indexes (bool): Use monthly indexes instead of daily indexes
|
||||
number_of_shards (int): The number of shards to use in the index
|
||||
number_of_replicas (int): The number of replicas to use in the index
|
||||
|
||||
Raises:
|
||||
AlreadySaved
|
||||
"""
|
||||
logger.info("Saving aggregate report to OpenSearch")
|
||||
org_name = report["organization_name"]
|
||||
report_id = report["report_id"]
|
||||
begin_date = human_timestamp_to_datetime(report["begin_date"], to_utc=True)
|
||||
end_date = human_timestamp_to_datetime(report["end_date"], to_utc=True)
|
||||
begin_date_human = begin_date.strftime("%Y-%m-%d %H:%M:%SZ")
|
||||
end_date_human = end_date.strftime("%Y-%m-%d %H:%M:%SZ")
|
||||
if monthly_indexes:
|
||||
index_date = begin_date.strftime("%Y-%m")
|
||||
else:
|
||||
index_date = begin_date.strftime("%Y-%m-%d")
|
||||
report["begin_date"] = begin_date
|
||||
report["end_date"] = end_date
|
||||
|
||||
org_name_query = Q(dict(match_phrase=dict(org_name=org_name)))
|
||||
report_id_query = Q(dict(match_phrase=dict(report_id=report_id)))
|
||||
begin_date_query = Q(dict(match=dict(date_begin=begin_date)))
|
||||
end_date_query = Q(dict(match=dict(date_end=end_date)))
|
||||
|
||||
if index_suffix is not None:
|
||||
search_index = "smtp_tls_{0}*".format(index_suffix)
|
||||
else:
|
||||
search_index = "smtp_tls*"
|
||||
if index_prefix is not None:
|
||||
search_index = "{0}{1}".format(index_prefix, search_index)
|
||||
search = Search(index=search_index)
|
||||
query = org_name_query & report_id_query
|
||||
query = query & begin_date_query & end_date_query
|
||||
search.query = query
|
||||
|
||||
try:
|
||||
existing = search.execute()
|
||||
except Exception as error_:
|
||||
raise OpenSearchError(
|
||||
"OpenSearch's search for existing report \
|
||||
error: {}".format(error_.__str__())
|
||||
)
|
||||
|
||||
if len(existing) > 0:
|
||||
raise AlreadySaved(
|
||||
f"An SMTP TLS report ID {report_id} from "
|
||||
f" {org_name} with a date range of "
|
||||
f"{begin_date_human} UTC to "
|
||||
f"{end_date_human} UTC already "
|
||||
"exists in OpenSearch"
|
||||
)
|
||||
|
||||
index = "smtp_tls"
|
||||
if index_suffix:
|
||||
index = "{0}_{1}".format(index, index_suffix)
|
||||
if index_prefix:
|
||||
index = "{0}{1}".format(index_prefix, index)
|
||||
index = "{0}-{1}".format(index, index_date)
|
||||
index_settings = dict(
|
||||
number_of_shards=number_of_shards, number_of_replicas=number_of_replicas
|
||||
)
|
||||
|
||||
smtp_tls_doc = _SMTPTLSReportDoc(
|
||||
org_name=report["organization_name"],
|
||||
date_range=[report["begin_date"], report["end_date"]],
|
||||
date_begin=report["begin_date"],
|
||||
date_end=report["end_date"],
|
||||
contact_info=report["contact_info"],
|
||||
report_id=report["report_id"],
|
||||
)
|
||||
|
||||
for policy in report["policies"]:
|
||||
policy_strings = None
|
||||
mx_host_patterns = None
|
||||
if "policy_strings" in policy:
|
||||
policy_strings = policy["policy_strings"]
|
||||
if "mx_host_patterns" in policy:
|
||||
mx_host_patterns = policy["mx_host_patterns"]
|
||||
policy_doc = _SMTPTLSPolicyDoc(
|
||||
policy_domain=policy["policy_domain"],
|
||||
policy_type=policy["policy_type"],
|
||||
succesful_session_count=policy["successful_session_count"],
|
||||
failed_session_count=policy["failed_session_count"],
|
||||
policy_string=policy_strings,
|
||||
mx_host_patterns=mx_host_patterns,
|
||||
)
|
||||
if "failure_details" in policy:
|
||||
for failure_detail in policy["failure_details"]:
|
||||
receiving_mx_hostname = None
|
||||
additional_information_uri = None
|
||||
failure_reason_code = None
|
||||
ip_address = None
|
||||
receiving_ip = None
|
||||
receiving_mx_helo = None
|
||||
sending_mta_ip = None
|
||||
|
||||
if "receiving_mx_hostname" in failure_detail:
|
||||
receiving_mx_hostname = failure_detail["receiving_mx_hostname"]
|
||||
if "additional_information_uri" in failure_detail:
|
||||
additional_information_uri = failure_detail[
|
||||
"additional_information_uri"
|
||||
]
|
||||
if "failure_reason_code" in failure_detail:
|
||||
failure_reason_code = failure_detail["failure_reason_code"]
|
||||
if "ip_address" in failure_detail:
|
||||
ip_address = failure_detail["ip_address"]
|
||||
if "receiving_ip" in failure_detail:
|
||||
receiving_ip = failure_detail["receiving_ip"]
|
||||
if "receiving_mx_helo" in failure_detail:
|
||||
receiving_mx_helo = failure_detail["receiving_mx_helo"]
|
||||
if "sending_mta_ip" in failure_detail:
|
||||
sending_mta_ip = failure_detail["sending_mta_ip"]
|
||||
policy_doc.add_failure_details(
|
||||
result_type=failure_detail["result_type"],
|
||||
ip_address=ip_address,
|
||||
receiving_ip=receiving_ip,
|
||||
receiving_mx_helo=receiving_mx_helo,
|
||||
failed_session_count=failure_detail["failed_session_count"],
|
||||
sending_mta_ip=sending_mta_ip,
|
||||
receiving_mx_hostname=receiving_mx_hostname,
|
||||
additional_information_uri=additional_information_uri,
|
||||
failure_reason_code=failure_reason_code,
|
||||
)
|
||||
smtp_tls_doc.policies.append(policy_doc)
|
||||
|
||||
create_indexes([index], index_settings)
|
||||
smtp_tls_doc.meta.index = index
|
||||
|
||||
try:
|
||||
smtp_tls_doc.save()
|
||||
except Exception as e:
|
||||
raise OpenSearchError("OpenSearch error: {0}".format(e.__str__()))
|
||||
0
parsedmarc/resources/__init__.py
Normal file
7
parsedmarc/resources/dbip/README.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# About
|
||||
|
||||
`dbip-country-lite.mmdb` is provided by [dbip][dbip] under a
|
||||
[Creative Commons Attribution 4.0 International License][cc].
|
||||
|
||||
[dbip]: https://db-ip.com/db/download/ip-to-country-lite
|
||||
[cc]: http://creativecommons.org/licenses/by/4.0/
|
||||
0
parsedmarc/resources/dbip/__init__.py
Normal file
BIN
parsedmarc/resources/dbip/dbip-country-lite.mmdb
Normal file
83
parsedmarc/resources/maps/README.md
Normal file
@@ -0,0 +1,83 @@
|
||||
# About
|
||||
|
||||
A mapping is meant to make it easier to identify who or what a sending source is. Please consider contributing
|
||||
additional mappings in a GitHub Pull Request.
|
||||
|
||||
## base_reverse_dns_map.csv
|
||||
|
||||
A CSV file with three fields: `base_reverse_dns`, `name`, and `type`.
|
||||
Most of the time the base reverse DNS of sending service is closely related to the name of the
|
||||
service, but not always. Sometimes services will use multiple reverse DNS domains for the same service. For example,
|
||||
Intuit Mailchimp uses the base domains `mcdlv.net`, `mcsv.net`,
|
||||
and `rsgsv.net`. Having all of these mapped to the same service name and type makes it easier to answer questions like:
|
||||
"How many emails is Intuit Mailchimp sending as my domains?"
|
||||
|
||||
The `service_type` is based on the following rule precedence:
|
||||
|
||||
1. All email security services are identified as `Email Security`, no matter how or where they are hosted.
|
||||
2. All marketing services are identified as `Marketing`, no matter how or where they are hosted.
|
||||
3. All telecommunications providers that offer internet access are identified as `ISP`, even if they also offer other services, such as web hosting or email hosting.
|
||||
4. All web hosting providers are identified as `Web Hosting`, even if the service also offers email hosting.
|
||||
5. All email account providers are identified as `Email Provider`, no matter how or where they are hosted
|
||||
6. All legitimate platforms offering their Software as a Service (SaaS) are identified as `SaaS`, regardless of industry. This helps simplify metrics.
|
||||
7. All other senders that use their own domain as a Reverse DNS base domain should be identified based on their industry
|
||||
|
||||
- Agriculture
|
||||
- Automotive
|
||||
- Beauty
|
||||
- Construction
|
||||
- Consulting
|
||||
- Defense
|
||||
- Education
|
||||
- Email Provider
|
||||
- Email Security
|
||||
- Entertainment
|
||||
- Event Planning
|
||||
- Finance
|
||||
- Food
|
||||
- Government
|
||||
- Government Media
|
||||
- Healthcare
|
||||
- IaaS
|
||||
- Industrial
|
||||
- ISP
|
||||
- Logistics
|
||||
- Manufacturing
|
||||
- Marketing
|
||||
- MSP
|
||||
- MSSP
|
||||
- News
|
||||
- Nonprofit
|
||||
- PaaS
|
||||
- Photography
|
||||
- Print
|
||||
- Publishing
|
||||
- Real Estate
|
||||
- Retail
|
||||
- SaaS
|
||||
- Science
|
||||
- Search Engine
|
||||
- Social Media
|
||||
- Sports
|
||||
- Staffing
|
||||
- Technology
|
||||
- Travel
|
||||
- Web Host
|
||||
|
||||
The file currently contains over 1,400 mappings from a wide variety of email sending sources.
|
||||
|
||||
## known_unknown_base_reverse_dns.txt
|
||||
|
||||
A list of reverse DNS base domains that could not be identified as belonging to a particular organization, service, or industry.
|
||||
|
||||
## base_reverse_dns.csv
|
||||
|
||||
A CSV with the fields `source_name` and optionally `message_countcount`. This CSV can be generated byy exporting the base DNS data from the Kibana on Splunk dashboards provided by parsedmarc. This file is not tracked by Git.
|
||||
|
||||
## unknown_base_reverse_dns.csv
|
||||
|
||||
A CSV file with the fields `source_name` and `message_count`. This file is not tracked by Git.
|
||||
|
||||
## find_unknown_base_reverse_dns.py
|
||||
|
||||
This is a python script that reads the domains in `base_reverse_dns.csv` and writes the domains that are not in `base_reverse_dns_map.csv` or `known_unknown_base_reverse_dns.txt` to `unknown_base_reverse_dns.csv`. This is useful for identifying potential additional domains to contribute to `base_reverse_dns_map.csv` and `known_unknown_base_reverse_dns.txt`.
|
||||
0
parsedmarc/resources/maps/__init__.py
Normal file
1414
parsedmarc/resources/maps/base_reverse_dns_map.csv
Normal file
73
parsedmarc/resources/maps/find_unknown_base_reverse_dns.py
Executable file
@@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env python
|
||||
|
||||
import logging
|
||||
import os
|
||||
import csv
|
||||
|
||||
|
||||
def _main():
|
||||
input_csv_file_path = "base_reverse_dns.csv"
|
||||
base_reverse_dns_map_file_path = "base_reverse_dns_map.csv"
|
||||
known_unknown_list_file_path = "known_unknown_base_reverse_dns.txt"
|
||||
output_csv_file_path = "unknown_base_reverse_dns.csv"
|
||||
|
||||
csv_headers = ["source_name", "message_count"]
|
||||
|
||||
output_rows = []
|
||||
|
||||
logging.basicConfig()
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
for p in [
|
||||
input_csv_file_path,
|
||||
base_reverse_dns_map_file_path,
|
||||
known_unknown_list_file_path,
|
||||
]:
|
||||
if not os.path.exists(p):
|
||||
logger.error(f"{p} does not exist")
|
||||
exit(1)
|
||||
logger.info(f"Loading {known_unknown_list_file_path}")
|
||||
known_unknown_domains = []
|
||||
with open(known_unknown_list_file_path) as f:
|
||||
for line in f.readlines():
|
||||
domain = line.lower().strip()
|
||||
if domain in known_unknown_domains:
|
||||
logger.warning(
|
||||
f"{domain} is in {known_unknown_list_file_path} multiple times"
|
||||
)
|
||||
else:
|
||||
known_unknown_domains.append(domain)
|
||||
logger.info(f"Loading {base_reverse_dns_map_file_path}")
|
||||
known_domains = []
|
||||
with open(base_reverse_dns_map_file_path) as f:
|
||||
for row in csv.DictReader(f):
|
||||
domain = row["base_reverse_dns"].lower().strip()
|
||||
if domain in known_domains:
|
||||
logger.warning(
|
||||
f"{domain} is in {base_reverse_dns_map_file_path} multiple times"
|
||||
)
|
||||
else:
|
||||
known_domains.append(domain)
|
||||
if domain in known_unknown_domains and known_domains:
|
||||
pass
|
||||
logger.warning(
|
||||
f"{domain} is in {known_unknown_list_file_path} and {base_reverse_dns_map_file_path}"
|
||||
)
|
||||
|
||||
logger.info(f"Checking domains against {base_reverse_dns_map_file_path}")
|
||||
with open(input_csv_file_path) as f:
|
||||
for row in csv.DictReader(f):
|
||||
domain = row["source_name"].lower().strip()
|
||||
if domain not in known_domains and domain not in known_unknown_domains:
|
||||
logger.info(f"New unknown domain found: {domain}")
|
||||
output_rows.append(row)
|
||||
logger.info(f"Writing {output_csv_file_path}")
|
||||
with open(output_csv_file_path, "w") as f:
|
||||
writer = csv.DictWriter(f, fieldnames=csv_headers)
|
||||
writer.writeheader()
|
||||
writer.writerows(output_rows)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
_main()
|
||||
125
parsedmarc/resources/maps/known_unknown_base_reverse_dns.txt
Normal file
@@ -0,0 +1,125 @@
|
||||
200.in-addr.arpa
|
||||
adlucrumnewsletter.com
|
||||
admin.corpivensa.gob.ve
|
||||
aerospacevitro.us.com
|
||||
albagroup-eg.com
|
||||
anteldata.net.uy
|
||||
antonaoll.com
|
||||
aosau.net
|
||||
arandomserver.com
|
||||
asmecam.it
|
||||
b8sales.com
|
||||
bestinvestingtime.com
|
||||
biocorp.com
|
||||
bisno1.co.jp
|
||||
bluhosting.com
|
||||
bodiax.pp.ua
|
||||
bost-law.com
|
||||
brnonet.cz
|
||||
brushinglegal.de
|
||||
christus.mx
|
||||
cloud-edm.com
|
||||
cloudlogin.co
|
||||
cnode.io
|
||||
commerceinsurance.com
|
||||
coolblaze.com
|
||||
cps.com.ar
|
||||
detrot.xyz
|
||||
digi.net.my
|
||||
dkginternet.com
|
||||
doorsrv.com
|
||||
dreamtechmedia.com
|
||||
ds.network
|
||||
emailperegrine.com
|
||||
epsilon-group.com
|
||||
eyecandyhosting.xyz
|
||||
fetscorp.shop
|
||||
formicidaehunt.net
|
||||
fosterheap.com
|
||||
gendns.com
|
||||
ginous.eu.com
|
||||
gist-th.com
|
||||
gophermedia.com
|
||||
gqlists.us.com
|
||||
gratzl.de
|
||||
hgnbroken.us.com
|
||||
hosting1337.com
|
||||
hostingmichigan.com
|
||||
hostname.localhost
|
||||
hostnetwork.com
|
||||
hostwhitelabel.com
|
||||
idcfcloud.net
|
||||
immenzaces.com
|
||||
ivol.co
|
||||
jalanet.co.id
|
||||
kahlaa.com
|
||||
kbronet.com.tw
|
||||
kdnursing.org
|
||||
kitchenaildbd.com
|
||||
legenditds.com
|
||||
lighthouse-media.com
|
||||
lohkal.com
|
||||
lonestarmm.net
|
||||
magnetmail.net
|
||||
manhattanbulletpoint.com
|
||||
masterclassjournal.com
|
||||
moderntradingnews.com
|
||||
moonjaws.com
|
||||
motion4ever.net
|
||||
mschosting.com
|
||||
mspnet.pro
|
||||
mts-nn.ru
|
||||
mxthunder.net
|
||||
myrewards.net
|
||||
mysagestore.com
|
||||
ncport.ru
|
||||
nebdig.com
|
||||
neovet-base.ru
|
||||
nic.name
|
||||
nidix.net
|
||||
ogicom.net
|
||||
omegabrasil.inf.br
|
||||
onnet21.com
|
||||
ovaltinalization.co
|
||||
overta.ru
|
||||
passionatesmiles.com
|
||||
planethoster.net
|
||||
pmnhost.net
|
||||
popiup.com
|
||||
prima.com.ar
|
||||
prima.net.ar
|
||||
proudserver.com
|
||||
qontenciplc.autos
|
||||
raxa.host
|
||||
sahacker-2020.com
|
||||
samsales.site
|
||||
satirogluet.com
|
||||
securednshost.com
|
||||
securen.net
|
||||
securerelay.in
|
||||
securev.net
|
||||
servershost.biz
|
||||
smallvillages.com
|
||||
solusoftware.com
|
||||
spiritualtechnologies.io
|
||||
sprout.org
|
||||
stableserver.net
|
||||
stockexchangejournal.com
|
||||
suksangroup.com
|
||||
system.eu.com
|
||||
t-jon.com
|
||||
tenkids.net
|
||||
thaicloudsolutions.com
|
||||
thaimonster.com
|
||||
tullostrucking.com
|
||||
unite.services
|
||||
urawasl.com
|
||||
us.servername.us
|
||||
vendimetry.com
|
||||
vibrantwellnesscorp.com
|
||||
wallstreetsgossip.com
|
||||
weblinkinternational.com
|
||||
xsfati.us.com
|
||||
xspmail.jp
|
||||
zerowebhosting.net
|
||||
znlc.jp
|
||||
91
parsedmarc/s3.py
Normal file
@@ -0,0 +1,91 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import json
|
||||
import boto3
|
||||
|
||||
from parsedmarc.log import logger
|
||||
from parsedmarc.utils import human_timestamp_to_datetime
|
||||
|
||||
|
||||
class S3Client(object):
|
||||
"""A client for a Amazon S3"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
bucket_name,
|
||||
bucket_path,
|
||||
region_name,
|
||||
endpoint_url,
|
||||
access_key_id,
|
||||
secret_access_key,
|
||||
):
|
||||
"""
|
||||
Initializes the S3Client
|
||||
Args:
|
||||
bucket_name (str): The S3 Bucket
|
||||
bucket_path (str): The path to save reports
|
||||
region_name (str): The region name
|
||||
endpoint_url (str): The endpoint URL
|
||||
access_key_id (str): The access key id
|
||||
secret_access_key (str): The secret access key
|
||||
"""
|
||||
self.bucket_name = bucket_name
|
||||
self.bucket_path = bucket_path
|
||||
self.metadata_keys = [
|
||||
"org_name",
|
||||
"org_email",
|
||||
"report_id",
|
||||
"begin_date",
|
||||
"end_date",
|
||||
]
|
||||
|
||||
# https://github.com/boto/boto3/blob/1.24.7/boto3/session.py#L312
|
||||
self.s3 = boto3.resource(
|
||||
"s3",
|
||||
region_name=region_name,
|
||||
endpoint_url=endpoint_url,
|
||||
aws_access_key_id=access_key_id,
|
||||
aws_secret_access_key=secret_access_key,
|
||||
)
|
||||
self.bucket = self.s3.Bucket(self.bucket_name)
|
||||
|
||||
def save_aggregate_report_to_s3(self, report):
|
||||
self.save_report_to_s3(report, "aggregate")
|
||||
|
||||
def save_forensic_report_to_s3(self, report):
|
||||
self.save_report_to_s3(report, "forensic")
|
||||
|
||||
def save_smtp_tls_report_to_s3(self, report):
|
||||
self.save_report_to_s3(report, "smtp_tls")
|
||||
|
||||
def save_report_to_s3(self, report, report_type):
|
||||
if report_type == "smtp_tls":
|
||||
report_date = report["begin_date"]
|
||||
report_id = report["report_id"]
|
||||
else:
|
||||
report_date = human_timestamp_to_datetime(
|
||||
report["report_metadata"]["begin_date"]
|
||||
)
|
||||
report_id = report["report_metadata"]["report_id"]
|
||||
path_template = "{0}/{1}/year={2}/month={3:02d}/day={4:02d}/{5}.json"
|
||||
object_path = path_template.format(
|
||||
self.bucket_path,
|
||||
report_type,
|
||||
report_date.year,
|
||||
report_date.month,
|
||||
report_date.day,
|
||||
report_id,
|
||||
)
|
||||
logger.debug(
|
||||
"Saving {0} report to s3://{1}/{2}".format(
|
||||
report_type, self.bucket_name, object_path
|
||||
)
|
||||
)
|
||||
object_metadata = {
|
||||
k: v
|
||||
for k, v in report["report_metadata"].items()
|
||||
if k in self.metadata_keys
|
||||
}
|
||||
self.bucket.put_object(
|
||||
Body=json.dumps(report), Key=object_path, Metadata=object_metadata
|
||||
)
|
||||
183
parsedmarc/splunk.py
Normal file
@@ -0,0 +1,183 @@
|
||||
from urllib.parse import urlparse
|
||||
import socket
|
||||
import json
|
||||
|
||||
import urllib3
|
||||
import requests
|
||||
|
||||
from parsedmarc import __version__
|
||||
from parsedmarc.log import logger
|
||||
from parsedmarc.utils import human_timestamp_to_unix_timestamp
|
||||
|
||||
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||
|
||||
|
||||
class SplunkError(RuntimeError):
|
||||
"""Raised when a Splunk API error occurs"""
|
||||
|
||||
|
||||
class HECClient(object):
|
||||
"""A client for a Splunk HTTP Events Collector (HEC)"""
|
||||
|
||||
# http://docs.splunk.com/Documentation/Splunk/latest/Data/AboutHEC
|
||||
# http://docs.splunk.com/Documentation/Splunk/latest/RESTREF/RESTinput#services.2Fcollector
|
||||
|
||||
def __init__(
|
||||
self, url, access_token, index, source="parsedmarc", verify=True, timeout=60
|
||||
):
|
||||
"""
|
||||
Initializes the HECClient
|
||||
|
||||
Args:
|
||||
url (str): The URL of the HEC
|
||||
access_token (str): The HEC access token
|
||||
index (str): The name of the index
|
||||
source (str): The source name
|
||||
verify (bool): Verify SSL certificates
|
||||
timeout (float): Number of seconds to wait for the server to send
|
||||
data before giving up
|
||||
"""
|
||||
url = urlparse(url)
|
||||
self.url = "{0}://{1}/services/collector/event/1.0".format(
|
||||
url.scheme, url.netloc
|
||||
)
|
||||
self.access_token = access_token.lstrip("Splunk ")
|
||||
self.index = index
|
||||
self.host = socket.getfqdn()
|
||||
self.source = source
|
||||
self.session = requests.Session()
|
||||
self.timeout = timeout
|
||||
self.session.verify = verify
|
||||
self._common_data = dict(host=self.host, source=self.source, index=self.index)
|
||||
|
||||
self.session.headers = {
|
||||
"User-Agent": "parsedmarc/{0}".format(__version__),
|
||||
"Authorization": "Splunk {0}".format(self.access_token),
|
||||
}
|
||||
|
||||
def save_aggregate_reports_to_splunk(self, aggregate_reports):
|
||||
"""
|
||||
Saves aggregate DMARC reports to Splunk
|
||||
|
||||
Args:
|
||||
aggregate_reports: A list of aggregate report dictionaries
|
||||
to save in Splunk
|
||||
|
||||
"""
|
||||
logger.debug("Saving aggregate reports to Splunk")
|
||||
if isinstance(aggregate_reports, dict):
|
||||
aggregate_reports = [aggregate_reports]
|
||||
|
||||
if len(aggregate_reports) < 1:
|
||||
return
|
||||
|
||||
data = self._common_data.copy()
|
||||
json_str = ""
|
||||
for report in aggregate_reports:
|
||||
for record in report["records"]:
|
||||
new_report = dict()
|
||||
for metadata in report["report_metadata"]:
|
||||
new_report[metadata] = report["report_metadata"][metadata]
|
||||
new_report["published_policy"] = report["policy_published"]
|
||||
new_report["source_ip_address"] = record["source"]["ip_address"]
|
||||
new_report["source_country"] = record["source"]["country"]
|
||||
new_report["source_reverse_dns"] = record["source"]["reverse_dns"]
|
||||
new_report["source_base_domain"] = record["source"]["base_domain"]
|
||||
new_report["source_type"] = record["source"]["type"]
|
||||
new_report["source_name"] = record["source"]["name"]
|
||||
new_report["message_count"] = record["count"]
|
||||
new_report["disposition"] = record["policy_evaluated"]["disposition"]
|
||||
new_report["spf_aligned"] = record["alignment"]["spf"]
|
||||
new_report["dkim_aligned"] = record["alignment"]["dkim"]
|
||||
new_report["passed_dmarc"] = record["alignment"]["dmarc"]
|
||||
new_report["header_from"] = record["identifiers"]["header_from"]
|
||||
new_report["envelope_from"] = record["identifiers"]["envelope_from"]
|
||||
if "dkim" in record["auth_results"]:
|
||||
new_report["dkim_results"] = record["auth_results"]["dkim"]
|
||||
if "spf" in record["auth_results"]:
|
||||
new_report["spf_results"] = record["auth_results"]["spf"]
|
||||
|
||||
data["sourcetype"] = "dmarc:aggregate"
|
||||
timestamp = human_timestamp_to_unix_timestamp(new_report["begin_date"])
|
||||
data["time"] = timestamp
|
||||
data["event"] = new_report.copy()
|
||||
json_str += "{0}\n".format(json.dumps(data))
|
||||
|
||||
if not self.session.verify:
|
||||
logger.debug("Skipping certificate verification for Splunk HEC")
|
||||
try:
|
||||
response = self.session.post(self.url, data=json_str, timeout=self.timeout)
|
||||
response = response.json()
|
||||
except Exception as e:
|
||||
raise SplunkError(e.__str__())
|
||||
if response["code"] != 0:
|
||||
raise SplunkError(response["text"])
|
||||
|
||||
def save_forensic_reports_to_splunk(self, forensic_reports):
|
||||
"""
|
||||
Saves forensic DMARC reports to Splunk
|
||||
|
||||
Args:
|
||||
forensic_reports (list): A list of forensic report dictionaries
|
||||
to save in Splunk
|
||||
"""
|
||||
logger.debug("Saving forensic reports to Splunk")
|
||||
if isinstance(forensic_reports, dict):
|
||||
forensic_reports = [forensic_reports]
|
||||
|
||||
if len(forensic_reports) < 1:
|
||||
return
|
||||
|
||||
json_str = ""
|
||||
for report in forensic_reports:
|
||||
data = self._common_data.copy()
|
||||
data["sourcetype"] = "dmarc:forensic"
|
||||
timestamp = human_timestamp_to_unix_timestamp(report["arrival_date_utc"])
|
||||
data["time"] = timestamp
|
||||
data["event"] = report.copy()
|
||||
json_str += "{0}\n".format(json.dumps(data))
|
||||
|
||||
if not self.session.verify:
|
||||
logger.debug("Skipping certificate verification for Splunk HEC")
|
||||
try:
|
||||
response = self.session.post(self.url, data=json_str, timeout=self.timeout)
|
||||
response = response.json()
|
||||
except Exception as e:
|
||||
raise SplunkError(e.__str__())
|
||||
if response["code"] != 0:
|
||||
raise SplunkError(response["text"])
|
||||
|
||||
def save_smtp_tls_reports_to_splunk(self, reports):
|
||||
"""
|
||||
Saves aggregate DMARC reports to Splunk
|
||||
|
||||
Args:
|
||||
reports: A list of SMTP TLS report dictionaries
|
||||
to save in Splunk
|
||||
|
||||
"""
|
||||
logger.debug("Saving SMTP TLS reports to Splunk")
|
||||
if isinstance(reports, dict):
|
||||
reports = [reports]
|
||||
|
||||
if len(reports) < 1:
|
||||
return
|
||||
|
||||
data = self._common_data.copy()
|
||||
json_str = ""
|
||||
for report in reports:
|
||||
data["sourcetype"] = "smtp:tls"
|
||||
timestamp = human_timestamp_to_unix_timestamp(report["begin_date"])
|
||||
data["time"] = timestamp
|
||||
data["event"] = report.copy()
|
||||
json_str += "{0}\n".format(json.dumps(data))
|
||||
|
||||
if not self.session.verify:
|
||||
logger.debug("Skipping certificate verification for Splunk HEC")
|
||||
try:
|
||||
response = self.session.post(self.url, data=json_str, timeout=self.timeout)
|
||||
response = response.json()
|
||||
except Exception as e:
|
||||
raise SplunkError(e.__str__())
|
||||
if response["code"] != 0:
|
||||
raise SplunkError(response["text"])
|
||||
44
parsedmarc/syslog.py
Normal file
@@ -0,0 +1,44 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import logging
|
||||
import logging.handlers
|
||||
import json
|
||||
|
||||
from parsedmarc import (
|
||||
parsed_aggregate_reports_to_csv_rows,
|
||||
parsed_forensic_reports_to_csv_rows,
|
||||
parsed_smtp_tls_reports_to_csv_rows,
|
||||
)
|
||||
|
||||
|
||||
class SyslogClient(object):
|
||||
"""A client for Syslog"""
|
||||
|
||||
def __init__(self, server_name, server_port):
|
||||
"""
|
||||
Initializes the SyslogClient
|
||||
Args:
|
||||
server_name (str): The Syslog server
|
||||
server_port (int): The Syslog UDP port
|
||||
"""
|
||||
self.server_name = server_name
|
||||
self.server_port = server_port
|
||||
self.logger = logging.getLogger("parsedmarc_syslog")
|
||||
self.logger.setLevel(logging.INFO)
|
||||
log_handler = logging.handlers.SysLogHandler(address=(server_name, server_port))
|
||||
self.logger.addHandler(log_handler)
|
||||
|
||||
def save_aggregate_report_to_syslog(self, aggregate_reports):
|
||||
rows = parsed_aggregate_reports_to_csv_rows(aggregate_reports)
|
||||
for row in rows:
|
||||
self.logger.info(json.dumps(row))
|
||||
|
||||
def save_forensic_report_to_syslog(self, forensic_reports):
|
||||
rows = parsed_forensic_reports_to_csv_rows(forensic_reports)
|
||||
for row in rows:
|
||||
self.logger.info(json.dumps(row))
|
||||
|
||||
def save_smtp_tls_report_to_syslog(self, smtp_tls_reports):
|
||||
rows = parsed_smtp_tls_reports_to_csv_rows(smtp_tls_reports)
|
||||
for row in rows:
|
||||
self.logger.info(json.dumps(row))
|
||||
675
parsedmarc/utils.py
Normal file
@@ -0,0 +1,675 @@
|
||||
"""Utility functions that might be useful for other projects"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime
|
||||
from datetime import timezone
|
||||
from datetime import timedelta
|
||||
from collections import OrderedDict
|
||||
import tempfile
|
||||
import subprocess
|
||||
import shutil
|
||||
import mailparser
|
||||
import json
|
||||
import hashlib
|
||||
import base64
|
||||
import mailbox
|
||||
import re
|
||||
import csv
|
||||
import io
|
||||
|
||||
try:
|
||||
from importlib.resources import files
|
||||
except ImportError:
|
||||
# Try backported to PY<3 `importlib_resources`
|
||||
from importlib.resources import files
|
||||
|
||||
|
||||
from dateutil.parser import parse as parse_date
|
||||
import dns.reversename
|
||||
import dns.resolver
|
||||
import dns.exception
|
||||
import geoip2.database
|
||||
import geoip2.errors
|
||||
import publicsuffixlist
|
||||
import requests
|
||||
|
||||
from parsedmarc.log import logger
|
||||
import parsedmarc.resources.dbip
|
||||
import parsedmarc.resources.maps
|
||||
|
||||
|
||||
parenthesis_regex = re.compile(r"\s*\(.*\)\s*")
|
||||
|
||||
null_file = open(os.devnull, "w")
|
||||
mailparser_logger = logging.getLogger("mailparser")
|
||||
mailparser_logger.setLevel(logging.CRITICAL)
|
||||
|
||||
|
||||
class EmailParserError(RuntimeError):
|
||||
"""Raised when an error parsing the email occurs"""
|
||||
|
||||
|
||||
class DownloadError(RuntimeError):
|
||||
"""Raised when an error occurs when downloading a file"""
|
||||
|
||||
|
||||
def decode_base64(data):
|
||||
"""
|
||||
Decodes a base64 string, with padding being optional
|
||||
|
||||
Args:
|
||||
data: A base64 encoded string
|
||||
|
||||
Returns:
|
||||
bytes: The decoded bytes
|
||||
|
||||
"""
|
||||
data = bytes(data, encoding="ascii")
|
||||
missing_padding = len(data) % 4
|
||||
if missing_padding != 0:
|
||||
data += b"=" * (4 - missing_padding)
|
||||
return base64.b64decode(data)
|
||||
|
||||
|
||||
def get_base_domain(domain):
|
||||
"""
|
||||
Gets the base domain name for the given domain
|
||||
|
||||
.. note::
|
||||
Results are based on a list of public domain suffixes at
|
||||
https://publicsuffix.org/list/public_suffix_list.dat.
|
||||
|
||||
Args:
|
||||
domain (str): A domain or subdomain
|
||||
|
||||
Returns:
|
||||
str: The base domain of the given domain
|
||||
|
||||
"""
|
||||
psl = publicsuffixlist.PublicSuffixList()
|
||||
return psl.privatesuffix(domain)
|
||||
|
||||
|
||||
def query_dns(domain, record_type, cache=None, nameservers=None, timeout=2.0):
|
||||
"""
|
||||
Queries DNS
|
||||
|
||||
Args:
|
||||
domain (str): The domain or subdomain to query about
|
||||
record_type (str): The record type to query for
|
||||
cache (ExpiringDict): Cache storage
|
||||
nameservers (list): A list of one or more nameservers to use
|
||||
(Cloudflare's public DNS resolvers by default)
|
||||
timeout (float): Sets the DNS timeout in seconds
|
||||
|
||||
Returns:
|
||||
list: A list of answers
|
||||
"""
|
||||
domain = str(domain).lower()
|
||||
record_type = record_type.upper()
|
||||
cache_key = "{0}_{1}".format(domain, record_type)
|
||||
if cache:
|
||||
records = cache.get(cache_key, None)
|
||||
if records:
|
||||
return records
|
||||
|
||||
resolver = dns.resolver.Resolver()
|
||||
timeout = float(timeout)
|
||||
if nameservers is None:
|
||||
nameservers = [
|
||||
"1.1.1.1",
|
||||
"1.0.0.1",
|
||||
"2606:4700:4700::1111",
|
||||
"2606:4700:4700::1001",
|
||||
]
|
||||
resolver.nameservers = nameservers
|
||||
resolver.timeout = timeout
|
||||
resolver.lifetime = timeout
|
||||
if record_type == "TXT":
|
||||
resource_records = list(
|
||||
map(
|
||||
lambda r: r.strings,
|
||||
resolver.resolve(domain, record_type, lifetime=timeout),
|
||||
)
|
||||
)
|
||||
_resource_record = [
|
||||
resource_record[0][:0].join(resource_record)
|
||||
for resource_record in resource_records
|
||||
if resource_record
|
||||
]
|
||||
records = [r.decode() for r in _resource_record]
|
||||
else:
|
||||
records = list(
|
||||
map(
|
||||
lambda r: r.to_text().replace('"', "").rstrip("."),
|
||||
resolver.resolve(domain, record_type, lifetime=timeout),
|
||||
)
|
||||
)
|
||||
if cache:
|
||||
cache[cache_key] = records
|
||||
|
||||
return records
|
||||
|
||||
|
||||
def get_reverse_dns(ip_address, cache=None, nameservers=None, timeout=2.0):
|
||||
"""
|
||||
Resolves an IP address to a hostname using a reverse DNS query
|
||||
|
||||
Args:
|
||||
ip_address (str): The IP address to resolve
|
||||
cache (ExpiringDict): Cache storage
|
||||
nameservers (list): A list of one or more nameservers to use
|
||||
(Cloudflare's public DNS resolvers by default)
|
||||
timeout (float): Sets the DNS query timeout in seconds
|
||||
|
||||
Returns:
|
||||
str: The reverse DNS hostname (if any)
|
||||
"""
|
||||
hostname = None
|
||||
try:
|
||||
address = dns.reversename.from_address(ip_address)
|
||||
hostname = query_dns(
|
||||
address, "PTR", cache=cache, nameservers=nameservers, timeout=timeout
|
||||
)[0]
|
||||
|
||||
except dns.exception.DNSException as e:
|
||||
logger.warning(f"get_reverse_dns({ip_address}) exception: {e}")
|
||||
pass
|
||||
|
||||
return hostname
|
||||
|
||||
|
||||
def timestamp_to_datetime(timestamp):
|
||||
"""
|
||||
Converts a UNIX/DMARC timestamp to a Python ``datetime`` object
|
||||
|
||||
Args:
|
||||
timestamp (int): The timestamp
|
||||
|
||||
Returns:
|
||||
datetime: The converted timestamp as a Python ``datetime`` object
|
||||
"""
|
||||
return datetime.fromtimestamp(int(timestamp))
|
||||
|
||||
|
||||
def timestamp_to_human(timestamp):
|
||||
"""
|
||||
Converts a UNIX/DMARC timestamp to a human-readable string
|
||||
|
||||
Args:
|
||||
timestamp: The timestamp
|
||||
|
||||
Returns:
|
||||
str: The converted timestamp in ``YYYY-MM-DD HH:MM:SS`` format
|
||||
"""
|
||||
return timestamp_to_datetime(timestamp).strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
|
||||
def human_timestamp_to_datetime(human_timestamp, to_utc=False):
|
||||
"""
|
||||
Converts a human-readable timestamp into a Python ``datetime`` object
|
||||
|
||||
Args:
|
||||
human_timestamp (str): A timestamp string
|
||||
to_utc (bool): Convert the timestamp to UTC
|
||||
|
||||
Returns:
|
||||
datetime: The converted timestamp
|
||||
"""
|
||||
|
||||
human_timestamp = human_timestamp.replace("-0000", "")
|
||||
human_timestamp = parenthesis_regex.sub("", human_timestamp)
|
||||
|
||||
dt = parse_date(human_timestamp)
|
||||
return dt.astimezone(timezone.utc) if to_utc else dt
|
||||
|
||||
|
||||
def human_timestamp_to_unix_timestamp(human_timestamp):
|
||||
"""
|
||||
Converts a human-readable timestamp into a UNIX timestamp
|
||||
|
||||
Args:
|
||||
human_timestamp (str): A timestamp in `YYYY-MM-DD HH:MM:SS`` format
|
||||
|
||||
Returns:
|
||||
float: The converted timestamp
|
||||
"""
|
||||
human_timestamp = human_timestamp.replace("T", " ")
|
||||
return human_timestamp_to_datetime(human_timestamp).timestamp()
|
||||
|
||||
|
||||
def get_ip_address_country(ip_address, db_path=None):
|
||||
"""
|
||||
Returns the ISO code for the country associated
|
||||
with the given IPv4 or IPv6 address
|
||||
|
||||
Args:
|
||||
ip_address (str): The IP address to query for
|
||||
db_path (str): Path to a MMDB file from MaxMind or DBIP
|
||||
|
||||
Returns:
|
||||
str: And ISO country code associated with the given IP address
|
||||
"""
|
||||
db_paths = [
|
||||
"GeoLite2-Country.mmdb",
|
||||
"/usr/local/share/GeoIP/GeoLite2-Country.mmdb",
|
||||
"/usr/share/GeoIP/GeoLite2-Country.mmdb",
|
||||
"/var/lib/GeoIP/GeoLite2-Country.mmdb",
|
||||
"/var/local/lib/GeoIP/GeoLite2-Country.mmdb",
|
||||
"/usr/local/var/GeoIP/GeoLite2-Country.mmdb",
|
||||
"%SystemDrive%\\ProgramData\\MaxMind\\GeoIPUpdate\\GeoIP\\"
|
||||
"GeoLite2-Country.mmdb",
|
||||
"C:\\GeoIP\\GeoLite2-Country.mmdb",
|
||||
"dbip-country-lite.mmdb",
|
||||
"dbip-country.mmdb",
|
||||
]
|
||||
|
||||
if db_path is not None:
|
||||
if os.path.isfile(db_path) is False:
|
||||
db_path = None
|
||||
logger.warning(
|
||||
f"No file exists at {db_path}. Falling back to an "
|
||||
"included copy of the IPDB IP to Country "
|
||||
"Lite database."
|
||||
)
|
||||
|
||||
if db_path is None:
|
||||
for system_path in db_paths:
|
||||
if os.path.exists(system_path):
|
||||
db_path = system_path
|
||||
break
|
||||
|
||||
if db_path is None:
|
||||
db_path = str(
|
||||
files(parsedmarc.resources.dbip).joinpath("dbip-country-lite.mmdb")
|
||||
)
|
||||
|
||||
db_age = datetime.now() - datetime.fromtimestamp(os.stat(db_path).st_mtime)
|
||||
if db_age > timedelta(days=30):
|
||||
logger.warning("IP database is more than a month old")
|
||||
|
||||
db_reader = geoip2.database.Reader(db_path)
|
||||
|
||||
country = None
|
||||
|
||||
try:
|
||||
country = db_reader.country(ip_address).country.iso_code
|
||||
except geoip2.errors.AddressNotFoundError:
|
||||
pass
|
||||
|
||||
return country
|
||||
|
||||
|
||||
def get_service_from_reverse_dns_base_domain(
|
||||
base_domain,
|
||||
always_use_local_file=False,
|
||||
local_file_path=None,
|
||||
url=None,
|
||||
offline=False,
|
||||
reverse_dns_map=None,
|
||||
):
|
||||
"""
|
||||
Returns the service name of a given base domain name from reverse DNS.
|
||||
|
||||
Args:
|
||||
base_domain (str): The base domain of the reverse DNS lookup
|
||||
always_use_local_file (bool): Always use a local map file
|
||||
local_file_path (str): Path to a local map file
|
||||
url (str): URL ro a reverse DNS map
|
||||
offline (bool): Use the built-in copy of the reverse DNS map
|
||||
reverse_dns_map (dict): A reverse DNS map
|
||||
Returns:
|
||||
dict: A dictionary containing name and type.
|
||||
If the service is unknown, the name will be
|
||||
the supplied reverse_dns_base_domain and the type will be None
|
||||
"""
|
||||
|
||||
def load_csv(_csv_file):
|
||||
reader = csv.DictReader(_csv_file)
|
||||
for row in reader:
|
||||
key = row["base_reverse_dns"].lower().strip()
|
||||
reverse_dns_map[key] = dict(name=row["name"], type=row["type"])
|
||||
|
||||
base_domain = base_domain.lower().strip()
|
||||
if url is None:
|
||||
url = (
|
||||
"https://raw.githubusercontent.com/domainaware"
|
||||
"/parsedmarc/master/parsedmarc/"
|
||||
"resources/maps/base_reverse_dns_map.csv"
|
||||
)
|
||||
if reverse_dns_map is None:
|
||||
reverse_dns_map = dict()
|
||||
csv_file = io.StringIO()
|
||||
|
||||
if not (offline or always_use_local_file) and len(reverse_dns_map) == 0:
|
||||
try:
|
||||
logger.debug(f"Trying to fetch reverse DNS map from {url}...")
|
||||
response = requests.get(url)
|
||||
response.raise_for_status()
|
||||
csv_file.write(response.text)
|
||||
csv_file.seek(0)
|
||||
load_csv(csv_file)
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.warning(f"Failed to fetch reverse DNS map: {e}")
|
||||
except Exception:
|
||||
logger.warning("Not a valid CSV file")
|
||||
csv_file.seek(0)
|
||||
logger.debug(csv_file.read())
|
||||
|
||||
if len(reverse_dns_map) == 0:
|
||||
logger.info("Loading included reverse DNS map...")
|
||||
path = str(
|
||||
files(parsedmarc.resources.maps).joinpath("base_reverse_dns_map.csv")
|
||||
)
|
||||
if local_file_path is not None:
|
||||
path = local_file_path
|
||||
with open(path) as csv_file:
|
||||
load_csv(csv_file)
|
||||
try:
|
||||
service = reverse_dns_map[base_domain]
|
||||
except KeyError:
|
||||
service = dict(name=base_domain, type=None)
|
||||
|
||||
return service
|
||||
|
||||
|
||||
def get_ip_address_info(
|
||||
ip_address,
|
||||
ip_db_path=None,
|
||||
reverse_dns_map_path=None,
|
||||
always_use_local_files=False,
|
||||
reverse_dns_map_url=None,
|
||||
cache=None,
|
||||
reverse_dns_map=None,
|
||||
offline=False,
|
||||
nameservers=None,
|
||||
timeout=2.0,
|
||||
):
|
||||
"""
|
||||
Returns reverse DNS and country information for the given IP address
|
||||
|
||||
Args:
|
||||
ip_address (str): The IP address to check
|
||||
ip_db_path (str): path to a MMDB file from MaxMind or DBIP
|
||||
reverse_dns_map_path (str): Path to a reverse DNS map file
|
||||
reverse_dns_map_url (str): URL to the reverse DNS map file
|
||||
always_use_local_files (bool): Do not download files
|
||||
cache (ExpiringDict): Cache storage
|
||||
reverse_dns_map (dict): A reverse DNS map
|
||||
offline (bool): Do not make online queries for geolocation or DNS
|
||||
nameservers (list): A list of one or more nameservers to use
|
||||
(Cloudflare's public DNS resolvers by default)
|
||||
timeout (float): Sets the DNS timeout in seconds
|
||||
|
||||
Returns:
|
||||
OrderedDict: ``ip_address``, ``reverse_dns``
|
||||
|
||||
"""
|
||||
ip_address = ip_address.lower()
|
||||
if cache is not None:
|
||||
info = cache.get(ip_address, None)
|
||||
if info:
|
||||
logger.debug(f"IP address {ip_address} was found in cache")
|
||||
return info
|
||||
info = OrderedDict()
|
||||
info["ip_address"] = ip_address
|
||||
if offline:
|
||||
reverse_dns = None
|
||||
else:
|
||||
reverse_dns = get_reverse_dns(
|
||||
ip_address, nameservers=nameservers, timeout=timeout
|
||||
)
|
||||
country = get_ip_address_country(ip_address, db_path=ip_db_path)
|
||||
info["country"] = country
|
||||
info["reverse_dns"] = reverse_dns
|
||||
info["base_domain"] = None
|
||||
info["name"] = None
|
||||
info["type"] = None
|
||||
if reverse_dns is not None:
|
||||
base_domain = get_base_domain(reverse_dns)
|
||||
if base_domain is not None:
|
||||
service = get_service_from_reverse_dns_base_domain(
|
||||
base_domain,
|
||||
offline=offline,
|
||||
local_file_path=reverse_dns_map_path,
|
||||
url=reverse_dns_map_url,
|
||||
always_use_local_file=always_use_local_files,
|
||||
reverse_dns_map=reverse_dns_map,
|
||||
)
|
||||
info["base_domain"] = base_domain
|
||||
info["type"] = service["type"]
|
||||
info["name"] = service["name"]
|
||||
|
||||
if cache is not None:
|
||||
cache[ip_address] = info
|
||||
logger.debug(f"IP address {ip_address} added to cache")
|
||||
else:
|
||||
logger.debug(f"IP address {ip_address} reverse_dns not found")
|
||||
|
||||
return info
|
||||
|
||||
|
||||
def parse_email_address(original_address):
|
||||
if original_address[0] == "":
|
||||
display_name = None
|
||||
else:
|
||||
display_name = original_address[0]
|
||||
address = original_address[1]
|
||||
address_parts = address.split("@")
|
||||
local = None
|
||||
domain = None
|
||||
if len(address_parts) > 1:
|
||||
local = address_parts[0].lower()
|
||||
domain = address_parts[-1].lower()
|
||||
|
||||
return OrderedDict(
|
||||
[
|
||||
("display_name", display_name),
|
||||
("address", address),
|
||||
("local", local),
|
||||
("domain", domain),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def get_filename_safe_string(string):
|
||||
"""
|
||||
Converts a string to a string that is safe for a filename
|
||||
|
||||
Args:
|
||||
string (str): A string to make safe for a filename
|
||||
|
||||
Returns:
|
||||
str: A string safe for a filename
|
||||
"""
|
||||
invalid_filename_chars = ["\\", "/", ":", '"', "*", "?", "|", "\n", "\r"]
|
||||
if string is None:
|
||||
string = "None"
|
||||
for char in invalid_filename_chars:
|
||||
string = string.replace(char, "")
|
||||
string = string.rstrip(".")
|
||||
|
||||
string = (string[:100]) if len(string) > 100 else string
|
||||
|
||||
return string
|
||||
|
||||
|
||||
def is_mbox(path):
|
||||
"""
|
||||
Checks if the given content is an MBOX mailbox file
|
||||
|
||||
Args:
|
||||
path: Content to check
|
||||
|
||||
Returns:
|
||||
bool: A flag that indicates if the file is an MBOX mailbox file
|
||||
"""
|
||||
_is_mbox = False
|
||||
try:
|
||||
mbox = mailbox.mbox(path)
|
||||
if len(mbox.keys()) > 0:
|
||||
_is_mbox = True
|
||||
except Exception as e:
|
||||
logger.debug("Error checking for MBOX file: {0}".format(e.__str__()))
|
||||
|
||||
return _is_mbox
|
||||
|
||||
|
||||
def is_outlook_msg(content):
|
||||
"""
|
||||
Checks if the given content is an Outlook msg OLE/MSG file
|
||||
|
||||
Args:
|
||||
content: Content to check
|
||||
|
||||
Returns:
|
||||
bool: A flag that indicates if the file is an Outlook MSG file
|
||||
"""
|
||||
return isinstance(content, bytes) and content.startswith(
|
||||
b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1"
|
||||
)
|
||||
|
||||
|
||||
def convert_outlook_msg(msg_bytes):
|
||||
"""
|
||||
Uses the ``msgconvert`` Perl utility to convert an Outlook MS file to
|
||||
standard RFC 822 format
|
||||
|
||||
Args:
|
||||
msg_bytes (bytes): the content of the .msg file
|
||||
|
||||
Returns:
|
||||
A RFC 822 string
|
||||
"""
|
||||
if not is_outlook_msg(msg_bytes):
|
||||
raise ValueError("The supplied bytes are not an Outlook MSG file")
|
||||
orig_dir = os.getcwd()
|
||||
tmp_dir = tempfile.mkdtemp()
|
||||
os.chdir(tmp_dir)
|
||||
with open("sample.msg", "wb") as msg_file:
|
||||
msg_file.write(msg_bytes)
|
||||
try:
|
||||
subprocess.check_call(
|
||||
["msgconvert", "sample.msg"], stdout=null_file, stderr=null_file
|
||||
)
|
||||
eml_path = "sample.eml"
|
||||
with open(eml_path, "rb") as eml_file:
|
||||
rfc822 = eml_file.read()
|
||||
except FileNotFoundError:
|
||||
raise EmailParserError(
|
||||
"Failed to convert Outlook MSG: msgconvert utility not found"
|
||||
)
|
||||
finally:
|
||||
os.chdir(orig_dir)
|
||||
shutil.rmtree(tmp_dir)
|
||||
|
||||
return rfc822
|
||||
|
||||
|
||||
def parse_email(data, strip_attachment_payloads=False):
|
||||
"""
|
||||
A simplified email parser
|
||||
|
||||
Args:
|
||||
data: The RFC 822 message string, or MSG binary
|
||||
strip_attachment_payloads (bool): Remove attachment payloads
|
||||
|
||||
Returns:
|
||||
dict: Parsed email data
|
||||
"""
|
||||
|
||||
if isinstance(data, bytes):
|
||||
if is_outlook_msg(data):
|
||||
data = convert_outlook_msg(data)
|
||||
data = data.decode("utf-8", errors="replace")
|
||||
parsed_email = mailparser.parse_from_string(data)
|
||||
headers = json.loads(parsed_email.headers_json).copy()
|
||||
parsed_email = json.loads(parsed_email.mail_json).copy()
|
||||
parsed_email["headers"] = headers
|
||||
|
||||
if "received" in parsed_email:
|
||||
for received in parsed_email["received"]:
|
||||
if "date_utc" in received:
|
||||
if received["date_utc"] is None:
|
||||
del received["date_utc"]
|
||||
else:
|
||||
received["date_utc"] = received["date_utc"].replace("T", " ")
|
||||
|
||||
if "from" not in parsed_email:
|
||||
if "From" in parsed_email["headers"]:
|
||||
parsed_email["from"] = parsed_email["Headers"]["From"]
|
||||
else:
|
||||
parsed_email["from"] = None
|
||||
|
||||
if parsed_email["from"] is not None:
|
||||
parsed_email["from"] = parse_email_address(parsed_email["from"][0])
|
||||
|
||||
if "date" in parsed_email:
|
||||
parsed_email["date"] = parsed_email["date"].replace("T", " ")
|
||||
else:
|
||||
parsed_email["date"] = None
|
||||
if "reply_to" in parsed_email:
|
||||
parsed_email["reply_to"] = list(
|
||||
map(lambda x: parse_email_address(x), parsed_email["reply_to"])
|
||||
)
|
||||
else:
|
||||
parsed_email["reply_to"] = []
|
||||
|
||||
if "to" in parsed_email:
|
||||
parsed_email["to"] = list(
|
||||
map(lambda x: parse_email_address(x), parsed_email["to"])
|
||||
)
|
||||
else:
|
||||
parsed_email["to"] = []
|
||||
|
||||
if "cc" in parsed_email:
|
||||
parsed_email["cc"] = list(
|
||||
map(lambda x: parse_email_address(x), parsed_email["cc"])
|
||||
)
|
||||
else:
|
||||
parsed_email["cc"] = []
|
||||
|
||||
if "bcc" in parsed_email:
|
||||
parsed_email["bcc"] = list(
|
||||
map(lambda x: parse_email_address(x), parsed_email["bcc"])
|
||||
)
|
||||
else:
|
||||
parsed_email["bcc"] = []
|
||||
|
||||
if "delivered_to" in parsed_email:
|
||||
parsed_email["delivered_to"] = list(
|
||||
map(lambda x: parse_email_address(x), parsed_email["delivered_to"])
|
||||
)
|
||||
|
||||
if "attachments" not in parsed_email:
|
||||
parsed_email["attachments"] = []
|
||||
else:
|
||||
for attachment in parsed_email["attachments"]:
|
||||
if "payload" in attachment:
|
||||
payload = attachment["payload"]
|
||||
try:
|
||||
if "content_transfer_encoding" in attachment:
|
||||
if attachment["content_transfer_encoding"] == "base64":
|
||||
payload = decode_base64(payload)
|
||||
else:
|
||||
payload = str.encode(payload)
|
||||
attachment["sha256"] = hashlib.sha256(payload).hexdigest()
|
||||
except Exception as e:
|
||||
logger.debug("Unable to decode attachment: {0}".format(e.__str__()))
|
||||
if strip_attachment_payloads:
|
||||
for attachment in parsed_email["attachments"]:
|
||||
if "payload" in attachment:
|
||||
del attachment["payload"]
|
||||
|
||||
if "subject" not in parsed_email:
|
||||
parsed_email["subject"] = None
|
||||
|
||||
parsed_email["filename_safe_subject"] = get_filename_safe_string(
|
||||
parsed_email["subject"]
|
||||
)
|
||||
|
||||
if "body" not in parsed_email:
|
||||
parsed_email["body"] = None
|
||||
|
||||
return parsed_email
|
||||
50
parsedmarc/webhook.py
Normal file
@@ -0,0 +1,50 @@
|
||||
import requests
|
||||
|
||||
from parsedmarc import logger
|
||||
|
||||
|
||||
class WebhookClient(object):
|
||||
"""A client for webhooks"""
|
||||
|
||||
def __init__(self, aggregate_url, forensic_url, smtp_tls_url, timeout=60):
|
||||
"""
|
||||
Initializes the WebhookClient
|
||||
Args:
|
||||
aggregate_url (str): The aggregate report webhook url
|
||||
forensic_url (str): The forensic report webhook url
|
||||
smtp_tls_url (str): The smtp_tls report webhook url
|
||||
timeout (int): The timeout to use when calling the webhooks
|
||||
"""
|
||||
self.aggregate_url = aggregate_url
|
||||
self.forensic_url = forensic_url
|
||||
self.smtp_tls_url = smtp_tls_url
|
||||
self.timeout = timeout
|
||||
self.session = requests.Session()
|
||||
self.session.headers = {
|
||||
"User-Agent": "parsedmarc",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
def save_forensic_report_to_webhook(self, report):
|
||||
try:
|
||||
self._send_to_webhook(self.forensic_url, report)
|
||||
except Exception as error_:
|
||||
logger.error("Webhook Error: {0}".format(error_.__str__()))
|
||||
|
||||
def save_smtp_tls_report_to_webhook(self, report):
|
||||
try:
|
||||
self._send_to_webhook(self.smtp_tls_url, report)
|
||||
except Exception as error_:
|
||||
logger.error("Webhook Error: {0}".format(error_.__str__()))
|
||||
|
||||
def save_aggregate_report_to_webhook(self, report):
|
||||
try:
|
||||
self._send_to_webhook(self.aggregate_url, report)
|
||||
except Exception as error_:
|
||||
logger.error("Webhook Error: {0}".format(error_.__str__()))
|
||||
|
||||
def _send_to_webhook(self, webhook_url, payload):
|
||||
try:
|
||||
self.session.post(webhook_url, data=payload, timeout=self.timeout)
|
||||
except Exception as error_:
|
||||
logger.error("Webhook Error: {0}".format(error_.__str__()))
|
||||
10
publish-docs.sh
Executable file
@@ -0,0 +1,10 @@
|
||||
#!/bin/bash
|
||||
git pull
|
||||
cd ../parsedmarc-docs || exit
|
||||
git pull
|
||||
cd ../parsedmarc || exit
|
||||
./build.sh
|
||||
cd ../parsedmarc-docs || exit
|
||||
git add .
|
||||
git commit -m "Update docs"
|
||||
git push
|
||||
84
pyproject.toml
Normal file
@@ -0,0 +1,84 @@
|
||||
[build-system]
|
||||
requires = [
|
||||
"hatchling>=1.27.0",
|
||||
]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "parsedmarc"
|
||||
dynamic = [
|
||||
"version",
|
||||
]
|
||||
description = "A Python package and CLI for parsing aggregate and forensic DMARC reports"
|
||||
readme = "README.md"
|
||||
license = "Apache-2.0"
|
||||
authors = [
|
||||
{ name = "Sean Whalen", email = "whalenster@gmail.com" },
|
||||
]
|
||||
keywords = [
|
||||
"DMARC",
|
||||
"parser",
|
||||
"reporting",
|
||||
]
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
"Intended Audience :: Developers",
|
||||
"Intended Audience :: Information Technology",
|
||||
"License :: OSI Approved :: Apache Software License",
|
||||
"Operating System :: OS Independent",
|
||||
"Programming Language :: Python :: 3"
|
||||
]
|
||||
dependencies = [
|
||||
"azure-identity>=1.8.0",
|
||||
"azure-monitor-ingestion>=1.0.0",
|
||||
"boto3>=1.16.63",
|
||||
"dateparser>=1.1.1",
|
||||
"dnspython>=2.0.0",
|
||||
"elasticsearch-dsl==7.4.0",
|
||||
"elasticsearch<7.14.0",
|
||||
"expiringdict>=1.1.4",
|
||||
"geoip2>=3.0.0",
|
||||
"google-api-core>=2.4.0",
|
||||
"google-api-python-client>=2.35.0",
|
||||
"google-auth-httplib2>=0.1.0",
|
||||
"google-auth-oauthlib>=0.4.6",
|
||||
"google-auth>=2.3.3",
|
||||
"imapclient>=2.1.0",
|
||||
"kafka-python-ng>=2.2.2",
|
||||
"lxml>=4.4.0",
|
||||
"mailsuite>=1.9.18",
|
||||
"msgraph-core==0.2.2",
|
||||
"opensearch-py>=2.4.2,<=3.0.0",
|
||||
"publicsuffixlist>=0.10.0",
|
||||
"pygelf>=0.4.2",
|
||||
"requests>=2.22.0",
|
||||
"tqdm>=4.31.1",
|
||||
"urllib3>=1.25.7",
|
||||
"xmltodict>=0.12.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
build = [
|
||||
"hatch>=1.14.0",
|
||||
"myst-parser[linkify]",
|
||||
"nose",
|
||||
"pytest",
|
||||
"pytest-cov",
|
||||
"ruff",
|
||||
"sphinx",
|
||||
"sphinx_rtd_theme",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
parsedmarc = "parsedmarc.cli:_main"
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://domainaware.github.io/parsedmarc"
|
||||
|
||||
[tool.hatch.version]
|
||||
path = "parsedmarc/__init__.py"
|
||||
|
||||
[tool.hatch.build.targets.sdist]
|
||||
include = [
|
||||
"/parsedmarc",
|
||||
]
|
||||
@@ -1,10 +0,0 @@
|
||||
dnspython
|
||||
requests
|
||||
publicsuffix
|
||||
xmltodict
|
||||
geoip2
|
||||
flake8
|
||||
sphinx
|
||||
sphinx_rtd_theme
|
||||
collective.checkdocs
|
||||
wheel
|
||||
40
samples/aggregate/!example.com!1538204542!1538463818.xml
Normal file
@@ -0,0 +1,40 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<feedback>
|
||||
<report_metadata>
|
||||
<org_name></org_name>
|
||||
<email>administrator@accurateplastics.com</email>
|
||||
<report_id>example.com:1538463741</report_id>
|
||||
<date_range>
|
||||
<begin>1538413632</begin>
|
||||
<end>1538413632</end>
|
||||
</date_range>
|
||||
</report_metadata>
|
||||
<policy_published>
|
||||
<domain>example.com</domain>
|
||||
<adkim>r</adkim>
|
||||
<aspf>r</aspf>
|
||||
<p>none</p>
|
||||
<sp>reject</sp>
|
||||
<pct>100</pct>
|
||||
</policy_published>
|
||||
<record>
|
||||
<row>
|
||||
<source_ip>12.20.127.122</source_ip>
|
||||
<count>1</count>
|
||||
<policy_evaluated>
|
||||
<disposition>none</disposition>
|
||||
<dkim>fail</dkim>
|
||||
<spf>fail</spf>
|
||||
</policy_evaluated>
|
||||
</row>
|
||||
<identifiers>
|
||||
<header_from>example.com</header_from>
|
||||
</identifiers>
|
||||
<auth_results>
|
||||
<spf>
|
||||
<domain></domain>
|
||||
<result>none</result>
|
||||
</spf>
|
||||
</auth_results>
|
||||
</record>
|
||||
</feedback>
|
||||
45740
samples/aggregate/!large-example.com!1711897200!1711983600.xml
Normal file
@@ -0,0 +1,181 @@
|
||||
Received: from SN6PR04MB4480.namprd04.prod.outlook.com (2603:10b6:803:41::31)
|
||||
by SN6PR04MB4477.namprd04.prod.outlook.com with HTTPS via
|
||||
SN4PR0501CA0054.NAMPRD05.PROD.OUTLOOK.COM; Wed, 13 Feb 2019 10:48:13 +0000
|
||||
Received: from DM5PR04CA0035.namprd04.prod.outlook.com (2603:10b6:3:12b::21)
|
||||
by SN6PR04MB4480.namprd04.prod.outlook.com (2603:10b6:805:a5::17) with
|
||||
Microsoft SMTP Server (version=TLS1_2,
|
||||
cipher=TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384) id 15.20.1622.16; Wed, 13 Feb
|
||||
2019 10:48:12 +0000
|
||||
Received: from BY2NAM03FT014.eop-NAM03.prod.protection.outlook.com
|
||||
(2a01:111:f400:7e4a::207) by DM5PR04CA0035.outlook.office365.com
|
||||
(2603:10b6:3:12b::21) with Microsoft SMTP Server (version=TLS1_2,
|
||||
cipher=TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA384) id 15.20.1622.16 via Frontend
|
||||
Transport; Wed, 13 Feb 2019 10:48:12 +0000
|
||||
Authentication-Results: spf=softfail (sender IP is 199.230.200.198)
|
||||
smtp.mailfrom=google.com; cardinalhealth.mail.onmicrosoft.com; dkim=fail
|
||||
(signature did not verify)
|
||||
header.d=google.com;cardinalhealth.mail.onmicrosoft.com; dmarc=fail
|
||||
action=oreject header.from=google.com;
|
||||
Received-SPF: SoftFail (protection.outlook.com: domain of transitioning
|
||||
google.com discourages use of 199.230.200.198 as permitted sender)
|
||||
Received: from SMTP10.cardinalhealth.com (199.230.200.198) by
|
||||
BY2NAM03FT014.mail.protection.outlook.com (10.152.84.239) with Microsoft SMTP
|
||||
Server (version=TLS1_2, cipher=TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA384) id
|
||||
15.20.1580.10 via Frontend Transport; Wed, 13 Feb 2019 10:48:12 +0000
|
||||
Received: from WPOH0019EXHYB01.cardinalhealth.net (10.212.25.115) by
|
||||
smtp10.cardinalhealth.com (10.208.183.149) with Microsoft SMTP Server (TLS)
|
||||
id 14.3.361.1; Wed, 13 Feb 2019 05:47:36 -0500
|
||||
Received: from WPIL0210EXCAS23.cardinalhealth.net (161.244.3.66) by
|
||||
WPOH0019EXHYB01.cardinalhealth.net (10.212.25.115) with Microsoft SMTP Server
|
||||
(TLS) id 14.3.361.1; Wed, 13 Feb 2019 05:47:32 -0500
|
||||
Received: from smtp2.cardinal.com (198.89.161.108) by
|
||||
WPIL0210EXCAS23.cardinalhealth.net (161.244.3.66) with Microsoft SMTP Server
|
||||
(TLS) id 14.3.361.1; Wed, 13 Feb 2019 04:47:32 -0600
|
||||
Authentication-Results-Original: smtp2.cardinal.com; spf=Pass
|
||||
smtp.mailfrom=noreply-dmarc-support@google.com
|
||||
Received-SPF: Pass (smtp2.cardinal.com: domain of
|
||||
noreply-dmarc-support@google.com designates 209.85.166.201 as
|
||||
permitted sender) identity=mailfrom;
|
||||
client-ip=209.85.166.201; receiver=smtp2.cardinal.com;
|
||||
envelope-from="noreply-dmarc-support@google.com";
|
||||
x-sender="noreply-dmarc-support@google.com";
|
||||
x-conformance=spf_only; x-record-type="v=spf1"
|
||||
X-SenderGrp: None
|
||||
X-SBRS: 3.5
|
||||
X-ExtWarning: Yes
|
||||
X-SLBL-Result: SAFE-LISTED
|
||||
X-Amp-Result: UNKNOWN
|
||||
X-Amp-Original-Verdict: FILE UNKNOWN
|
||||
X-Amp-File-Uploaded: False
|
||||
IronPort-PHdr: =?us-ascii?q?9a23=3AQPa/HRJPWXWEsohNPdmcpTZcNBhigK39O0su0rRi?=
|
||||
=?us-ascii?q?jrtPdqq5+JG7Zh7Q4vJiiFKPVoLeuatJ?=
|
||||
X-IPAS-Result: =?us-ascii?q?A0HNBQBz9WNch8mmVdFjHAEBAR8EAQEFAQEGEIE2AoJpgQM?=
|
||||
=?us-ascii?q?nh0+FPIsFlRGCXYIygSQDGCoSCAEEGAMTgQKDOQECg1YhATQJDQEDAQEBAQEBA?=
|
||||
=?us-ascii?q?QECAhABAQEKCwkIKSMIBIJ0BE06MAEBAQEBAQEBAQEBAQEBAQEBAQEBAQEUAjM?=
|
||||
=?us-ascii?q?XKQUYGwwBAThiMgEFAYNZAYIBngU8iikBAQGCCxOCeAEBBXSBFzuDPIEdBwgSg?=
|
||||
=?us-ascii?q?luFFIF9gkSCFoFXgkyBJoF4AoF4NoR5GoEshjgJiViIBIhVgQsJgXaBRIEPgRu?=
|
||||
=?us-ascii?q?BVosZGYFeAWOQLRmYRgGDVQ8hgSVugSFwXIESgU4JIxeBczSDIIp0ITCPbwEB?=
|
||||
X-IronPort-Outbreak-Status: No, level 0, Unknown - Unknown
|
||||
Received: from mail-it1-f201.google.com ([209.85.166.201]) by
|
||||
smtp2.cardinal.com with ESMTP/TLS/AES128-GCM-SHA256; 13 Feb 2019 04:47:15
|
||||
-0600
|
||||
Received: by mail-it1-f201.google.com with SMTP id p21so3350239itb.8
|
||||
for <dmarcreports@cardinalhealth.com>; Wed, 13 Feb 2019 02:47:15 -0800
|
||||
(PST)
|
||||
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed;
|
||||
d=google.com; s=20161025;
|
||||
h=mime-version:date:message-id:subject:from:to:content-disposition
|
||||
:content-transfer-encoding;
|
||||
bh=9auxxiIHA4359CcIJ8D48sw6ZXttCPhnCsgP3zpHWC4=;
|
||||
b=QSiY0EPGhBY11lvmfniPOmA71xd6uAv17KYGbEtmOtocmGen1BQ96kZA2rNtAtZDUx
|
||||
IfLoJgkzO31GmVXh9k0kBi+r8vR16zXebZHuBgfBesayykHOfSSWOTZtwSukaWV3RChV
|
||||
PPMRPMksnrITfFHNo3u6xbcx6usplxn8XS8XyENgua30BBjweJXYMrQrRkrjgLv+JpoY
|
||||
o6fVvAtcuSnwtm3fv9j1GsqSK05sw2aVFZkJLP9DvMfhW+bXJJ2rVp4MvVqlleua20Ro
|
||||
Y0vbFMWtbvFZseSOc+AYGvv6oL9eBilXal26kS2BrRJQ+B4Yt4GFiRDbjF4QqVSTHOd4
|
||||
YDSw==
|
||||
X-Google-DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed;
|
||||
d=1e100.net; s=20161025;
|
||||
h=x-gm-message-state:mime-version:date:message-id:subject:from:to
|
||||
:content-disposition:content-transfer-encoding;
|
||||
bh=9auxxiIHA4359CcIJ8D48sw6ZXttCPhnCsgP3zpHWC4=;
|
||||
b=lqkAygJJ/s8iZJI+AnwWegB47wiLE67qFfi26J+5Uu5lQuObEqK2KTlBZGwnd51c8R
|
||||
A2y47YQ9RqBKWTibQH9pLBKT5ChyxP/+Vk8e3wK+IfU720wG8P2eDXn91rBSBNIXOTOn
|
||||
McNwB/z6Ukurt8GFiy4aMvni0t3yWa4/xL0c5zFQJAgwm01jhxOjgOpnnqF0ppYatzf4
|
||||
5maCMzhvRJ9FFsDhk8sE0pJjdaWS9ybDGKOteSYr1wlGSnydTkt2z6z38IO8fgVJwT3e
|
||||
AUiqhNgNmDdyNI3Aom8dnfJHsyqjKC3iTXGxmqsMDVui4faHwOqMx2lgjuv7bbQFyv1K
|
||||
b//w==
|
||||
X-Gm-Message-State: AHQUAubgr+0/AsmLjETdSMNb9+rAZ5j0xlExGd75CusDHQJl4eIy2avN
|
||||
XnhZqrYsvbPhKCNFyDE0KQ==
|
||||
X-Google-Smtp-Source: AHgI3IZS0dawFR+Q0mnSaOenxA+M5W6V6z1dMorYgiX855zYf4aqZyS2Jjol+TCgY3f/lSsVDbuiefhqmZNtNA==
|
||||
MIME-Version: 1.0
|
||||
X-Received: by 2002:a24:1c87:: with SMTP id c129mr1998419itc.11.1550054834929;
|
||||
Wed, 13 Feb 2019 02:47:14 -0800 (PST)
|
||||
Date: Tue, 12 Feb 2019 15:59:59 -0800
|
||||
Message-ID: <949348866075514174@google.com>
|
||||
Subject: Report domain: borschow.com Submitter: google.com Report-ID: 949348866075514174
|
||||
From: <noreply-dmarc-support@google.com>
|
||||
To: <dmarcreports@cardinalhealth.com>
|
||||
name="google.com!borschow.com!1549929600!1550015999.zip"
|
||||
filename="google.com!borschow.com!1549929600!1550015999.zip"
|
||||
Return-Path: noreply-dmarc-support@google.com
|
||||
X-OrganizationHeadersPreserved: WPIL0210EXCAS23.cardinalhealth.net
|
||||
X-MS-Exchange-Organization-ExpirationStartTime: 13 Feb 2019 10:48:12.5214
|
||||
(UTC)
|
||||
X-MS-Exchange-Organization-ExpirationStartTimeReason: OriginalSubmit
|
||||
X-MS-Exchange-Organization-ExpirationInterval: 2:00:00:00.0000000
|
||||
X-MS-Exchange-Organization-ExpirationIntervalReason: OriginalSubmit
|
||||
X-MS-Exchange-Organization-Network-Message-Id:
|
||||
e9371fc9-cf12-4fcb-502a-08d691a0c038
|
||||
X-EOPAttributedMessage: 0
|
||||
X-MS-Exchange-Organization-MessageDirectionality: Originating
|
||||
X-CrossPremisesHeadersPromoted:
|
||||
BY2NAM03FT014.eop-NAM03.prod.protection.outlook.com
|
||||
X-CrossPremisesHeadersFiltered:
|
||||
BY2NAM03FT014.eop-NAM03.prod.protection.outlook.com
|
||||
X-Forefront-Antispam-Report:
|
||||
CIP:199.230.200.198;IPV:NLI;CTRY:US;EFV:NLI;SFV:SKN;SFS:;DIR:INB;SFP:;SCL:-1;SRVR:SN6PR04MB4480;H:SMTP10.cardinalhealth.com;FPR:;SPF:None;LANG:en;
|
||||
X-Microsoft-Exchange-Diagnostics:
|
||||
1;BY2NAM03FT014;1:9gD0HAosLjXNiAwpOsmGDn2zgTJAIEDY0eKyvNoIEz6oio7FckNeMUQRNa3AUeC/Qil0Sn2rntyy6LjTvutGN6e2BsGQyNaiKEsKI3so3l0Kpb9i3CdYF21/lNVHrjKuxxEJ8F7WUBlR88jcBwDjl6x0CO2FBckAmUnBJOJv2zg=
|
||||
X-MS-Exchange-Organization-AuthSource: WPIL0210EXCAS23.cardinalhealth.net
|
||||
X-MS-Exchange-Organization-AuthAs: Anonymous
|
||||
X-OriginatorOrg: cardinalhealth.onmicrosoft.com
|
||||
X-MS-PublicTrafficType: Email
|
||||
X-MS-Office365-Filtering-Correlation-Id: e9371fc9-cf12-4fcb-502a-08d691a0c038
|
||||
X-Microsoft-Antispam:
|
||||
BCL:0;PCL:0;RULEID:(2390118)(7020095)(4652040)(8989299)(4534185)(4627221)(201703031133081)(8559020)(8990200)(5600110)(711020)(4605077)(4710068)(4711035)(2017052603328)(7153060)(49563074);SRVR:SN6PR04MB4480;
|
||||
X-MS-TrafficTypeDiagnostic: SN6PR04MB4480:
|
||||
X-MS-Exchange-Organization-SCL: -1
|
||||
X-Microsoft-Exchange-Diagnostics:
|
||||
1;SN6PR04MB4480;20:9lFp0O5yeS9rEVtgFCaVjrPpXZAA0REuLHin4MfFWihk274IOvh7BRRMQfNNBwtC3q0+vTeNPc37wIBQlwVq6T7j1bNe06DnEjGgP5GWNU7zOUjt6qeq21ebYk/RV2QcTM85ZcFxr8SF2bGAKcNkj7GQLnnogH5o1GotLtqwXOht4qNZxhp46eCrIt+yQJJHFJyHFoflM9+z2WAYSl6yY8Wauhp05LBIqjduLdEN6MmU7bpPrzOmBrc4nuOmA4s1D8A3KdzBCdx0wIEwBv1zq6RjAB43UrfhpwMmh07U60CU/0QPhSrEBfn19eZLv4eTRJozsA313tp/mPylGCegahxmClixw/ku/GENI6pWxTCz3Jz1x8YCMLqJS7M+UOg7IosLPbUr26Q2CtSduue1vhk6v8peX5c5di6b9WftMKup3YMESA0RrqNbS6HbBCmH+iHSUwWjTBVva4L0fGiG82SbFbkH/UH+ZoFzkFnMtDZwDK+9pK+oHS2k97XwVzzYrzFh98JBdnk6jI/L2MmHWldt75NqJjSifAf2P/PjlploUQ8CAHsZZSRx5cu5tLaptOzUq/NVXF23VavhTslxK5C0/ntAAZAAvhmR8v/FNFU=;23:m8XEp0VuraCd8j9yukaQaVZE+Ufz0qQ9v4369t8CewCI5dikl+UkdVfYaJsMrwHTAtH3it3YrgDXpo7py6m/RDwgDnLGxviIfy/puyEEN3Qq99TJUMn19W9S5U7VJ8DgMZsnEv6RVCrjf05bNshRyA==
|
||||
X-Microsoft-Exchange-Diagnostics:
|
||||
1;SN6PR04MB4480;20:fJacS7QTNHPZGJt2KoBiyZLfHf3R2G+vFZ1EUyyFaqoQUdJU3WDLMmHMUqn36br0oZNxyMkV05SJMoFAz3mVO2hO/nsUX6SR7RMpr5XHYxLD+tPbbTTT2aGzo5IR+GOrJc5l3z4uX34x0WdoggvjUhi6DWaqwMn/OnkEBJ7ozYg=
|
||||
X-MS-Exchange-CrossTenant-OriginalArrivalTime: 13 Feb 2019 10:48:12.1984
|
||||
(UTC)
|
||||
X-MS-Exchange-CrossTenant-Network-Message-Id: e9371fc9-cf12-4fcb-502a-08d691a0c038
|
||||
X-MS-Exchange-CrossTenant-Id: a86dc674-e6a2-4c25-b4ba-e36c31382530
|
||||
X-MS-Exchange-CrossTenant-OriginalAttributedTenantConnectingIp: TenantId=a86dc674-e6a2-4c25-b4ba-e36c31382530;Ip=[199.230.200.198];Helo=[SMTP10.cardinalhealth.com]
|
||||
X-MS-Exchange-CrossTenant-FromEntityHeader: HybridOnPrem
|
||||
X-MS-Exchange-Transport-CrossTenantHeadersStamped: SN6PR04MB4480
|
||||
X-MS-Exchange-Transport-EndToEndLatency: 00:00:01.1339945
|
||||
X-MS-Exchange-Processed-By-BccFoldering: 15.20.1601.011
|
||||
X-Microsoft-Antispam-Mailbox-Delivery:
|
||||
ucf:0;jmr:0;ex:0;auth:0;dest:I;ENG:(750119)(520011016)(944506303)(944626516);
|
||||
X-Microsoft-Antispam-Message-Info:
|
||||
Cqz80Cj8FVW4uTBbPiVvb9OH0/VEl4Uz5BiS+YWHErndckPxKMInYe6J09QImrgTO+t2bYNNpL66Km1sbVKa2o+iWj1pSIxAONbkfZuosS0y7Xbj/NEw1eqGtwavoj5WckV7MfJmBINAEBVg6UPnNhmW5rY8PTa27tmGQgYoE7wm5JLH0EH8ARuebjtTf8j+WuBg/BY6uaK7FLOdAUnnlvAtoHDKTnL/oH5E6GG59HTarZyC4wMl5sN2ibbMqZ80Aj4EczyuoWz1N2thodsW/4yzTk2w2dtHgwMdKgPI+4xALQC81kQAlgVyN9ukvBpJnRKrA4bvx+XhUUsMKsoXmpWdQCIoALqAfXheY/96JepEYN05Fqa7wzDRLkbejIfsfPq16asiX/kw8Dq2N/WG5LeQpC28iOkY4TB/GlI6CQPVd8rMY3DvzBYZPyCAcUdPhXC3nR+qxLea+zH/s1IRKaXolnF0r0zaiCki952hC6UwfdeK9F/nCA75kRb930QXJbmOS9emnEf+xqWhIhJuMdd8gV1NLSz6SDimeHRfDgXMTgNUjkk/BQQ0bCWAEQrRPxdvt/5PEiUZMZzMKZAqYh67j2RpV8FC/qJLjHPljagvtH4bUvGmpn/W9MArWgsUkk2skhNcLVletMwYbVXvJfJPr7K9Pnfpnd4p1ETHwjlzXaKcvlziIE29MYEXPUcg9rnk2t33YTM1NJHhgyiKebbrHC2/BU1O+BNrkZYQhqlkvsAu4JxBdyzld2sDz9CQdvwOSAwOkMpdlkaHV26Y0e6gPLkaprWVqXPr5KFXSUfuz2fvVUNM+FuHGV/fIFkcKdK4lw0MRufwhBz1gqudL/PSQuI8r9lQmuh7K3+gIprdWqOiYlYEELj+TMnSnZaFkbIX70rhAAkB7MoNfs/A38hIooGzxlRYzTrlPqwoIkOpyqQykDzXoNRODHwo7QJx
|
||||
Content-type: multipart/mixed;
|
||||
boundary="B_3632898004_720446853"
|
||||
|
||||
> This message is in MIME format. Since your mail reader does not understand
|
||||
this format, some or all of this message may not be legible.
|
||||
|
||||
--B_3632898004_720446853
|
||||
Content-type: text/plain;
|
||||
charset="UTF-8"
|
||||
Content-transfer-encoding: 7bit
|
||||
|
||||
|
||||
|
||||
--B_3632898004_720446853
|
||||
Content-type: application/zip; name="google.com!borschow.com!1549929600!1550015999.zip";
|
||||
x-mac-creator="4F50494D"
|
||||
Content-ID: <B399946F5954C04A8523535C1E5699A5@namprd04.prod.outlook.com>
|
||||
Content-disposition: attachment;
|
||||
filename="google.com!borschow.com!1549929600!1550015999.zip"
|
||||
Content-transfer-encoding: base64
|
||||
|
||||
|
||||
UEsDBAoAAAAIAPhTTU5+28OP0QEAACgEAAAxAAAAZ29vZ2xlLmNvbSFib3JzY2hvdy5jb20h
|
||||
MTU0OTkyOTYwMCExNTUwMDE1OTk5LnhtbI1TwXakIBC85yvmzX1EJ2rGfYTsab9g9+xjoFU2
|
||||
CjzATPL3iwHRnexhT2J1d3VXNeCX92k8vIGxQsnnY5HlxwNIpriQ/fPx188fp8vx8EIecAfA
|
||||
r5S9kofDARvQyrh2Akc5dXTBPKpM30o6AemV6kfImJowSmDIgYmKkUjlGcaPE5+oYSc764Xu
|
||||
+74s5MWad2doy5R0lLlWyE6RwTltvyEUS7OtFFFEpb2BQeeyrqtL7rm+1gfiKENw0pTNY3m5
|
||||
1HX+VFVFWTyVGG3RkO2VQmuo7KMWD12hF5IUVdk056bOfa+ArHGQ3EerPC+qpmn8JHIlQ3+z
|
||||
pW57S7FWo2AfrZ6vo7ADpEGUN0eSqzKWDeoWDItgyKD8VUzEYBQOEbS6+8SWb4A0MfAbmMNI
|
||||
R8RukF0xzRwpFnHL4XPYfw3m3WTKrDMadUsuWDUbBq3QpDln1WNWFHVW5GffIQXWVKZm6Zth
|
||||
FA4rHPvBGx1n7xtfA4sZwmplhfPXN02+x3aZixWdv1Y+IbkSRXcxkKzZqbzr6le1asOCg3Si
|
||||
E/75pLIBKAfTdkZNdyvaRyLVFwJMZze0Buw8uo1zN9b/7D9e7oUj6oo/Sdp2BdB9wyU5LBKj
|
||||
7dH/AVBLAQIKAAoAAAAIAPhTTU5+28OP0QEAACgEAAAxAAAAAAAAAAAAAAAAAAAAAABnb29n
|
||||
bGUuY29tIWJvcnNjaG93LmNvbSExNTQ5OTI5NjAwITE1NTAwMTU5OTkueG1sUEsFBgAAAAAB
|
||||
AAEAXwAAACACAAAAAA==
|
||||
--B_3632898004_720446853--
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
<?xml version="1.0"?>
|
||||
<feedback>
|
||||
<version>1.0</version>
|
||||
<report_metadata>
|
||||
<org_name>addisonfoods.com</org_name>
|
||||
<email>postmaster@addisonfoods.com</email>
|
||||
<report_id>3ceb5548498640beaeb47327e202b0b9</report_id>
|
||||
<date_range>
|
||||
<begin>1536105600</begin>
|
||||
<end>1536191999</end>
|
||||
</date_range>
|
||||
</report_metadata>
|
||||
<policy_published>
|
||||
<domain>example.com</domain>
|
||||
<adkim>r</adkim>
|
||||
<aspf>r</aspf>
|
||||
<p>none</p>
|
||||
<sp>none</sp>
|
||||
<pct>100</pct>
|
||||
<fo>0</fo>
|
||||
</policy_published>
|
||||
<record>
|
||||
<row>
|
||||
<source_ip>109.203.100.17</source_ip>
|
||||
<count>1</count>
|
||||
<policy_evaluated>
|
||||
<disposition>none</disposition>
|
||||
<dkim>fail</dkim>
|
||||
<spf>fail</spf>
|
||||
</policy_evaluated>
|
||||
</row>
|
||||
<identifiers>
|
||||
<envelope_from>example.com</envelope_from>
|
||||
<header_from>example.com</header_from>
|
||||
</identifiers>
|
||||
<auth_results>
|
||||
<dkim>
|
||||
<domain>toptierhighticket.club</domain>
|
||||
<selector>default</selector>
|
||||
<result>pass</result>
|
||||
</dkim>
|
||||
</auth_results>
|
||||
</record>
|
||||
</feedback>
|
||||
56
samples/aggregate/empty_reason.xml
Normal file
@@ -0,0 +1,56 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<feedback>
|
||||
<version>1.0</version>
|
||||
<report_metadata>
|
||||
<org_name>example.org</org_name>
|
||||
<email>noreply-dmarc-support@example.org</email>
|
||||
<extra_contact_info>https://support.example.org/dmarc</extra_contact_info>
|
||||
<report_id>20240125141224705995</report_id>
|
||||
<date_range>
|
||||
<begin>1706159544</begin>
|
||||
<end>1706185733</end>
|
||||
</date_range>
|
||||
</report_metadata>
|
||||
<policy_published>
|
||||
<domain>example.com</domain>
|
||||
<adkim>r</adkim>
|
||||
<aspf>r</aspf>
|
||||
<p>quarantine</p>
|
||||
<sp>quarantine</sp>
|
||||
<pct>100</pct>
|
||||
<fo>1</fo>
|
||||
</policy_published>
|
||||
<record>
|
||||
<row>
|
||||
<source_ip>198.51.100.123</source_ip>
|
||||
<count>2</count>
|
||||
<policy_evaluated>
|
||||
<disposition>none</disposition>
|
||||
<dkim>pass</dkim>
|
||||
<spf>fail</spf>
|
||||
<reason>
|
||||
<type></type>
|
||||
<comment></comment>
|
||||
</reason>
|
||||
</policy_evaluated>
|
||||
</row>
|
||||
<identifiers>
|
||||
<envelope_to>example.net</envelope_to>
|
||||
<envelope_from>example.edu</envelope_from>
|
||||
<header_from>example.com</header_from>
|
||||
</identifiers>
|
||||
<auth_results>
|
||||
<dkim>
|
||||
<domain>example.com</domain>
|
||||
<selector>example</selector>
|
||||
<result>pass</result>
|
||||
<human_result>2048-bit key</human_result>
|
||||
</dkim>
|
||||
<spf>
|
||||
<domain>example.edu</domain>
|
||||
<scope>mfrom</scope>
|
||||
<result>pass</result>
|
||||
</spf>
|
||||
</auth_results>
|
||||
</record>
|
||||
</feedback>
|
||||
@@ -0,0 +1,39 @@
|
||||
<?xml version="1.0"?>
|
||||
<feedback>
|
||||
<version>1.0</version>
|
||||
<report_metadata>
|
||||
<org_name>example.net</org_name>
|
||||
<email>postmaster@example.net</email>
|
||||
<report_id>b043f0e264cf4ea995e93765242f6dfb</report_id>
|
||||
<date_range>
|
||||
<begin>1529366400</begin>
|
||||
<end>1529452799</end>
|
||||
</date_range>
|
||||
</report_metadata>
|
||||
<policy_published>
|
||||
<domain>example.com</domain>
|
||||
<adkim>r</adkim>
|
||||
<aspf>r</aspf>
|
||||
<p>none</p>
|
||||
<sp>none</sp>11
|
||||
<pct>100</pct>
|
||||
<fo>0</fo>
|
||||
</policy_published>
|
||||
<record>
|
||||
<row>
|
||||
<source_ip>199.230.200.36</source_ip>
|
||||
<count>1</count>
|
||||
<policy_evaluated>
|
||||
<disposition>none</disposition>
|
||||
<dkim>fail</dkim>
|
||||
<spf>fail</spf>
|
||||
</policy_evaluated>
|
||||
</row>
|
||||
<identifiers>
|
||||
<envelope_from>example.com</envelope_from>
|
||||
<header_from>example.com</header_from>
|
||||
</identifiers>
|
||||
<auth_results>
|
||||
</auth_results>
|
||||
</record>
|
||||
</feedback>
|
||||
@@ -0,0 +1,47 @@
|
||||
<?xml version="1.0"?> <xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" targetNamespace="http://dmarc.org/dmarc-xml/0.1">
|
||||
<feedback>
|
||||
<report_metadata>
|
||||
<org_name>ikea.com</org_name>
|
||||
<email>double-bounce@ikea.com</email>
|
||||
<report_id>aggr_report_2018_10_05_5bc7e9b4f3e8a</report_id>
|
||||
<date_range>
|
||||
<begin>1538690400</begin>
|
||||
<end>1538776800</end>
|
||||
</date_range>
|
||||
</report_metadata>
|
||||
<policy_published>
|
||||
<domain>example.de</domain>
|
||||
<adkim>r</adkim>
|
||||
<aspf>r</aspf>
|
||||
<p>none</p>
|
||||
<sp>none</sp>
|
||||
<pct>100</pct>
|
||||
<fo>0</fo>
|
||||
</policy_published>
|
||||
<record>
|
||||
<row>
|
||||
<source_ip>234.234.234.234</source_ip>
|
||||
<count>1</count>
|
||||
<policy_evaluated>
|
||||
<disposition>none</disposition>
|
||||
<dkim>fail</dkim>
|
||||
<spf>fail</spf>
|
||||
</policy_evaluated>
|
||||
</row>
|
||||
<identifiers>
|
||||
<header_from>example.de</header_from>
|
||||
<envelope_from>example.de</envelope_from>
|
||||
</identifiers>
|
||||
<auth_results>
|
||||
<dkim>
|
||||
<domain>example.de</domain>
|
||||
<result>pass</result>
|
||||
</dkim>
|
||||
<spf>
|
||||
<domain>mailrelay.com</domain>
|
||||
<scope>helo</scope>
|
||||
<result>none</result>
|
||||
</spf>
|
||||
</auth_results>
|
||||
</record>
|
||||
</feedback>
|
||||
40
samples/aggregate/invalid_utf_8.xml
Normal file
@@ -0,0 +1,40 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<feedback>
|
||||
<report_metadata>
|
||||
<org_name></org_name>
|
||||
<email>administrator@accurateplastics.com</email>
|
||||
<report_id>example.com:1538463741</report_id>
|
||||
<date_range>
|
||||
<begin>1538413632</begin>
|
||||
<end>1538413632</end>
|
||||
</date_range>
|
||||
</report_metadata>
|
||||
<policy_published>
|
||||
<domain>example.com</domain>
|
||||
<adkim>r</adkim>
|
||||
<aspf>r</aspf>
|
||||
<p>none</p>
|
||||
<sp>reject</sp>
|
||||
<pct>100</pct>
|
||||
</policy_published>
|
||||
<record>
|
||||
<row>
|
||||
<source_ip>12.20.127.122</source_ip>
|
||||
<count>1</count>
|
||||
<policy_evaluated>
|
||||
<disposition>none</disposition>
|
||||
<dkim>fail</dkim>
|
||||
<spf>fail</spf>
|
||||
</policy_evaluated>
|
||||
</row>
|
||||
<identifiers>
|
||||
<header_from>bad_byte‘</header_from>
|
||||
</identifiers>
|
||||
<auth_results>
|
||||
<spf>
|
||||
<domain></domain>
|
||||
<result>none</result>
|
||||
</spf>
|
||||
</auth_results>
|
||||
</record>
|
||||
</feedback>
|
||||
40
samples/aggregate/invalid_xml.xml
Normal file
@@ -0,0 +1,40 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<feedback>
|
||||
<report_metadata>
|
||||
<org_name>veeam.com</org_name>
|
||||
<email><bad-xml@bad-xml.net></email>
|
||||
<report_id>sonexushealth.com:1530233361</report_id>
|
||||
<date_range>
|
||||
<begin>1530133200</begin>
|
||||
<end>1530219600</end>
|
||||
</date_range>
|
||||
</report_metadata>
|
||||
<policy_published>
|
||||
<domain>example.com</domain>
|
||||
<adkim>r</adkim>
|
||||
<aspf>r</aspf>
|
||||
<p>none</p>
|
||||
<sp>none</sp>
|
||||
<pct>100</pct>
|
||||
</policy_published>
|
||||
<record>
|
||||
<row>
|
||||
<source_ip>199.230.200.36</source_ip>
|
||||
<count>1</count>
|
||||
<policy_evaluated>
|
||||
<disposition>none</disposition>
|
||||
<dkim>fail</dkim>
|
||||
<spf>fail</spf>
|
||||
</policy_evaluated>
|
||||
</row>
|
||||
<identifiers>
|
||||
<header_from>bad<xml.net</header_from>
|
||||
</identifiers>
|
||||
<auth_results>
|
||||
<spf>
|
||||
<domain></domain>
|
||||
<result>none</result>
|
||||
</spf>
|
||||
</auth_results>
|
||||
</record>
|
||||
</feedback>
|
||||
132
samples/aggregate/mimecast-weird-gzip.eml
Normal file
@@ -0,0 +1,132 @@
|
||||
Received: from CY8PR05MB9817.namprd05.prod.outlook.com (::1) by
|
||||
BL0PR05MB4724.namprd05.prod.outlook.com with HTTPS; Thu, 31 Aug 2023 10:06:17
|
||||
+0000
|
||||
Received: from BN9P220CA0018.NAMP220.PROD.OUTLOOK.COM (2603:10b6:408:13e::23)
|
||||
by CY8PR05MB9817.namprd05.prod.outlook.com (2603:10b6:930:73::11) with
|
||||
Microsoft SMTP Server (version=TLS1_2,
|
||||
cipher=TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384) id 15.20.6745.18; Thu, 31 Aug
|
||||
2023 10:06:14 +0000
|
||||
Received: from BN7NAM10FT021.eop-nam10.prod.protection.outlook.com
|
||||
(2603:10b6:408:13e:cafe::8c) by BN9P220CA0018.outlook.office365.com
|
||||
(2603:10b6:408:13e::23) with Microsoft SMTP Server (version=TLS1_2,
|
||||
cipher=TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384) id 15.20.6745.21 via Frontend
|
||||
Transport; Thu, 31 Aug 2023 10:06:14 +0000
|
||||
Authentication-Results: spf=pass (sender IP is 103.13.69.22)
|
||||
smtp.mailfrom=au-1.mimecastreport.com; dkim=pass (signature was verified)
|
||||
header.d=au-1.mimecastreport.com;dmarc=pass action=none
|
||||
header.from=au-1.mimecastreport.com;compauth=pass reason=100
|
||||
Received-SPF: Pass (protection.outlook.com: domain of au-1.mimecastreport.com
|
||||
designates 103.13.69.22 as permitted sender) receiver=protection.outlook.com;
|
||||
client-ip=103.13.69.22; helo=au-mta-51.au.mimecast.lan; pr=C
|
||||
Received: from au-mta-51.au.mimecast.lan (103.13.69.22) by
|
||||
BN7NAM10FT021.mail.protection.outlook.com (10.13.157.29) with Microsoft SMTP
|
||||
Server id 15.20.6745.21 via Frontend Transport; Thu, 31 Aug 2023 10:06:13
|
||||
+0000
|
||||
From: no-reply@au-1.mimecastreport.com
|
||||
Subject: Report domain: ab.id.au Submitter: mimecast.org Report-ID:
|
||||
157a5fe30ec76f4bc0d8bccfc96c118a167a1280fee7c7465af5115e73082e5e
|
||||
To: <dmarc_rua@ab.id.au>
|
||||
Message-Id: <4973f467-4c66-4341-a50a-cf1a87033376@au-1.mimecastreport.com>
|
||||
Date: Thu, 31 Aug 2023 20:06:12 +1000
|
||||
X-MC-UniqueId: 1188de4c-94ab-48e5-ae52-1932a1244233
|
||||
Dkim-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=au-1.mimecastreport.com;
|
||||
s=20221110; t=1693476372;
|
||||
h=from:from:reply-to:subject:subject:date:date:message-id:message-id:
|
||||
to:to:cc:mime-version:mime-version:content-type:content-type:
|
||||
content-transfer-encoding:content-transfer-encoding;
|
||||
bh=XwviLMLojoFwjpACllwVkV31g5lHF/M5j8QcvOKa33g=;
|
||||
b=a9pvqoms3KHaG2iI3ZSZxaDx4MSLNUrsoGnEmkpjFihZDkHSVokflRxFTpa0e2L1mlKWry
|
||||
THZdlMEbllstH3l6YMNsSH4Rt7+EmnTLoIkVxXbcH2DCZ87n817KzQkJy3PD/4YbUUUIkV
|
||||
vg6s+3uxmtqWLjzaazTBsQFnezv8jkVqxbkhGDs9Hu8D9fXLB+Vr7wWLL/qWT3x9JHpGR6
|
||||
28a0LnmH/G6TYhi6cQXagqV0UMljJRMLi5Gn7c7GDTwoVHJN00e1uDHlwan6Cto2OXXL+7
|
||||
uBXJbCeikrAVoniMgijcGCiE5heShQXbBX72Ft+NG2plQHy2EcIqVs8uu4yvaQ==
|
||||
Content-Type: application/gzip
|
||||
Content-Transfer-Encoding: base64
|
||||
Content-Disposition: attachment;
|
||||
filename="mimecast.org!ab.id.au!1693353600!1693439999!157a5fe30ec76f4bc0d8bccfc96c118a167a1280fee7c7465af5115e73082e5e.xml.gz"
|
||||
Return-Path: no-reply@au-1.mimecastreport.com
|
||||
X-MS-Exchange-Organization-ExpirationStartTime: 31 Aug 2023 10:06:14.1450
|
||||
(UTC)
|
||||
X-MS-Exchange-Organization-ExpirationStartTimeReason: OriginalSubmit
|
||||
X-MS-Exchange-Organization-ExpirationInterval: 1:00:00:00.0000000
|
||||
X-MS-Exchange-Organization-ExpirationIntervalReason: OriginalSubmit
|
||||
X-MS-Exchange-Organization-Network-Message-Id:
|
||||
a4ad0aba-721e-49f7-92c5-08dbaa09e8df
|
||||
X-EOPAttributedMessage: 0
|
||||
X-EOPTenantAttributedMessage: 6a84b1de-1f34-4ef7-833c-a5017802acbb:0
|
||||
X-MS-Exchange-Organization-MessageDirectionality: Incoming
|
||||
X-MS-PublicTrafficType: Email
|
||||
X-MS-TrafficTypeDiagnostic:
|
||||
BN7NAM10FT021:EE_|CY8PR05MB9817:EE_|BL0PR05MB4724:EE_
|
||||
X-MS-Exchange-Organization-AuthSource:
|
||||
BN7NAM10FT021.eop-nam10.prod.protection.outlook.com
|
||||
X-MS-Exchange-Organization-AuthAs: Anonymous
|
||||
X-MS-Office365-Filtering-Correlation-Id: a4ad0aba-721e-49f7-92c5-08dbaa09e8df
|
||||
X-MS-Exchange-Organization-SCL: 5
|
||||
X-Forefront-Antispam-Report:
|
||||
CIP:103.13.69.22;CTRY:AU;LANG:en;SCL:5;SRV:;IPV:NLI;SFV:SPM;H:au-mta-51.au.mimecast.lan;PTR:au-pop.mimecast.com;CAT:SPM;SFS:(13230031)(286005)(451199024)(9402899012)(36736006)(6512007)(6916009)(8676002)(9686003)(1096003)(336012)(2616005)(31696002)(956004)(9316004)(86362001)(621065003)(73894004)(26005)(4270600006)(22186003)(7636003)(356005)(6506007)(6486002)(58800400005)(31686004)(43540500003);DIR:INB;
|
||||
X-Microsoft-Antispam: BCL:5;
|
||||
X-MS-Exchange-CrossTenant-OriginalArrivalTime: 31 Aug 2023 10:06:13.2857
|
||||
(UTC)
|
||||
X-MS-Exchange-CrossTenant-Network-Message-Id: a4ad0aba-721e-49f7-92c5-08dbaa09e8df
|
||||
X-MS-Exchange-CrossTenant-Id: 6a84b1de-1f34-4ef7-833c-a5017802acbb
|
||||
X-MS-Exchange-CrossTenant-AuthSource:
|
||||
BN7NAM10FT021.eop-nam10.prod.protection.outlook.com
|
||||
X-MS-Exchange-CrossTenant-AuthAs: Anonymous
|
||||
X-MS-Exchange-CrossTenant-FromEntityHeader: Internet
|
||||
X-MS-Exchange-Transport-CrossTenantHeadersStamped: CY8PR05MB9817
|
||||
X-MS-Exchange-Transport-EndToEndLatency: 00:00:04.5164388
|
||||
X-MS-Exchange-Processed-By-BccFoldering: 15.20.6745.019
|
||||
X-Microsoft-Antispam-Mailbox-Delivery:
|
||||
ucf:0;jmr:0;auth:0;dest:J;OFR:SpamFilterAuthJ;ENG:(910001)(944506478)(944626604)(920097)(930097)(3100021)(140003);RF:JunkEmail;
|
||||
X-Microsoft-Antispam-Message-Info:
|
||||
=?us-ascii?Q?F/3PRniLM5cjpdXatKQrGxqt4Inc28gNO8OBFkRXF4GkzZtyY/caFk627tA0?=
|
||||
=?us-ascii?Q?yjv2qyd4ZWVOw7qbNFhrHJzRnwQjRihlYNeUNP2+rYH3vdVoVqsidEu1XsGD?=
|
||||
=?us-ascii?Q?dfaoldzskg6P9Q1/4TSwYhnidrWfidNFN70D4Gj7W6KO6461A6PjJmQGVPYr?=
|
||||
=?us-ascii?Q?bvO+dtDo7cHqi8WalDaEnnFviyDo0bPZ53ZaGzRgC5zELkH/uZoNUn7RSFEW?=
|
||||
=?us-ascii?Q?iUs/6hQ6Q1f18Yi0cRn+wH2XiMS6CkhSPYAW21ZV8JTsahIpCVLSvcWR/qRQ?=
|
||||
=?us-ascii?Q?Og78TZKvGSfGcXhygh6Fg8ZrbJ+PHPS/yp5OtLgZGXKmx2vP9RyIoGa43f2E?=
|
||||
=?us-ascii?Q?atreM+Ok47Bp3VirSoX6aKj50p8VsXreo4WPxMgSrRO0LBSCgxD50aAQjZbL?=
|
||||
=?us-ascii?Q?9jgjGfn57YeRIkMQwjRTWUO0P8MHm9RTHpX9K3Tl5pMbiFnKBP1RyFG4y1vi?=
|
||||
=?us-ascii?Q?eBPG22g20XewdizlLA740EDAkMB/QWc2L7xzWf5ku32/bL5mVvGwLO/NJi8E?=
|
||||
=?us-ascii?Q?SWEotpv1tBpZOesP/xik6LoufMGLJKep8HcDnkP6IK77OpR0r2DLG1GBIHIR?=
|
||||
=?us-ascii?Q?VGKU8zPZkMqp/+7OT/Gblnq+GgixDaINpAtsSb9bP/bYTSDbbnjJtDIXdqA0?=
|
||||
=?us-ascii?Q?xgKHpSMZSSsNYpwzUFzxYXhLO15l314K7gVYEQ1nTYU74w24H5MvEYjI0+jN?=
|
||||
=?us-ascii?Q?jU/IICoJQotVyrrVhfdeFIgcaeygy6BXoRs4ab21QCan9/mv3OhvnL0FMf+Q?=
|
||||
=?us-ascii?Q?OCQZdoOz4MNSIhOnfUAftvnvoO54GDWKMU1uuAJ9hu4k8MJiJmiHyvbnMCXw?=
|
||||
=?us-ascii?Q?zujlXccxyTjhzI869wdELgzh46vjTbbdiDqlCZXmBEemSwsiwTsEeYKU242L?=
|
||||
=?us-ascii?Q?SYHzONOvbb+9IQGAYsDcCf4apvgGt8m7QUd4IeJl7Y3h8riHP2e62rf8DL1O?=
|
||||
=?us-ascii?Q?6MOwFqyLTsiyJDe6Q+M/9oUtl8/Kd935o5zo7RVkRBUB1EiBctUnzuDI1wi8?=
|
||||
=?us-ascii?Q?LGIBhxdI/0h6+d1LHH31qo1nUt6suoxJwwJ/CakejMdpIJixBygl99JVNuMN?=
|
||||
=?us-ascii?Q?a+mZyQ8/au3YxipX2oJXgvrBIoLMM8CUG22h65gbyEo0m9MEiA8HG52eUIzs?=
|
||||
=?us-ascii?Q?p29c3TLBV4gB5nmsa+rheO/hriuzQPKBfL6gmJaQLdmzMMVSGgj2zo3a7LhE?=
|
||||
=?us-ascii?Q?+p5oXgcWK4nAjgf9PPVqyxX5J7tRcKheyDzZOoKUGsKJhxFtvTHQR2vU7Je0?=
|
||||
=?us-ascii?Q?DwG5xJqzV5kZiy1IzbqH93CXtJB7/D4Ef74ZfHWKEAuC7krNyHLTsLLex+u2?=
|
||||
=?us-ascii?Q?d/U26AF36mcFJKpw6EftF4jnXkJPxBehVol2UzDi1ygwc3zoXRyVKBQ31UvR?=
|
||||
=?us-ascii?Q?TquvytzSjF9wqCA/EBltxQG0oKEPTS7cBC3KEXCmdMjnXsP/1GtIVxKwfZ6b?=
|
||||
=?us-ascii?Q?DJF/n4g9bR7MjTxaKJGXettEYlY6qDxaRWfKdwLUJwy2o+ss+QWGLA4XurLz?=
|
||||
=?us-ascii?Q?R+Q8BMvYSb4Mrjk2+ZyQ9FMKR+ujlAgkg69nzovdSNMGoZKx81oeQiXYYqn2?=
|
||||
=?us-ascii?Q?1wl15GKY/0C73em3G/EAHGz/kmOauOd/3zX4KpQNGB+u12XaTQsLsT/GEc4L?=
|
||||
=?us-ascii?Q?hYYtavwANBzCxxuzaPb+e+KdgqAuzO9+eNz7W1tL8mu+amiFEobwzxvJZpwe?=
|
||||
=?us-ascii?Q?UWGFqS6NsEfP7d5sUJh6On1F2S8il6ysFqcXFWmwFCtX3PP7x4kC3u9O2ob+?=
|
||||
=?us-ascii?Q?JvfW4s4NZA8jjWPBO5wPjIoxI9QyLsqn2xnm14qtCbWwMvvRZVK8GFqTGpOD?=
|
||||
=?us-ascii?Q?3KJkFN8u7StcQIum5MtowZxvvutMYcp4FrYtqnqbu6Sty9FM5qQFLK7Ug1Ev?=
|
||||
=?us-ascii?Q?O7mvfgDo754R27RHeP1iFc+w9jYZIOaspI5uJxL/QwPCKkxJmIN30YPt5B6M?=
|
||||
=?us-ascii?Q?0LLGF1rtAhZyzzO661QO1iRv8EITq5aMdhZnaMstm31gBy23Z9/cRHInVbwM?=
|
||||
=?us-ascii?Q?NM98sDJ8rMV1mxvWHyrzpHi1ifbRNdb26nUw8KcAnpwYawXaDJdIP/8oxBna?=
|
||||
=?us-ascii?Q?Dx7/7mZ6iBgR4M5bYJQvby3NbpMrzewqecVuZjX5wzk7ccp2K6Grm0a5+L7N?=
|
||||
=?us-ascii?Q?ET/Wn2FDOEsWHP4gIva5YCCxBzDxhf0jww=3D=3D?=
|
||||
MIME-Version: 1.0
|
||||
|
||||
H4sICAAAAAAA/21pbWVjYXN0Lm9yZyFhYi5pZC5hdSExNjkzMzUzNjAwITE2OTM0Mzk5OTkhMTU3
|
||||
YTVmZTMwZWM3NmY0YmMwZDhiY2NmYzk2YzExOGExNjdhMTI4MGZlZTdjNzQ2NWFmNTExNWU3MzA4
|
||||
MmU1ZS54bWwAjVTLctswDLznKzy+Ww/L8qNDMz311lt71sAkZLOWSA1JJfHfFzIpWU0ymeoiegEs
|
||||
sAvK7PmtbRYvaJ0y+rjMk2y5QC2MVPp8XP7+9WO1Xy6cBy2hMRqPyxu65TN/YjWiPIG48qfFglns
|
||||
jPVVix4keBgwQo09Vxpa5D9ViwKcZ+kEhQxsQTVcmxURNLfv0K/ypI3JgTMRpmVpyIs1b95CJYz2
|
||||
IHyldG34xfvOfUtTym17rfxtIhnKU5detXltUJ6RqD6WB96oQUmelzsoaywyFLttvTmJTO5PQtTi
|
||||
sBV5vod8u4N8vc/Igp3YbbYl1GWel7grsv0aS2ry4ArcZApWFvQ5CifohGeleb49FEVZbLOMpQEZ
|
||||
46jlPbopDvTQ3HokS/9lm7rN3WedaZS4VV1/apS74DSIISc1h1OiZAI9cQUgREFeVcstS8Mhgq6r
|
||||
79jwDlDHLf5BQfvsIuI62qIm5W5EOuF5PsgaDvcxPxuJXBfGjtNZ8zrpd6a3AivV8U2WHIokPxyS
|
||||
9ZoaTPiYKUyvqRdLw2GEYzt8gaYnw+QYGFxQrjNOebrycew5MssbXOjAOUqYDIl66xiYXJlJfNeT
|
||||
NjQKY0qi9qpW9MFNZRcEibaqrWlnm5mjkeZDMYPeXyqLrm/8g+/drF/uPKjBhrZpLB8PZOaEPdJC
|
||||
n6g7/nhEL30LOs6SPiyZD8NmZv3XYF90nDlPd/OdDUNyuFcsffxR/QXF3tRK7AQAAA0K
|
||||