recheck 0.0.1 → 0.5.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,206 @@
1
+ # Recheck Checker API Documentation
2
+ #
3
+ # This file documents the complete Checker API for Recheck.
4
+ # Use this as a reference when creating your own checkers.
5
+
6
+ # All checkers must inherit from Recheck::Checker::Base to be registered
7
+ class SampleChecker < Recheck::Checker::Base
8
+ #
9
+ # CHECKER LIFECYCLE AND HOOKS
10
+ # ==========================
11
+ #
12
+ # Checkers have a simple lifecycle with four hooks:
13
+ #
14
+ # 1. initialize - Optional setup
15
+ # 2. query_* methods - Required to fetch records to check
16
+ # 3. check_* methods - Required to validate each record
17
+ # 4. Metadata methods - Optional for routing and prioritization
18
+ #
19
+
20
+ # Hook 1: initialize (optional)
21
+ # ----------------------------
22
+ # Use initialize to set up any resources needed by your checks.
23
+ # This runs once when the checker is instantiated.
24
+ def initialize
25
+ # You can:
26
+ # - Connect to external services
27
+ # - Load reference data
28
+ # - Set up caches or other shared resources
29
+ # - Configure the checker based on environment
30
+
31
+ @external_service = ExternalService.new(api_key: ENV["API_KEY"])
32
+ @reference_data = load_reference_data
33
+ @cache = {}
34
+ end
35
+
36
+ # You can use helper methods to organize your code
37
+ def load_reference_data
38
+ # Load data from a file, database, or API
39
+ # This is just a helper method, not part of the Checker API
40
+ {}
41
+ end
42
+
43
+ # Hook 2: query_* methods (one required)
44
+ # ---------------------------------
45
+ # At least one query method is required. Query methods must:
46
+ # - Start with "query" (or they won't be detected)
47
+ # - Return an Enumerable of records to check
48
+ # - Be efficient (they often run against production databases)
49
+
50
+ # Basic query method - returns all records of a type
51
+ def query
52
+ # The simplest query just returns all records
53
+ # Use find_each with ActiveRecord for batching
54
+ Model.find_each
55
+ end
56
+
57
+ # You can have multiple query methods to:
58
+ # - Check different record types
59
+ # - Optimize performance with targeted queries
60
+ # - Focus on specific subsets of data
61
+ def query_recent
62
+ # Focus on recently created/updated records
63
+ Model.where("updated_at > ?", 1.day.ago).find_each
64
+ end
65
+
66
+ def query_problematic
67
+ # Target records that might have issues
68
+ Model.where(status: "error")
69
+ .or(Model.where(processed_at: nil))
70
+ .includes(:related_records) # Eager load associations
71
+ .find_each
72
+ end
73
+
74
+ # Query methods can return any Enumerable, not just ActiveRecord
75
+ def query_external_data
76
+ # You can check data from external sources
77
+ @external_service.fetch_records.map do |record|
78
+ # Transform external data if needed
79
+ {id: record["id"], data: record["payload"]}
80
+ end
81
+ end
82
+
83
+ # Query methods can return arrays, hashes, or custom objects
84
+ def query_composite
85
+ # You can join data from multiple sources
86
+ [
87
+ {type: "config", value: AppConfig.settings},
88
+ {type: "status", value: SystemStatus.current}
89
+ ]
90
+ end
91
+
92
+ # Hook 3: check_* methods (one required)
93
+ # ---------------------------------
94
+ # Check methods must:
95
+ # - Start with "check_" (or they won't be detected)
96
+ # - Take a single record parameter
97
+ # - Return false/nil for failing records, anything else for passing
98
+
99
+ # Basic check - validates a single aspect of a record
100
+ def check_record_is_valid(record)
101
+ # The simplest check just calls ActiveRecord validations
102
+ # Returns false if invalid, true if valid
103
+ record.valid?
104
+ end
105
+
106
+ # Checks can implement complex business rules
107
+ def check_business_rule(record)
108
+ # Implement any business logic
109
+ # Return false/nil for failing records
110
+ if record.status == "completed" && record.completed_at.nil?
111
+ # This is a failing condition
112
+ return false
113
+ end
114
+
115
+ # Any non-false/nil return is considered passing
116
+ true
117
+ end
118
+
119
+ # Checks can integrate with external systems
120
+ def check_external_consistency(record)
121
+ # Check that record matches external system
122
+ external_data = @external_service.find(record.external_id)
123
+
124
+ # Return false if inconsistent
125
+ return false if external_data.nil?
126
+ return false if external_data["status"] != record.status
127
+
128
+ # Return true if consistent
129
+ true
130
+ end
131
+
132
+ # Checks can automatically fix issues (use carefully!)
133
+ def check_and_fix(record)
134
+ # Check if there's an issue
135
+ if record.calculated_total != record.stored_total
136
+ # Fix the issue
137
+ record.stored_total = record.calculated_total
138
+ record.save!
139
+
140
+ # Log the fix
141
+ LoggingService.info("Fixed total for record #{record.id}")
142
+ end
143
+
144
+ # Return true since we've fixed the issue
145
+ true
146
+ end
147
+
148
+ # Checks can handle different record types from different queries
149
+ def check_config_is_valid(record)
150
+ # Handle records from query_composite
151
+ if record[:type] == "config"
152
+ # Check config settings
153
+ return record[:value].valid?
154
+ elsif record[:type] == "status"
155
+ # Check system status
156
+ return record[:value].ok?
157
+ end
158
+
159
+ # Skip records this check doesn't understand
160
+ true
161
+ end
162
+
163
+ #
164
+ # METADATA METHODS
165
+ # ===============
166
+ #
167
+ # These optional methods provide metadata about your checker
168
+ # for use by reporters and the Recheck runner.
169
+ #
170
+
171
+ # Team responsible for this checker
172
+ # Used by reporters to route notifications
173
+ def team
174
+ :data_integrity
175
+ end
176
+
177
+ # Priority of this checker
178
+ # Used by reporters to prioritize notifications
179
+ def priority
180
+ :high # :high, :medium, :low
181
+ end
182
+
183
+ # Tags for this checker
184
+ # Used for filtering and categorization
185
+ def tags
186
+ [:critical, :customer_facing]
187
+ end
188
+
189
+ # Documentation URL
190
+ # Link to runbook or documentation
191
+ def documentation_url
192
+ "https://internal-docs.example.com/data-integrity/sample-checker"
193
+ end
194
+
195
+ # Slack channel for notifications
196
+ # Used by SlackReporter
197
+ def slack_channel
198
+ "#data-alerts"
199
+ end
200
+
201
+ # Email recipients for notifications
202
+ # Perhaps used by EmailReporter
203
+ def email_recipients
204
+ ["data-team@example.com", "oncall@example.com"]
205
+ end
206
+ end
@@ -0,0 +1,178 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Reporters are how you turn failing checks into emails, bug tracker tickets,
4
+ # or any other useful notification or report. You can notify different teams
5
+ # however they most "enjoy" hearing about bad data.
6
+
7
+ class SampleReporter < Recheck::Reporter::Base
8
+ # Optional but strongly recommended: Provide help text that appears when running `recheck
9
+ # reporters`. This should briefly explain what your reporter does and any argument it takes.
10
+ def self.help
11
+ "A template reporter showing how to implement your own custom reporter. Takes an optional argument."
12
+ end
13
+
14
+ # Optional: Initialize with the argument string from the command line.
15
+ # The arg is passed as a single string, or nil if no arg is provided
16
+ # Arguments:
17
+ # * arg: The string argument passed after the colon in the reporter specification
18
+ # For example: `--reporter SampleReporter:api_key_123` -> `api_key_123`
19
+ def initialize(arg:)
20
+ # Process your argument here if needed
21
+ @config = arg || "default_config"
22
+ end
23
+
24
+ # Important: If you define an around_ hook, it must 'yield' to run the next step.
25
+ # A hook method's return value is not used.
26
+
27
+ # around_run: Fires around the entire run.
28
+ # This is a good place to set up resources, send summary emails, etc.
29
+ # Arguments:
30
+ # * checkers: Array of checker instances that will be run
31
+ def around_run(checkers: [])
32
+ # Setup before the run
33
+ start_time = Time.now
34
+
35
+ # yield returns: a CountStats about the entire run
36
+ # CountStats tracks counts of passes, failures, and other result types
37
+ # It provides methods like #all_pass?, #any_errors?, #summary, etc.
38
+ total_counts = yield
39
+
40
+ # Teardown/reporting after the run
41
+ duration = Time.now - start_time
42
+
43
+ # Example of how you might report results
44
+ if total_counts.any_errors?
45
+ puts "SampleReporter: Found errors in #{duration.round(2)}s: #{total_counts.summary}"
46
+ # In a real reporter, you might:
47
+ # - Send an email
48
+ # - Post to Slack
49
+ # - Create a ticket in your issue tracker
50
+ # - Log to a monitoring service
51
+ end
52
+
53
+ # Return the counts (optional)
54
+ total_counts
55
+ end
56
+
57
+ # around_checker: Fires around each checker.
58
+ # Arguments:
59
+ # * checker: The checker instance being run
60
+ # * queries: Array of query method names defined on the checker
61
+ # * checks: Array of check method names defined on the checker
62
+ def around_checker(checker:, queries: [], checks: [])
63
+ # Before running this checker
64
+ checker_name = checker.class.name
65
+
66
+ # yields returns a CountStats for the checker
67
+ counts = yield
68
+
69
+ # After running this checker
70
+ if counts.any_errors?
71
+ puts "SampleReporter: Checker #{checker_name} had issues: #{counts.summary}"
72
+ # In a real reporter, you might group errors by checker
73
+ end
74
+ end
75
+
76
+ # around_query: Fires around each query
77
+ # This is useful for tracking which queries are slow or problematic
78
+ # Arguments:
79
+ # * checker: The checker instance being run
80
+ # * query: The name of the query method being executed
81
+ # * checks: Array of check method names that will be run against query results
82
+ def around_query(checker:, query:, checks: [])
83
+ # Before running this query
84
+ query_start = Time.now
85
+
86
+ # yield does not return anything for this hook
87
+ yield
88
+
89
+ # After running this query
90
+ query_duration = Time.now - query_start
91
+ if query_duration > 5 # seconds
92
+ puts "SampleReporter: Slow query #{checker.class.name}##{query} took #{query_duration.round(2)}s"
93
+ end
94
+ end
95
+
96
+ # The around_check and halt hooks both receive one of two result objects.
97
+ #
98
+ # Recheck::Pass: A successful check.
99
+ # Attributes:
100
+ # #type: always :pass
101
+ #
102
+ # Recheck::Error: A failed check.
103
+ # Attributes:
104
+ # #type: One of the following symbols
105
+ # fail: The check returned a falsey value
106
+ # exception: The check raised an exception
107
+ # blanket: The first 20 checks all failed or raised; the runner skips
108
+ # no_query_methods: The checker does not define a query_methods
109
+ # no_queries: The checker defines query_methods, but did not return any
110
+ # no_check_methods: The checker does not define a check_methods
111
+ # no_checks: The checker defines check_methods, but did not return any
112
+ # #checker: Checker instance
113
+ # #query: Query method name
114
+ # #check: Check method name
115
+ # #record: The record being checked
116
+ # #exception: rescued Exception
117
+ #
118
+
119
+ # around_check: Fires for each call to a check_ method on each record
120
+ # This is where you can collect detailed information about failures.
121
+ # Arguments:
122
+ # * checker: The checker instance being run
123
+ # * query: The name of the query method that produced this record
124
+ # * check: The name of the check method being executed
125
+ # * record: The individual record being checked
126
+ def around_check(checker:, query:, check:, record:)
127
+ # Returns a result object, see comment above
128
+ result = yield
129
+
130
+ # Process the result
131
+ if result.is_a?(Recheck::Error)
132
+ case result.type
133
+ when :fail
134
+ record_id = fetch_record_id(result.record)
135
+ puts "SampleReporter: #{checker.class.name}##{check} failed for record: #{record_id}"
136
+ when :exception
137
+ puts "SampleReporter: #{checker.class.name}##{check} raised exception: #{result.exception.message}"
138
+ end
139
+
140
+ # In a real reporter, you might:
141
+ # - Collect failures to report in a batch
142
+ # - Send immediate alerts for critical failures
143
+ # - Log to a monitoring system
144
+ end
145
+ end
146
+
147
+ # halt: Called when a checker is halted due to an error.
148
+ # This is useful for reporting fatal errors that prevent checks from running
149
+ # Arguments:
150
+ # * checker: The checker instance that was halted
151
+ # * query: The name of the query method that was running (if any)
152
+ # * check: The name of the check method that was running (if any)
153
+ # * error: The error result, see comment above around_check
154
+ def halt(checker:, query:, error:, check: nil)
155
+ puts "SampleReporter: Halted #{checker.class.name}##{query} due to #{error.type}"
156
+
157
+ # In a real reporter, you might:
158
+ # - Send an urgent alert
159
+ # - Create a high-priority ticket
160
+ # - Log a critical error
161
+ end
162
+
163
+ # You can add any helper methods, include modules, etc.
164
+
165
+ # Example of a helper method to format data for reporting
166
+ def format_error_message(error)
167
+ case error.type
168
+ when :fail
169
+ "Record is invalid"
170
+ when :exception
171
+ "Exception occurred: #{error.exception.message}"
172
+ when :blanket
173
+ "Blanket failure - first 20 checks all failed"
174
+ else
175
+ "Other error: #{error.type}"
176
+ end
177
+ end
178
+ end
@@ -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