dmarc_report 1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.gitignore +14 -0
- data/.rubocop.yml +44 -0
- data/CHANGELOG.md +3 -0
- data/LICENSE.txt +22 -0
- data/README.md +84 -0
- data/bin/dmarc_dns.rb +109 -0
- data/bin/dmarc_dump.rb +47 -0
- data/bin/dmarc_imap.rb +416 -0
- data/bin/dmarc_report.rb +161 -0
- data/bin/dmarc_ripmime.rb +144 -0
- data/bin/install-dmarc_report.rb +126 -0
- data/bin/rename_rfc.rb +44 -0
- data/examples/config/dmarc.yml +13 -0
- data/examples/dmarc-profile.sh +7 -0
- data/examples/rules/dmarc.yml +33 -0
- data/lib/xmltohash.rb +61 -0
- metadata +106 -0
data/bin/dmarc_imap.rb
ADDED
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
|
|
3
|
+
# = dmarc_imap.rb
|
|
4
|
+
#
|
|
5
|
+
# Author:: Dirk Meyer
|
|
6
|
+
# Copyright:: Copyright (c) 2020 - 2024 Dirk Meyer
|
|
7
|
+
# License:: Distributes under the same terms as Ruby
|
|
8
|
+
# SPDX-FileCopyrightText: 2020-2024 Dirk Meyer
|
|
9
|
+
# SPDX-License-Identifier: Ruby
|
|
10
|
+
#
|
|
11
|
+
# Parse IMAP accounts for DMARC reports and save them to files
|
|
12
|
+
# Input: IMAP folder
|
|
13
|
+
# Output: defined in the rulesets
|
|
14
|
+
#
|
|
15
|
+
|
|
16
|
+
# inspired by
|
|
17
|
+
# http://wonko.com/post/ruby_script_to_sync_email_from_any_imap_server_to_gmail
|
|
18
|
+
|
|
19
|
+
# dependecies:
|
|
20
|
+
# gem install --user-install new_rfc_2047
|
|
21
|
+
|
|
22
|
+
require 'openssl'
|
|
23
|
+
require 'net/imap'
|
|
24
|
+
require 'yaml'
|
|
25
|
+
require 'date'
|
|
26
|
+
# require 'rfc_2047'
|
|
27
|
+
|
|
28
|
+
# Maximum number of messages to select at once.
|
|
29
|
+
UID_BLOCK_SIZE = 1024
|
|
30
|
+
# Lockfile filename
|
|
31
|
+
LOCKFILENAME = '/tmp/dmarc-imap.lck'.freeze
|
|
32
|
+
# Lockfile timeout
|
|
33
|
+
LOCKTIMEOUT = 60 * 60 # 60 min
|
|
34
|
+
|
|
35
|
+
# https://stackoverflow.com/questions/7488875/how-to-decode-an-rfc-2047-encoded-email-header-in-ruby
|
|
36
|
+
# see also: https://github.com/ConradIrwin/rfc2047-ruby/
|
|
37
|
+
# see also: https://github.com/tonytonyjan/rfc_2047/
|
|
38
|
+
module Rfc2047
|
|
39
|
+
TOKEN = /[\041\043-\047\052\053\055\060-\071\101-\132\134\136\137\141-\176]+/.freeze
|
|
40
|
+
ENCODED_TEXT = /[\041-\076\100-\176]+/.freeze
|
|
41
|
+
ENCODED_WORD = /=\?(?<charset>#{TOKEN})\?(?<encoding>[QqBb])\?(?<encoded_text>#{ENCODED_TEXT})\?=/i.freeze
|
|
42
|
+
|
|
43
|
+
class << self
|
|
44
|
+
# encode text
|
|
45
|
+
def encode( input )
|
|
46
|
+
"=?#{input.encoding}?B?#{[ input ].pack( 'm0' )}?="
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# decode text
|
|
50
|
+
def decode( input )
|
|
51
|
+
match_data = ENCODED_WORD.match( input )
|
|
52
|
+
# raise ArgumentError if match_data.nil?
|
|
53
|
+
# not encoded
|
|
54
|
+
return input if match_data.nil?
|
|
55
|
+
|
|
56
|
+
charset, encoding, encoded_text = match_data.captures
|
|
57
|
+
decoded =
|
|
58
|
+
case encoding
|
|
59
|
+
when 'Q', 'q' then encoded_text.unpack1( 'M' )
|
|
60
|
+
when 'B', 'b' then encoded_text.unpack1( 'm' )
|
|
61
|
+
end
|
|
62
|
+
decoded.force_encoding( charset )
|
|
63
|
+
begin
|
|
64
|
+
return decoded.encode( 'utf-8' )
|
|
65
|
+
rescue Encoding::InvalidByteSequenceError
|
|
66
|
+
decoded.force_encoding( 'BINARY' )
|
|
67
|
+
warn "InvalidByteSequenceError in #{decoded}"
|
|
68
|
+
end
|
|
69
|
+
decoded
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# fetch a block of mails
|
|
75
|
+
def uid_fetch_block( server, uids, *args, &block )
|
|
76
|
+
pos = 0
|
|
77
|
+
while pos < uids.size
|
|
78
|
+
server.uid_fetch( uids[ pos, UID_BLOCK_SIZE ], *args ).each( &block )
|
|
79
|
+
pos += UID_BLOCK_SIZE
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# report bad headers
|
|
84
|
+
def debug_headers( data )
|
|
85
|
+
puts 'Error in Header:'
|
|
86
|
+
puts data.inspect
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# decode a header to UTF-8
|
|
90
|
+
def save_decode( key, text )
|
|
91
|
+
return nil if text.nil?
|
|
92
|
+
|
|
93
|
+
begin
|
|
94
|
+
text = Rfc2047.decode( text )
|
|
95
|
+
rescue Encoding::CompatibilityError => e
|
|
96
|
+
warn "Encoding::CompatibilityError #{e}"
|
|
97
|
+
warn "in #{key}: #{text}"
|
|
98
|
+
rescue StandardError => e
|
|
99
|
+
warn e
|
|
100
|
+
warn "in #{key}: #{text}"
|
|
101
|
+
end
|
|
102
|
+
text
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# decode all headers to UTF-8
|
|
106
|
+
def decode_header( headers )
|
|
107
|
+
headers.each_pair do |key, list|
|
|
108
|
+
list.map! { |text| save_decode( key, text ) }
|
|
109
|
+
end
|
|
110
|
+
headers
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# fetch all headers of a mail
|
|
114
|
+
def fetch_header( data )
|
|
115
|
+
headers = {}
|
|
116
|
+
last = nil
|
|
117
|
+
index = nil
|
|
118
|
+
return headers if data.attr[ 'RFC822.HEADER' ].nil?
|
|
119
|
+
|
|
120
|
+
data.attr[ 'RFC822.HEADER' ].split( "\r\n" ).each do |line|
|
|
121
|
+
# p line
|
|
122
|
+
case line
|
|
123
|
+
when /^[ \t]/ # continuation line
|
|
124
|
+
if last.nil? || index.nil?
|
|
125
|
+
debug_headers( line )
|
|
126
|
+
return headers
|
|
127
|
+
end
|
|
128
|
+
headers[ last ][ index ] << line
|
|
129
|
+
next
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# new header line
|
|
133
|
+
key, val = line.split( ':', 2 )
|
|
134
|
+
if val.nil?
|
|
135
|
+
debug_headers( line )
|
|
136
|
+
return headers
|
|
137
|
+
end
|
|
138
|
+
val.strip!
|
|
139
|
+
if headers.key?( key )
|
|
140
|
+
headers[ key ].push( val )
|
|
141
|
+
else
|
|
142
|
+
headers[ key ] = [ val ]
|
|
143
|
+
end
|
|
144
|
+
last = key
|
|
145
|
+
index = headers[ key ].size - 1
|
|
146
|
+
end
|
|
147
|
+
decode_header( headers )
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# fetch body of a mail
|
|
151
|
+
def fetch_body( imap, data )
|
|
152
|
+
key = data.attr[ 'UID' ]
|
|
153
|
+
body = data.attr[ 'RFC822.HEADER' ]
|
|
154
|
+
# pp key
|
|
155
|
+
body << imap.uid_fetch( key, 'BODY[TEXT]' )[ 0 ].attr[ 'BODY[TEXT]' ]
|
|
156
|
+
# pp body
|
|
157
|
+
body
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# find a header matching the given ruleset
|
|
161
|
+
def find_header( rule, headers, key, header )
|
|
162
|
+
unless rule.key?( key )
|
|
163
|
+
return nil # no rule
|
|
164
|
+
end
|
|
165
|
+
unless headers.key?( header )
|
|
166
|
+
# puts "no header '#{header}'"
|
|
167
|
+
return false # no header
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
headers[ header ].each do |line|
|
|
171
|
+
if line.include?( rule[ key ] )
|
|
172
|
+
puts "# found: #{rule[ key ]}"
|
|
173
|
+
return true
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
# puts "no match '#{header}'"
|
|
177
|
+
false # no match
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# select and create a IMAP folder
|
|
181
|
+
def create_folder( dest, dest_folder )
|
|
182
|
+
puts "Selecting folder '#{dest_folder}'..."
|
|
183
|
+
dest.select( dest_folder )
|
|
184
|
+
rescue Net::IMAP::NoResponseError => e
|
|
185
|
+
begin
|
|
186
|
+
warn 'Folder not found; creating...'
|
|
187
|
+
dest.create( dest_folder )
|
|
188
|
+
dest.subscribe( dest_folder )
|
|
189
|
+
dest.select( dest_folder )
|
|
190
|
+
rescue Net::IMAP::NoResponseError => ee
|
|
191
|
+
warn ee.inspect
|
|
192
|
+
@cancel = true
|
|
193
|
+
nil
|
|
194
|
+
rescue StandardError => ee
|
|
195
|
+
warn "Error: could not create folder: #{e}: #{ee}"
|
|
196
|
+
exit 1
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# strip zone from date line
|
|
201
|
+
def clean_date( date )
|
|
202
|
+
date.sub( / \([A-Z]+\)$/, '' )
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# save mail as file and enumerate in case of duplicates
|
|
206
|
+
def run_save( dir, file, body )
|
|
207
|
+
dest = "#{dir}/#{file}"
|
|
208
|
+
unless File.exist?( dest )
|
|
209
|
+
File.write( dest, body )
|
|
210
|
+
return true
|
|
211
|
+
end
|
|
212
|
+
puts "Warning: file exist: #{dest}"
|
|
213
|
+
old = File.read( dest )
|
|
214
|
+
return true if old == body
|
|
215
|
+
|
|
216
|
+
2.upto( 5 ) do |i|
|
|
217
|
+
dest = "#{dir}/#{file}.#{i}"
|
|
218
|
+
unless File.exist?( dest )
|
|
219
|
+
File.write( dest, body )
|
|
220
|
+
return true
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
false
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# use IMAP path syntax
|
|
228
|
+
def map_target( target )
|
|
229
|
+
return target unless @translate_slash
|
|
230
|
+
|
|
231
|
+
target.gsub( '/', '.' )
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# move mail into target folder
|
|
235
|
+
def move_target( imap, imap_uid, target )
|
|
236
|
+
target = map_target( target )
|
|
237
|
+
create_folder( imap, target ) if @create_folder
|
|
238
|
+
puts "# move: #{target}"
|
|
239
|
+
imap.uid_copy( imap_uid, target )
|
|
240
|
+
imap.uid_store( imap_uid, '+FLAGS', [ :Deleted ] )
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# strip filename from unwanted characters
|
|
244
|
+
def clean_filename( filename )
|
|
245
|
+
filename.gsub( /[^[:print:]]/, '_' ).sub( '/', '_' )
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# execute ruleset on given mail
|
|
249
|
+
def run_action( imap, rule, imap_uid, headers, data )
|
|
250
|
+
if rule.key?( 'move' )
|
|
251
|
+
move_target( imap, imap_uid, rule[ 'move' ] )
|
|
252
|
+
return
|
|
253
|
+
end
|
|
254
|
+
return unless rule.key?( 'save' )
|
|
255
|
+
|
|
256
|
+
dir = rule[ 'save' ][ 'dir' ]
|
|
257
|
+
unless headers.key?( 'Date' )
|
|
258
|
+
warn 'Error: no Date-Header'
|
|
259
|
+
return
|
|
260
|
+
end
|
|
261
|
+
unless headers.key?( 'Subject' )
|
|
262
|
+
warn 'Error: no Subject-Header'
|
|
263
|
+
return
|
|
264
|
+
end
|
|
265
|
+
puts "# save: #{dir}"
|
|
266
|
+
pp headers[ 'Date' ].first
|
|
267
|
+
pp clean_date( headers[ 'Date' ].first )
|
|
268
|
+
file = Date.rfc2822( clean_date( headers[ 'Date' ].first ) ).to_s
|
|
269
|
+
file << ' '
|
|
270
|
+
file << headers[ 'Subject' ].first
|
|
271
|
+
body = fetch_body( imap, data )
|
|
272
|
+
return unless run_save( dir, clean_filename( file ), body )
|
|
273
|
+
|
|
274
|
+
move_target( imap, imap_uid, rule[ 'save' ][ 'move' ] )
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# check all rulesets of given mail
|
|
278
|
+
def run_rules( imap, rules, headers, data )
|
|
279
|
+
moved = false
|
|
280
|
+
rules.each do |rule|
|
|
281
|
+
[ 'from', 'to', 'subject' ].each do |key|
|
|
282
|
+
header = key.capitalize
|
|
283
|
+
next unless find_header( rule, headers, key, header )
|
|
284
|
+
|
|
285
|
+
run_action( imap, rule, data.attr[ 'UID' ], headers, data )
|
|
286
|
+
moved = true
|
|
287
|
+
break
|
|
288
|
+
end
|
|
289
|
+
break if moved
|
|
290
|
+
|
|
291
|
+
next unless rule.key?( 'match' )
|
|
292
|
+
next unless headers.key?( rule[ 'header' ] )
|
|
293
|
+
|
|
294
|
+
headers[ rule[ 'header' ] ].each do |line|
|
|
295
|
+
next unless line.include?( rule[ 'match' ] )
|
|
296
|
+
|
|
297
|
+
puts "# found: #{rule[ 'match' ]}"
|
|
298
|
+
run_action( imap, rule, data.attr[ 'UID' ], headers, data )
|
|
299
|
+
moved = true
|
|
300
|
+
break
|
|
301
|
+
end
|
|
302
|
+
break if moved
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
# check input folder for mails
|
|
307
|
+
def run_folder( imap, folder, filename )
|
|
308
|
+
rules = YAML.load_file( filename )
|
|
309
|
+
# puts rules.inspect
|
|
310
|
+
|
|
311
|
+
begin
|
|
312
|
+
imap.select( folder )
|
|
313
|
+
rescue Net::IMAP::NoResponseError => e
|
|
314
|
+
warn e.inspect
|
|
315
|
+
warn "Folder '#{folder}' not found; abort..."
|
|
316
|
+
return
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
suids = imap.uid_search( [ 'ALL' ] )
|
|
320
|
+
puts "folder = '#{folder}' messages = #{suids.length}"
|
|
321
|
+
return unless suids.length.positive?
|
|
322
|
+
|
|
323
|
+
uid_fetch_block( imap, suids, [ 'RFC822.HEADER' ] ) do |data|
|
|
324
|
+
break if @cancel
|
|
325
|
+
|
|
326
|
+
headers = fetch_header( data )
|
|
327
|
+
if @debug
|
|
328
|
+
pp headers
|
|
329
|
+
puts headers[ 'Subject' ].first if headers.key?( 'Subject' )
|
|
330
|
+
puts
|
|
331
|
+
end
|
|
332
|
+
run_rules( imap, rules, headers, data )
|
|
333
|
+
end
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
# connect to IMAP server and parse mails
|
|
337
|
+
def run_sort( config )
|
|
338
|
+
imap = Net::IMAP.new( config[ 'host' ], { port: config[ 'port' ], ssl: { ca_file: config[ 'ca_file' ] } } )
|
|
339
|
+
imap.login( config[ 'login' ], config[ 'password' ] )
|
|
340
|
+
|
|
341
|
+
run_folder( imap, config[ 'folder' ], config[ 'rules' ] )
|
|
342
|
+
|
|
343
|
+
imap.expunge
|
|
344
|
+
imap.logout
|
|
345
|
+
imap.disconnect
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
# set options from configfile
|
|
349
|
+
def parse_options( config )
|
|
350
|
+
@translate_slash = config[ 'translate_slash' ]
|
|
351
|
+
@translate_slash = false if @translate_slash.nil?
|
|
352
|
+
@create_folder = config[ 'create_folder' ]
|
|
353
|
+
@create_folder = false if @create_folder.nil?
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
# parse arguments from commandline
|
|
357
|
+
def parse_arguments
|
|
358
|
+
@pattern = nil
|
|
359
|
+
@check_force = false
|
|
360
|
+
@debug = false
|
|
361
|
+
ARGV.each do |arg|
|
|
362
|
+
case arg
|
|
363
|
+
when 'force'
|
|
364
|
+
@check_force = true
|
|
365
|
+
when 'debug'
|
|
366
|
+
@debug = true
|
|
367
|
+
else
|
|
368
|
+
@pattern = argv
|
|
369
|
+
end
|
|
370
|
+
end
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
parse_arguments
|
|
374
|
+
|
|
375
|
+
# use a lockfile to limit parallel jobs
|
|
376
|
+
if File.exist?( LOCKFILENAME )
|
|
377
|
+
puts "locked: #{LOCKFILENAME}"
|
|
378
|
+
unless @check_force
|
|
379
|
+
mtime = File.stat( LOCKFILENAME ).mtime
|
|
380
|
+
exit 0 if mtime.to_i > Time.now.to_i - LOCKTIMEOUT
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
warn "unlocking: #{LOCKFILENAME}"
|
|
384
|
+
File.unlink( LOCKFILENAME )
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
File.write( LOCKFILENAME, Time.now.to_s )
|
|
388
|
+
|
|
389
|
+
# run on each given IMAP account
|
|
390
|
+
@cancel = false
|
|
391
|
+
dir = 'config'
|
|
392
|
+
Dir.foreach( dir ) do |file|
|
|
393
|
+
case file
|
|
394
|
+
when '.', '..'
|
|
395
|
+
next # skip unix entries
|
|
396
|
+
when /[.]yml$/
|
|
397
|
+
puts file
|
|
398
|
+
next if !@pattern.nil? && !/^#{@pattern}/.match( file )
|
|
399
|
+
|
|
400
|
+
config = YAML.load_file( "#{dir}/#{file}" )
|
|
401
|
+
parse_options( config )
|
|
402
|
+
|
|
403
|
+
# puts config.inspect
|
|
404
|
+
if config.key?( 'disable' )
|
|
405
|
+
puts 'disabled'
|
|
406
|
+
next
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
run_sort( config )
|
|
410
|
+
end
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
File.unlink( LOCKFILENAME )
|
|
414
|
+
|
|
415
|
+
exit 0
|
|
416
|
+
# eof
|
data/bin/dmarc_report.rb
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
|
|
3
|
+
# = dmarc_report.rb
|
|
4
|
+
#
|
|
5
|
+
# Author:: Dirk Meyer
|
|
6
|
+
# Copyright:: Copyright (c) 2020 - 2023 Dirk Meyer
|
|
7
|
+
# License:: Distributes under the same terms as Ruby
|
|
8
|
+
# SPDX-FileCopyrightText: 2020-2023 Dirk Meyer
|
|
9
|
+
# SPDX-License-Identifier: Ruby
|
|
10
|
+
#
|
|
11
|
+
# Parse directory with DMARC reports and extract the attachments
|
|
12
|
+
# Input: directory with extracted XML attachments
|
|
13
|
+
# Output: summary reports as CSV file
|
|
14
|
+
#
|
|
15
|
+
|
|
16
|
+
require 'nokogiri'
|
|
17
|
+
require 'csv'
|
|
18
|
+
|
|
19
|
+
$: << 'lib'
|
|
20
|
+
|
|
21
|
+
require 'xmltohash'
|
|
22
|
+
|
|
23
|
+
# directory with extracted XML attachments
|
|
24
|
+
XML_DIR = 'DMARC/xml'.freeze
|
|
25
|
+
# summary reports as CSV file
|
|
26
|
+
OUTPUT_FILE = 'dmarc-report.csv'.freeze
|
|
27
|
+
|
|
28
|
+
# convert tiemstamp to short ISO date
|
|
29
|
+
def date_text( secs )
|
|
30
|
+
Time.at( secs.to_i ).strftime( '%Y-%m-%d' )
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# generate csv header
|
|
34
|
+
def titles
|
|
35
|
+
[ :ziel_server, :datum, :source_ip, :header_from, :envelope_to, :anzahl,
|
|
36
|
+
:dkim, :dkim_ergebnis, :spf, :spf_ergebnis, :local_policy ].map( &:to_s )
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# parse auth_results entry
|
|
40
|
+
def auth_results_entry( entry, header_from )
|
|
41
|
+
if entry.key?( :domain )
|
|
42
|
+
entry[ :domain ]&.downcase!
|
|
43
|
+
return entry[ :result ] if entry[ :domain ] == header_from
|
|
44
|
+
|
|
45
|
+
return "#{entry[ :result ]}:#{entry[ :domain ]}"
|
|
46
|
+
end
|
|
47
|
+
return "#{entry[ :result ]}:#{entry[ :scope ]}" if entry.key?( :scope )
|
|
48
|
+
|
|
49
|
+
entry[ :result ]
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# search auth_results
|
|
53
|
+
def auth_results_field( record, field, header_from )
|
|
54
|
+
return nil unless record[ :auth_results ].respond_to?( :key? )
|
|
55
|
+
return nil unless record[ :auth_results ].key?( field )
|
|
56
|
+
|
|
57
|
+
return auth_results_entry( record[ :auth_results ][ field ], header_from ) \
|
|
58
|
+
if record[ :auth_results ][ field ].respond_to?( :key? )
|
|
59
|
+
|
|
60
|
+
list = []
|
|
61
|
+
record[ :auth_results ][ field ].each do |entry|
|
|
62
|
+
list.push( auth_results_entry( entry, header_from ) )
|
|
63
|
+
end
|
|
64
|
+
list.join( ' ' )
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# search for arc results
|
|
68
|
+
def local_results( hash )
|
|
69
|
+
return nil if hash.nil?
|
|
70
|
+
return nil unless hash.key?( :type )
|
|
71
|
+
return hash[ :type ] if hash[ :type ] == 'mailing_list'
|
|
72
|
+
return "type=#{hash[ :type ]}" if hash[ :type ] != 'local_policy'
|
|
73
|
+
return hash[ :type ] unless hash.key?( :comment )
|
|
74
|
+
return hash[ :type ] if hash[ :comment ].nil?
|
|
75
|
+
|
|
76
|
+
hash[ :comment ]
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# add cvs row
|
|
80
|
+
def add_row( hash, record )
|
|
81
|
+
# pp record
|
|
82
|
+
pp record
|
|
83
|
+
header_from = record[ :identifiers ][ :header_from ]
|
|
84
|
+
header_from&.downcase!
|
|
85
|
+
@log.push( [
|
|
86
|
+
hash[ :feedback ][ :report_metadata ][ :email ].split( '@' ).last,
|
|
87
|
+
date_text( hash[ :feedback ][ :report_metadata ][ :date_range ][ :begin ] ),
|
|
88
|
+
record[ :row ][ :source_ip ],
|
|
89
|
+
header_from,
|
|
90
|
+
record[ :identifiers ][ :envelope_to ],
|
|
91
|
+
record[ :row ][ :count ].to_i,
|
|
92
|
+
record[ :row ][ :policy_evaluated ][ :dkim ],
|
|
93
|
+
auth_results_field( record, :dkim, header_from ),
|
|
94
|
+
record[ :row ][ :policy_evaluated ][ :spf ],
|
|
95
|
+
auth_results_field( record, :spf, header_from ),
|
|
96
|
+
local_results( record[ :row ][ :policy_evaluated ][ :reason ] )
|
|
97
|
+
] )
|
|
98
|
+
pp @log.last
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# parse xml file
|
|
102
|
+
def run_xml( fullname )
|
|
103
|
+
p fullname
|
|
104
|
+
raw = File.read( fullname )
|
|
105
|
+
return if raw.empty?
|
|
106
|
+
return if raw == 'unused'
|
|
107
|
+
|
|
108
|
+
begin
|
|
109
|
+
h = Hash.from_xml( raw )
|
|
110
|
+
rescue NoMethodError
|
|
111
|
+
warn "Bad XML in #{fullname}"
|
|
112
|
+
pp 'raw'
|
|
113
|
+
return
|
|
114
|
+
end
|
|
115
|
+
pp h
|
|
116
|
+
# AOL can send empty XML files
|
|
117
|
+
return if h.nil?
|
|
118
|
+
|
|
119
|
+
records = h[ :feedback ][ :record ]
|
|
120
|
+
# pp records
|
|
121
|
+
if records.respond_to?( :key? )
|
|
122
|
+
add_row( h, records )
|
|
123
|
+
return
|
|
124
|
+
end
|
|
125
|
+
records.each do |record|
|
|
126
|
+
add_row( h, record )
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# parse xml directory
|
|
131
|
+
def parse_xml_dir
|
|
132
|
+
Dir.entries( XML_DIR ).each do |file|
|
|
133
|
+
fullname = "#{XML_DIR}/#{file}"
|
|
134
|
+
case file
|
|
135
|
+
when /^[.]/
|
|
136
|
+
next
|
|
137
|
+
when /[.]xml$/, /[.]xml_[0-9]*$/
|
|
138
|
+
run_xml( fullname )
|
|
139
|
+
next
|
|
140
|
+
end
|
|
141
|
+
p fullname
|
|
142
|
+
puts 'Aborted extension'
|
|
143
|
+
exit 1
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
@log = []
|
|
148
|
+
parse_xml_dir
|
|
149
|
+
# pp @log
|
|
150
|
+
|
|
151
|
+
# write csv
|
|
152
|
+
@log.sort_by! { |r| r[ 1 ] }
|
|
153
|
+
CSV.open( OUTPUT_FILE, 'wb+', col_sep: ';' ) do |csv|
|
|
154
|
+
csv << titles
|
|
155
|
+
@log.each do |row|
|
|
156
|
+
csv << row
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
exit 0
|
|
161
|
+
# eof
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
|
|
3
|
+
# = dmarc_ripmime.rb
|
|
4
|
+
#
|
|
5
|
+
# Author:: Dirk Meyer
|
|
6
|
+
# Copyright:: Copyright (c) 2020 - 2023 Dirk Meyer
|
|
7
|
+
# License:: Distributes under the same terms as Ruby
|
|
8
|
+
# SPDX-FileCopyrightText: 2020-2023 Dirk Meyer
|
|
9
|
+
# SPDX-License-Identifier: Ruby
|
|
10
|
+
#
|
|
11
|
+
# Parse directory with DMARC reports and extract the attachments
|
|
12
|
+
# Input: file with mail
|
|
13
|
+
# Output: decompressed attachments
|
|
14
|
+
#
|
|
15
|
+
|
|
16
|
+
# dependecies:
|
|
17
|
+
# apt-get install ripmime
|
|
18
|
+
|
|
19
|
+
# directory with new reports
|
|
20
|
+
INPUT_DIR = 'DMARC/in'.freeze
|
|
21
|
+
# directory with processed reports
|
|
22
|
+
SEEN_DIR = 'DMARC/seen'.freeze
|
|
23
|
+
# directory with new attachments
|
|
24
|
+
RIPMIME_DIR = 'DMARC/ripmime'.freeze
|
|
25
|
+
# directory with processed attachments
|
|
26
|
+
ZIP_DIR = 'DMARC/zips'.freeze
|
|
27
|
+
# directory with extracted attachments
|
|
28
|
+
XML_DIR = 'DMARC/xml'.freeze
|
|
29
|
+
# working directory for unzip
|
|
30
|
+
TMP_DIR = 'DMARC/tmp'.freeze
|
|
31
|
+
|
|
32
|
+
# start ripmime
|
|
33
|
+
def run_ripmime( src, dest )
|
|
34
|
+
line = "ripmime -i '#{src}' -d '#{RIPMIME_DIR}'"
|
|
35
|
+
rc = system( line )
|
|
36
|
+
if rc
|
|
37
|
+
# puts "mv '#{src}' '#{dest}'"
|
|
38
|
+
File.rename( src, dest )
|
|
39
|
+
return
|
|
40
|
+
end
|
|
41
|
+
warn 'ABORTED: ripmime'
|
|
42
|
+
exit 1
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# search for new reports
|
|
46
|
+
def parse_input_dir
|
|
47
|
+
Dir.entries( INPUT_DIR ).each do |file|
|
|
48
|
+
next if file =~ /^[.]/
|
|
49
|
+
|
|
50
|
+
fullname = "#{INPUT_DIR}/#{file}"
|
|
51
|
+
target = "#{SEEN_DIR}/#{file}"
|
|
52
|
+
p fullname
|
|
53
|
+
run_ripmime( fullname, target )
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# search for decompressed XML file
|
|
58
|
+
def parse_temp_dir
|
|
59
|
+
list = []
|
|
60
|
+
Dir.entries( TMP_DIR ).each do |file|
|
|
61
|
+
next if file =~ /^[.]/
|
|
62
|
+
|
|
63
|
+
list.push( file )
|
|
64
|
+
end
|
|
65
|
+
if list.empty?
|
|
66
|
+
puts 'no xml file found'
|
|
67
|
+
pp list
|
|
68
|
+
exit 1
|
|
69
|
+
end
|
|
70
|
+
if list.size != 1
|
|
71
|
+
puts 'to many xml files:'
|
|
72
|
+
list.each do |file|
|
|
73
|
+
puts "rm #{file}"
|
|
74
|
+
File.unlink( "#{TMP_DIR}/#{file}" )
|
|
75
|
+
end
|
|
76
|
+
exit 1
|
|
77
|
+
end
|
|
78
|
+
list.first
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# decompress zip attachment
|
|
82
|
+
def unzip( fullname, target )
|
|
83
|
+
return if File.exist?( target )
|
|
84
|
+
|
|
85
|
+
line = "unzip -j -n -d '#{TMP_DIR}' '#{fullname}'"
|
|
86
|
+
p line
|
|
87
|
+
rc = system( line )
|
|
88
|
+
if rc
|
|
89
|
+
src = "#{TMP_DIR}/#{parse_temp_dir}"
|
|
90
|
+
# puts "mv '#{src}' '#{target}'"
|
|
91
|
+
File.rename( src.to_s, target )
|
|
92
|
+
return
|
|
93
|
+
end
|
|
94
|
+
warn 'ABORTED: unzip'
|
|
95
|
+
exit 1
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# decompress gzip attachment
|
|
99
|
+
def ungzip( fullname, target )
|
|
100
|
+
return if File.exist?( target )
|
|
101
|
+
|
|
102
|
+
line = "gunzip -c '#{fullname}' > '#{target}'"
|
|
103
|
+
p line
|
|
104
|
+
rc = system( line )
|
|
105
|
+
return if rc
|
|
106
|
+
|
|
107
|
+
p rc
|
|
108
|
+
warn 'ABORTED: gunzip'
|
|
109
|
+
exit 1
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# search for new attachments
|
|
113
|
+
def parse_ripmime_dir
|
|
114
|
+
Dir.entries( RIPMIME_DIR ).each do |file|
|
|
115
|
+
fullname = "#{RIPMIME_DIR}/#{file}"
|
|
116
|
+
saved = "#{ZIP_DIR}/#{file}"
|
|
117
|
+
case file
|
|
118
|
+
when /^[.]/
|
|
119
|
+
next
|
|
120
|
+
when /[.]zip$/
|
|
121
|
+
# target = "#{XML_DIR}/#{file}".sub( /(_[0-9])*[.]zip$/, '.xml' )
|
|
122
|
+
target = "#{XML_DIR}/#{file}".sub( /[.]zip$/, '.xml' )
|
|
123
|
+
unzip( fullname, target )
|
|
124
|
+
File.rename( fullname, saved )
|
|
125
|
+
next
|
|
126
|
+
when /[.]xml(_[0-9])*[.]gz$/
|
|
127
|
+
# target = "#{XML_DIR}/#{file}".sub( /(_[0-9])*[.]gz$/, '' )
|
|
128
|
+
target = "#{XML_DIR}/#{file}".sub( /[.]gz$/, '' )
|
|
129
|
+
ungzip( fullname, target )
|
|
130
|
+
File.rename( fullname, saved )
|
|
131
|
+
next
|
|
132
|
+
when /^textfile/
|
|
133
|
+
File.unlink( fullname )
|
|
134
|
+
next
|
|
135
|
+
end
|
|
136
|
+
puts "IGNORED extension: #{fullname}"
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
parse_input_dir
|
|
141
|
+
parse_ripmime_dir
|
|
142
|
+
|
|
143
|
+
exit 0
|
|
144
|
+
# eof
|