recheck 0.0.1 → 0.4.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.
@@ -0,0 +1,271 @@
1
+ module Recheck
2
+ module Reporter
3
+ class Base
4
+ @subclasses = []
5
+
6
+ # Register subclasses for `recheck reporters`.
7
+ class << self
8
+ attr_reader :subclasses
9
+
10
+ def inherited(subclass)
11
+ super
12
+ @subclasses << subclass
13
+ end
14
+ end
15
+
16
+ def self.help
17
+ end
18
+
19
+ def initialize(arg:)
20
+ end
21
+
22
+ def fetch_record_id(record)
23
+ if Recheck.unloaded_is_a? record, "ActiveRecord::Base"
24
+ record.id.to_s
25
+ # or: record.to_global_id, if you want to override in
26
+ # your_app/recheck/reporter/base_reporter.rb
27
+ elsif Recheck.unloaded_is_a? record, "Sequel::Model"
28
+ record.pk.to_s # may be an array
29
+ else
30
+ record.to_s
31
+ end
32
+ end
33
+
34
+ # A recheck run flows like this, with indicated calls to each reporter.
35
+ #
36
+ # around_run -> for each Checker class:
37
+ # around_checker ->
38
+ # run each query() method
39
+ # for each 'check_' method on the checker:
40
+ # for each record queried:
41
+ # around_check -> check(record)
42
+
43
+ def around_run(checkers: [])
44
+ total_count = yield
45
+ end
46
+
47
+ def around_checker(checker:, queries: [], checks: [])
48
+ counts = yield
49
+ end
50
+
51
+ def around_check(checker:, query:, check:, record:)
52
+ result = yield
53
+ end
54
+
55
+ def halt(checker:, query:, error:, check: nil)
56
+ # running the checker was halted, so there's no result available for yield
57
+ end
58
+ end # Base
59
+
60
+ class Cron < Base
61
+ def self.help
62
+ "Prints failures/exceptions but nothing on pass. For use in cron jobs, which use silence to incidate success."
63
+ end
64
+
65
+ def initialize(arg:)
66
+ raise ArgumentError, "does not take options" unless arg.nil?
67
+ @errors = []
68
+ end
69
+
70
+ def around_run(checkers: [])
71
+ total_counts = yield
72
+
73
+ if total_counts.any_errors?
74
+ puts "Total: #{total_counts.summary}"
75
+ end
76
+ end
77
+
78
+ def around_checker(checker:, queries:, checks:)
79
+ @errors = []
80
+
81
+ counts = yield
82
+
83
+ if counts.any_errors?
84
+ puts "#{checker.class}: #{counts.summary}"
85
+ print_errors
86
+ end
87
+ end
88
+
89
+ def around_check(checker:, query:, check:, record:)
90
+ result = yield
91
+ @errors << result if result.is_a? Error
92
+ end
93
+
94
+ def halt(checker:, query:, error:, check: nil)
95
+ @errors << error
96
+ end
97
+
98
+ def print_errors
99
+ failure_details = []
100
+ grouped_errors = @errors.group_by { |e| [e.checker_class, e.query, e.check, e.type] }
101
+
102
+ grouped_errors.each do |(checker_class, query, check), group_errors|
103
+ case group_errors.first.type
104
+ when :fail
105
+ ids = group_errors.map { |e| fetch_record_id(e.record) }.join(", ")
106
+ failure_details << " #{checker_class}##{query} -> #{check} failed for records: #{ids}"
107
+ when :exception
108
+ error = group_errors.first
109
+ error_message = " #{checker_class}##{query} -> #{check} exception #{error.exception.message} for #{group_errors.size} records"
110
+ failure_details << error_message
111
+ failure_details << error.record.full_message(highlight: false, order: :top) if error.record.respond_to?(:full_message)
112
+ when :blanket
113
+ failure_details << " #{checker_class}: Skipping because the first 20 checks all failed. Either there's a lot of bad data or there's something wrong with the checks."
114
+ end
115
+ end
116
+ puts failure_details
117
+ end
118
+ end # Cron
119
+
120
+ class Default < Base
121
+ def self.help
122
+ "Used when no --reporter is named. Prints incremental progress to stdout. No options."
123
+ end
124
+
125
+ def initialize(arg:)
126
+ raise ArgumentError, "does not take options" unless arg.nil?
127
+ @current_counts = CountStats.new
128
+ @errors = []
129
+ end
130
+
131
+ def around_run(checkers: [])
132
+ total_counts = yield
133
+
134
+ puts "Total: #{total_counts.summary}"
135
+ puts "Queries found no records to check (this is OK when a checker queries for invalid data)" if total_counts.all_zero?
136
+
137
+ total_counts
138
+ end
139
+
140
+ def around_checker(checker:, queries:, checks:, check: [])
141
+ @errors = []
142
+
143
+ print "#{checker.class}: "
144
+ counts = yield
145
+
146
+ # don't double-print last progress indicator
147
+ print_progress unless @current_counts.total % 1000 == 0
148
+ print_check_summary(counts)
149
+ print_errors
150
+
151
+ counts
152
+ end
153
+
154
+ def around_check(checker:, query:, check:, record:)
155
+ result = yield
156
+
157
+ @current_counts.increment(result.type)
158
+ print_progress if @current_counts.total % 1000 == 0
159
+
160
+ @errors << result if result.is_a? Error
161
+ end
162
+
163
+ def halt(checker:, query:, error:, check: nil)
164
+ @errors << error
165
+ end
166
+
167
+ def print_check_summary(counts)
168
+ puts " #{counts.summary}"
169
+ end
170
+
171
+ def print_errors
172
+ failure_details = []
173
+ grouped_errors = @errors.group_by { |e| [e.checker, e.query, e.check, e.type] }
174
+
175
+ grouped_errors.each do |(checker, query, check), group_errors|
176
+ case group_errors.first.type
177
+ when :fail
178
+ ids = group_errors.map { |e| fetch_record_id(e.record) }.join(", ")
179
+ failure_details << " #{checker.class}##{query} -> #{check} failed for records: #{ids}"
180
+ when :exception
181
+ error = group_errors.first
182
+ error_message = " #{checker.class}##{query} -> #{check} exception #{error.exception.message} for #{group_errors.size} records"
183
+ failure_details << error_message
184
+ failure_details << error.exception.full_message(highlight: false, order: :top) if error.exception.respond_to?(:full_message)
185
+ when :no_query_methods
186
+ failure_details << " #{checker.class}: Did not define .query_methods"
187
+ when :no_queries
188
+ failure_details << " #{checker.class} Defines .query_methods, but it didn't return any"
189
+ when :no_check_methods
190
+ failure_details << " #{checker.class}: Did not define .check_methods"
191
+ when :no_checks
192
+ failure_details << " #{checker.class} Defines .check_methods, but it didn't return any"
193
+ when :blanket
194
+ failure_details << " #{checker.class}: Skipping because the first 20 checks all failed. Either there's a lot of bad data or there's something wrong with the checker."
195
+ else
196
+ failure_details << " #{checker.class} unknown error"
197
+ end
198
+ end
199
+ puts failure_details
200
+ end
201
+
202
+ def print_progress
203
+ print @current_counts.all_pass? ? "." : "x"
204
+ @current_counts = CountStats.new
205
+ end
206
+ end # Default
207
+
208
+ class Json < Base
209
+ def self.help
210
+ "Outputs JSON-formatted results to a file or stdout. Arg is filename or blank for stdout."
211
+ end
212
+
213
+ def initialize(arg:)
214
+ @filename = arg
215
+ @results = {}
216
+ end
217
+
218
+ def around_checker(checker:, queries:, checks:, check: [])
219
+ @results[checker.class.to_s] = checks.to_h { |method|
220
+ [method, {
221
+ counts: CountStats.new,
222
+ fail: [],
223
+ exception: []
224
+ }]
225
+ }
226
+ yield
227
+ end
228
+
229
+ def around_check(checker:, query:, check:, record:)
230
+ result = yield
231
+
232
+ # puts "around_check(checker: #{checker}, query: #{query}, check: #{check.inspect}, record: #{record}"
233
+ check ||= query
234
+ @results[checker.class.to_s][check][:counts].increment(result.type)
235
+ case result.type
236
+ when :fail
237
+ @results[checker.class.to_s][check][:fail] << fetch_record_id(result.record)
238
+ when :exception
239
+ @results[checker.class.to_s][check][:exception] << {
240
+ id: fetch_record_id(result.record),
241
+ message: result.exception.message,
242
+ backtrace: result.exception.backtrace
243
+ }
244
+ end
245
+ end
246
+
247
+ def around_run(checkers)
248
+ yield
249
+ if @filename
250
+ File.write(@filename, @results.to_json)
251
+ else
252
+ puts @results.to_json
253
+ end
254
+ end
255
+
256
+ def halt(checker:, query:, error:, check: "meta")
257
+ @results[checker.class.to_s][check][:halt] = error.type
258
+ end
259
+ end # Json
260
+
261
+ class Silent < Base
262
+ def self.help
263
+ "Prints nothing. Useful for checks that can automatically fix issues."
264
+ end
265
+
266
+ def initialize(arg:)
267
+ raise ArgumentError, "does not take options" unless arg.nil?
268
+ end
269
+ end # Silent
270
+ end
271
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Recheck
4
+ ERROR_TYPES = [:fail, :exception, :blanket, :no_query_methods, :no_queries, :no_check_methods, :no_checks].freeze
5
+ RESULT_TYPES = ([:pass] + ERROR_TYPES).freeze
6
+
7
+ # This doesn't track all the fields because Recheck is about finding errors and failures.
8
+ # If you need more data, please tell me about your use case?
9
+ Pass = Data.define do
10
+ def type
11
+ :pass
12
+ end
13
+ end
14
+
15
+ Error = Data.define(:checker, :query, :check, :record, :type, :exception) do
16
+ def initialize(*args)
17
+ super
18
+ raise ArgumentError unless ERROR_TYPES.include? type
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Recheck
4
+ class HookDidNotYield < RuntimeError; end
5
+
6
+ class HookYieldedTwice < RuntimeError; end
7
+
8
+ class UnexpectedHookYield < RuntimeError; end
9
+
10
+ class UnexpectedReporterYield < RuntimeError; end
11
+
12
+ class Yields
13
+ def initialize
14
+ @executions = {}
15
+ end
16
+
17
+ def expect(hook:, reporter:)
18
+ @executions[hook] ||= {}
19
+ @executions[hook][reporter] = false
20
+ # puts "expect #{hook}, #{reporter.class.name}, id #{reporter.id}"
21
+ end
22
+
23
+ def ran(hook:, reporter:)
24
+ raise UnexpectedHookYield, "Ran an unexpected hook #{hook} (for reporter #{reporter})" unless @executions.include? hook
25
+ raise UnexpectedReporterYield, "Ran an expected hook #{hook} for an unexpected reporter #{reporter}" unless @executions[hook].include? reporter
26
+ raise HookYieldedTwice, "Ran a hook #{hook} twice for reporter #{reporter}" unless @executions[hook][reporter] == false
27
+
28
+ # puts "ran #{hook}, #{reporter}, #{reporter.id}"
29
+ @executions[hook][reporter] = true
30
+ end
31
+
32
+ def raise_unless_all_reporters_yielded(hook:)
33
+ didnt_yield = @executions[hook].filter { |reporter, ran| ran == false }
34
+ raise HookDidNotYield, "Reporter(s) [#{didnt_yield.keys.join(", ")}] did not yield in their #{hook} hook" if didnt_yield.any?
35
+ end
36
+ end
37
+
38
+ class Runner
39
+ PASSTHROUGH_EXCEPTIONS = [
40
+ # ours
41
+ HookDidNotYield, HookYieldedTwice, UnexpectedHookYield,
42
+ # Ruby's
43
+ NoMemoryError, SignalException, SystemExit
44
+ ]
45
+
46
+ def initialize(checkers: [], reporters: [])
47
+ # maintain order and we want to check/report in user-provided order; Set lacks .reverse
48
+ @checkers = checkers.uniq
49
+ @reporters = reporters.uniq
50
+ @yields = Yields.new
51
+ end
52
+
53
+ # compose reporter hooks so they each see the block fire once at 'yield'
54
+ def reduce(hook:, kwargs: {}, reporters: [], &blk)
55
+ reporters.reverse.reduce(blk) do |proc, reporter|
56
+ @yields.expect(hook:, reporter:)
57
+ -> {
58
+ result = nil
59
+ reporter.public_send(hook, **kwargs) {
60
+ @yields.ran(hook:, reporter:)
61
+ result = proc.call.freeze
62
+ }
63
+ result
64
+ }
65
+ end.call
66
+ end
67
+
68
+ # only for calling from inside run()
69
+ def cant_run reporters:, checker:, queries:, checks:, type:
70
+ checker_counts = CountStats.new
71
+ checker_counts.increment type
72
+ @total_counts << checker_counts
73
+
74
+ error = Error.new(checker:, query: nil, check: nil, record: nil, type:, exception: nil)
75
+ reduce(reporters:, hook: :around_checker, kwargs: {checker:, queries:, checks:}) do
76
+ reporters.each { it.halt(checker:, query: nil, check: nil, error:) }
77
+ checker_counts
78
+ end
79
+ end
80
+
81
+ # n queries * n check methods * n records = O(1) right?
82
+ def run
83
+ @total_counts = CountStats.new
84
+ # All happy families are alike; each unhappy family is unhappy in its own way.
85
+ pass = Pass.new
86
+
87
+ # for want of a monad...
88
+ reduce(reporters: @reporters, hook: :around_run, kwargs: {checkers: @checkers}) do
89
+ # for each checker...
90
+ @checkers.each do |checker|
91
+ checker_counts = CountStats.new
92
+ if !checker.class.respond_to?(:query_methods)
93
+ cant_run(reporters: @reporters, checker:, type: :no_query_methods, queries: nil, checks: nil)
94
+ next
95
+ end
96
+ if (queries = checker.class.query_methods).empty?
97
+ cant_run(reporters: @reporters, checker:, type: :no_queries, queries:, checks: nil)
98
+ next
99
+ end
100
+
101
+ if !checker.class.respond_to?(:check_methods)
102
+ cant_run(reporters: @reporters, checker:, type: :no_check_methods, queries:, checks: nil)
103
+ next
104
+ end
105
+ if (checks = checker.class.check_methods).empty?
106
+ cant_run(reporters: @reporters, checker:, type: :no_checks, queries:, checks:)
107
+ next
108
+ end
109
+
110
+ reduce(reporters: @reporters, hook: :around_checker, kwargs: {checker:, queries:, checks:}) do
111
+ # for each query_...
112
+ queries.each do |query|
113
+ checker_counts.increment :queries
114
+ # for each record...
115
+ # TODO: must handle if the query method yields (find_each) OR returns (current)
116
+ (checker.public_send(query) || []).each do |record|
117
+ # for each check_method...
118
+ checks.each do |check|
119
+ raw_result = nil
120
+ reduce(reporters: @reporters, hook: :around_check, kwargs: {checker:, query:, check:, record:}) do
121
+ raw_result = checker.public_send(check, record)
122
+ result = raw_result ? pass : Error.new(checker:, query:, check:, record:, type: :fail, exception: nil)
123
+
124
+ checker_counts.increment(result.type)
125
+ break if checker_counts.reached_blanket_failure?
126
+
127
+ result
128
+ rescue *PASSTHROUGH_EXCEPTIONS
129
+ raise
130
+ rescue => e
131
+ Error.new(checker:, query:, check:, record:, type: :exception, exception: e)
132
+ end
133
+ end
134
+ @yields.raise_unless_all_reporters_yielded(hook: :around_check)
135
+
136
+ # if the first 20 error out, halt the check method, it's probably buggy
137
+ if checker_counts.reached_blanket_failure?
138
+ checker_counts.increment :blanket
139
+
140
+ error = Error.new(checker:, query:, check: nil, record: nil, type: :blanket, exception: nil)
141
+ @reporters.each { it.halt(checker:, query:, check: nil, error:) }
142
+
143
+ break
144
+ end
145
+ end
146
+ rescue *PASSTHROUGH_EXCEPTIONS
147
+ raise
148
+ rescue => e
149
+ # puts "outer rescue: #{e.inspect}"
150
+ @reporters.each do |check_reporter|
151
+ result = Error.new(checker:, query:, check: nil, record: nil, type: :exception, exception: e)
152
+ check_reporter.around_check(checker:, query: query, check: nil, record: nil) { result }
153
+ end
154
+ end
155
+ checker_counts
156
+ end
157
+ @yields.raise_unless_all_reporters_yielded(hook: :around_checker)
158
+ @total_counts << checker_counts
159
+ end
160
+ @total_counts
161
+ end
162
+ @yields.raise_unless_all_reporters_yielded(hook: :around_run)
163
+ end
164
+ end
165
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Recheck
4
+ VERSION = '0.4.0'
5
+ end
data/lib/recheck.rb ADDED
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Recheck
4
+ # Check if an obj.is_a? Foo without having to depend on or load the foo gem.
5
+ def self.unloaded_is_a? obj, class_name
6
+ raise ArgumentError, "unloaded_is_a? takes class_name as a String" unless class_name.is_a? String
7
+
8
+ Object.const_defined?(class_name) && obj.is_a?(Object.const_get(class_name))
9
+ end
10
+ end
11
+
12
+ require_relative "../vendor/optimist"
13
+ require_relative "recheck/checkers"
14
+ require_relative "recheck/cli"
15
+ require_relative "recheck/commands"
16
+ require_relative "recheck/results"
17
+ require_relative "recheck/count_stats"
18
+ require_relative "recheck/reporters"
19
+ require_relative "recheck/runner"
20
+ require_relative "recheck/version"
@@ -0,0 +1,18 @@
1
+ # This file is automatically required before running any checks.
2
+ # Customize it to load your application environment and provide utility methods.
3
+
4
+ # For Rails applications:
5
+ require File.expand_path("../config/environment", __dir__)
6
+
7
+ # For non-Rails applications, you might want to do something like:
8
+ # $LOAD_PATH.unshift File.expand_path('../../lib', __dir__)
9
+ # require 'your_app'
10
+
11
+ # Load helpers and reporters; not checkers because all loaded checkers are run
12
+ Dir.glob([
13
+ "#{__dir__}/*_helper*.rb",
14
+ "#{__dir__}/reporter/**/*.rb"
15
+ ]).sort.each { |file| require_relative file }
16
+
17
+ # Add any other setup here.
18
+ # You could also share code by writing a YourAppChecker class (or classes) for your checkers to inherit from.
@@ -0,0 +1,31 @@
1
+ require "resolv"
2
+
3
+ class EmailRecordsChecker < Recheck::Checker::Base
4
+ def query
5
+ # domains you send email from
6
+ []
7
+ end
8
+
9
+ def check_mx_records(domain)
10
+ mx_records = Resolv::DNS.open do |dns|
11
+ dns.getresources(domain, Resolv::DNS::Resource::IN::MX)
12
+ end
13
+ !mx_records.empty?
14
+ end
15
+
16
+ def check_soa_record(domain)
17
+ soa_record = Resolv::DNS.open do |dns|
18
+ dns.getresource(domain, Resolv::DNS::Resource::IN::SOA)
19
+ end
20
+ !soa_record.nil?
21
+ rescue Resolv::ResolvError
22
+ false
23
+ end
24
+
25
+ def check_spf_record(domain)
26
+ txt_records = Resolv::DNS.open do |dns|
27
+ dns.getresources(domain, Resolv::DNS::Resource::IN::TXT)
28
+ end
29
+ txt_records.any? { |record| record.strings.first.start_with?("v=spf1") }
30
+ end
31
+ end
@@ -0,0 +1,51 @@
1
+ require "openssl"
2
+ require "socket"
3
+ require "uri"
4
+
5
+ class TlsChecker < Recheck::Checker::Base
6
+ def query
7
+ # array of domains you host web servers on
8
+ [].map do |domain|
9
+ cert = fetch_certificate(domain)
10
+ {domain: domain, cert: cert}
11
+ end
12
+ end
13
+
14
+ def check_not_expiring_soon(record)
15
+ expiration_date = record[:cert].not_after
16
+ days_until_expiration = (expiration_date - Time.now) / (24 * 60 * 60)
17
+ days_until_expiration > 30
18
+ end
19
+
20
+ def check_cert_matches_domain(record)
21
+ cert = record[:cert]
22
+ domain = record[:domain]
23
+ cert.subject.to_a.any? { |name, value| name == "CN" && (value == domain || value == "*.#{domain}") } ||
24
+ cert.extensions.any? { |ext| ext.oid == "subjectAltName" && ext.value.include?("DNS:#{domain}") }
25
+ end
26
+
27
+ def check_cert_suites_no_old(record)
28
+ ctx = OpenSSL::SSL::SSLContext.new
29
+ ctx.set_params(verify_mode: OpenSSL::SSL::VERIFY_NONE)
30
+ socket = TCPSocket.new(record[:domain], 443)
31
+ ssl = OpenSSL::SSL::SSLSocket.new(socket, ctx)
32
+ ssl.connect
33
+ ciphers = ssl.cipher
34
+ ssl.close
35
+ socket.close
36
+
37
+ !["RC4", "MD5", "SHA1"].any? { |weak| ciphers.include?(weak) }
38
+ end
39
+
40
+ private
41
+
42
+ def fetch_certificate(domain)
43
+ uri = URI::HTTPS.build(host: domain)
44
+ http = Net::HTTP.new(uri.host, uri.port)
45
+ http.use_ssl = true
46
+ http.verify_mode = OpenSSL::SSL::VERIFY_NONE
47
+ http.start do |h|
48
+ h.peer_cert
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,25 @@
1
+ require "whois"
2
+ require "whois-parser"
3
+
4
+ class WhoisChecker < Recheck::Checker::Base
5
+ def query
6
+ whois = Whois::Client.new
7
+ # array of your domains
8
+ [].map do |domain|
9
+ whois.lookup(domain).parser
10
+ end
11
+ end
12
+
13
+ def check_not_expiring_soon(parser)
14
+ expiration_date = parser.expires_on
15
+ expiration_date > (Time.now + 180 * 24 * 60 * 60) # 180 days
16
+ end
17
+
18
+ def check_registrar_lock(domain)
19
+ domain_status.any? { |status| status.downcase.include?("clienttransferprohibited") }
20
+ end
21
+
22
+ def check_nameservers(domain)
23
+ parser.nameservers.length >= 2
24
+ end
25
+ end