mecha_encrypt 0.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.
@@ -0,0 +1,357 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ module MechaEncrypt
6
+ module Auditor
7
+ class Static
8
+ ColumnAudit = Struct.new(
9
+ :table,
10
+ :column,
11
+ :sql_type,
12
+ :model,
13
+ :status,
14
+ :encrypted,
15
+ :deterministic,
16
+ :comment,
17
+ :violation_message,
18
+ keyword_init: true
19
+ )
20
+
21
+ TableAudit = Struct.new(
22
+ :table,
23
+ :model,
24
+ :ignored,
25
+ :ignore_comment,
26
+ :registered,
27
+ :columns,
28
+ keyword_init: true
29
+ )
30
+
31
+ Result = Struct.new(
32
+ :table_audits,
33
+ :unmapped_tables,
34
+ :unmapped_columns,
35
+ :dead_tables,
36
+ :dead_columns,
37
+ :dead_ignored_patterns,
38
+ :unencrypted_in_model,
39
+ :undeclared_encrypted_in_model,
40
+ :mode_mismatches,
41
+ :invalid_comments,
42
+ keyword_init: true
43
+ ) do
44
+ def success?
45
+ unmapped_tables.empty? &&
46
+ unmapped_columns.empty? &&
47
+ dead_tables.empty? &&
48
+ dead_columns.empty? &&
49
+ dead_ignored_patterns.empty? &&
50
+ unencrypted_in_model.empty? &&
51
+ undeclared_encrypted_in_model.empty? &&
52
+ mode_mismatches.empty? &&
53
+ invalid_comments.empty?
54
+ end
55
+
56
+ def audited_tables
57
+ table_audits.reject(&:ignored)
58
+ end
59
+
60
+ def ignored_tables
61
+ table_audits.select(&:ignored)
62
+ end
63
+
64
+ def all_column_audits
65
+ audited_tables.flat_map(&:columns)
66
+ end
67
+ end
68
+
69
+ def initialize(registry = MechaEncrypt.registry, configuration = MechaEncrypt.configuration, connection: nil)
70
+ @registry = registry
71
+ @configuration = configuration
72
+ @connection = connection
73
+ end
74
+
75
+ def audit
76
+ ensure_models_loaded!
77
+
78
+ conn = db_connection
79
+ internal = Set.new(Array(@configuration.internal_tables).map(&:to_s))
80
+ db_tables = conn.tables.reject { |t| internal.include?(t) }.sort
81
+ models_by_table = discover_models_by_table
82
+
83
+ table_audits = []
84
+ unmapped_tables = []
85
+ unmapped_columns = []
86
+ dead_tables = []
87
+ dead_columns = []
88
+ dead_ignored_patterns = []
89
+ unencrypted_in_model = []
90
+ undeclared_encrypted_in_model = []
91
+ mode_mismatches = []
92
+ invalid_comments = []
93
+
94
+ matched_ignore_patterns = Set.new
95
+
96
+ # Validate comments on ignored_tables rules
97
+ @registry.ignored_tables.each do |ignore_entry|
98
+ comment_err = validate_comment(ignore_entry[:comment])
99
+ if comment_err
100
+ invalid_comments << "ignored_tables[#{ignore_entry[:pattern]}]: #{comment_err}"
101
+ end
102
+ end
103
+
104
+ db_tables.each do |table_name|
105
+ ignore_entry = @registry.ignored_table_entry_for(table_name)
106
+ if ignore_entry
107
+ matched_ignore_patterns << ignore_entry[:pattern]
108
+
109
+ if @registry.table_registered?(table_name)
110
+ dead_tables << "#{table_name} (table is matched by ignored_tables pattern '#{ignore_entry[:pattern]}' and must not also be listed in 'tables')"
111
+ end
112
+
113
+ table_audits << TableAudit.new(
114
+ table: table_name,
115
+ model: models_by_table[table_name]&.name,
116
+ ignored: true,
117
+ ignore_comment: ignore_entry[:comment],
118
+ registered: false,
119
+ columns: []
120
+ )
121
+ next
122
+ end
123
+
124
+ model_klass = models_by_table[table_name]
125
+ table_registered = @registry.table_registered?(table_name)
126
+ unmapped_tables << table_name unless table_registered
127
+
128
+ db_cols = conn.columns(table_name).sort_by(&:name)
129
+ db_col_names = Set.new(db_cols.map(&:name))
130
+
131
+ # Check for dead columns in YAML for this table
132
+ if table_registered
133
+ @registry.table_columns(table_name).each_key do |reg_col|
134
+ unless db_col_names.include?(reg_col)
135
+ dead_columns << "#{table_name}.#{reg_col}"
136
+ end
137
+ end
138
+ end
139
+
140
+ col_audits = db_cols.map do |col|
141
+ col_name = col.name
142
+ actual_enc, actual_det = inspect_model_encryption(model_klass, col_name)
143
+ entry = @registry.column_entry(table_name, col_name)
144
+
145
+ if entry.nil?
146
+ unmapped_columns << "#{table_name}.#{col_name}"
147
+ ColumnAudit.new(
148
+ table: table_name,
149
+ column: col_name,
150
+ sql_type: col.sql_type,
151
+ model: model_klass&.name,
152
+ status: :unmapped,
153
+ encrypted: actual_enc,
154
+ deterministic: actual_det,
155
+ comment: nil,
156
+ violation_message: "MISSING FROM config/mecha_encrypt.yml"
157
+ )
158
+ elsif entry[:encrypted] && !actual_enc
159
+ msg = if model_klass
160
+ "#{table_name}.#{col_name} is registered as encrypted in YAML, but #{model_klass.name} does not declare `encrypts :#{col_name}`"
161
+ else
162
+ "#{table_name}.#{col_name} is registered as encrypted in YAML, but no ActiveRecord model declares `encrypts :#{col_name}`"
163
+ end
164
+ unencrypted_in_model << msg
165
+ ColumnAudit.new(
166
+ table: table_name,
167
+ column: col_name,
168
+ sql_type: col.sql_type,
169
+ model: model_klass&.name,
170
+ status: :violation,
171
+ encrypted: false,
172
+ deterministic: false,
173
+ comment: entry[:comment],
174
+ violation_message: msg
175
+ )
176
+ elsif !entry[:encrypted] && actual_enc
177
+ msg = "#{table_name}.#{col_name} is encrypted on #{model_klass.name} (`encrypts :#{col_name}`), but registered as `encrypted: false` in YAML"
178
+ undeclared_encrypted_in_model << msg
179
+ ColumnAudit.new(
180
+ table: table_name,
181
+ column: col_name,
182
+ sql_type: col.sql_type,
183
+ model: model_klass&.name,
184
+ status: :violation,
185
+ encrypted: true,
186
+ deterministic: actual_det,
187
+ comment: entry[:comment],
188
+ violation_message: msg
189
+ )
190
+ elsif entry[:encrypted] && actual_enc && entry[:deterministic] != actual_det
191
+ msg = "#{table_name}.#{col_name} deterministic mode mismatch: YAML declares deterministic=#{entry[:deterministic]}, but #{model_klass.name} has deterministic=#{actual_det}"
192
+ mode_mismatches << msg
193
+ ColumnAudit.new(
194
+ table: table_name,
195
+ column: col_name,
196
+ sql_type: col.sql_type,
197
+ model: model_klass&.name,
198
+ status: :violation,
199
+ encrypted: true,
200
+ deterministic: actual_det,
201
+ comment: entry[:comment],
202
+ violation_message: msg
203
+ )
204
+ elsif !entry[:encrypted]
205
+ comment_err = validate_comment(entry[:comment])
206
+ if comment_err
207
+ msg = "#{table_name}.#{col_name}: #{comment_err}"
208
+ invalid_comments << msg
209
+ ColumnAudit.new(
210
+ table: table_name,
211
+ column: col_name,
212
+ sql_type: col.sql_type,
213
+ model: model_klass&.name,
214
+ status: :violation,
215
+ encrypted: false,
216
+ deterministic: false,
217
+ comment: entry[:comment],
218
+ violation_message: msg
219
+ )
220
+ else
221
+ ColumnAudit.new(
222
+ table: table_name,
223
+ column: col_name,
224
+ sql_type: col.sql_type,
225
+ model: model_klass&.name,
226
+ status: :unencrypted,
227
+ encrypted: false,
228
+ deterministic: false,
229
+ comment: entry[:comment],
230
+ violation_message: nil
231
+ )
232
+ end
233
+ else
234
+ ColumnAudit.new(
235
+ table: table_name,
236
+ column: col_name,
237
+ sql_type: col.sql_type,
238
+ model: model_klass&.name,
239
+ status: actual_det ? :deterministic : :encrypted,
240
+ encrypted: true,
241
+ deterministic: actual_det,
242
+ comment: entry[:comment],
243
+ violation_message: nil
244
+ )
245
+ end
246
+ end
247
+
248
+ table_audits << TableAudit.new(
249
+ table: table_name,
250
+ model: model_klass&.name,
251
+ ignored: false,
252
+ ignore_comment: nil,
253
+ registered: table_registered,
254
+ columns: col_audits
255
+ )
256
+ end
257
+
258
+ # Check for dead tables in YAML that do not exist in the DB
259
+ db_table_set = Set.new(db_tables)
260
+ @registry.tables.each_key do |reg_table|
261
+ unless db_table_set.include?(reg_table)
262
+ dead_tables << reg_table
263
+ end
264
+ end
265
+
266
+ # Check for dead ignored_tables patterns that matched no DB tables
267
+ @registry.ignored_tables.each do |ignore_entry|
268
+ unless matched_ignore_patterns.include?(ignore_entry[:pattern])
269
+ dead_ignored_patterns << ignore_entry[:pattern]
270
+ end
271
+ end
272
+
273
+ Result.new(
274
+ table_audits: table_audits,
275
+ unmapped_tables: unmapped_tables.sort,
276
+ unmapped_columns: unmapped_columns.sort,
277
+ dead_tables: dead_tables.sort,
278
+ dead_columns: dead_columns.sort,
279
+ dead_ignored_patterns: dead_ignored_patterns.sort,
280
+ unencrypted_in_model: unencrypted_in_model,
281
+ undeclared_encrypted_in_model: undeclared_encrypted_in_model,
282
+ mode_mismatches: mode_mismatches,
283
+ invalid_comments: invalid_comments
284
+ )
285
+ end
286
+
287
+ def validate_comment(comment)
288
+ text = comment.to_s.strip
289
+ if text.empty?
290
+ return "Missing required comment explaining why this field/table is not encrypted"
291
+ end
292
+
293
+ min_len = @configuration.min_comment_length || 10
294
+ if text.length < min_len
295
+ return "Comment is too short (#{text.length} chars; minimum is #{min_len} chars): #{text.inspect}"
296
+ end
297
+
298
+ normalized = text.downcase.gsub(/[^a-z0-9\s\/]/, "").squeeze(" ").strip
299
+ forbidden = Array(@configuration.forbidden_comments).map { |c| c.to_s.downcase.strip }
300
+ if forbidden.include?(normalized) || forbidden.include?(text.downcase)
301
+ return "Comment is a placeholder ('#{text}'); please provide a meaningful explanation"
302
+ end
303
+
304
+ # Reject strings made of a single repeated character or no alphabetic words
305
+ words = text.scan(/[a-zA-Z]{2,}/)
306
+ if words.size < 2 || text.gsub(/\s+/, "").chars.uniq.size < 4
307
+ return "Comment must contain a meaningful multi-word explanation (got #{text.inspect})"
308
+ end
309
+
310
+ nil
311
+ end
312
+
313
+ private
314
+
315
+ def db_connection
316
+ @connection || ActiveRecord::Base.connection
317
+ end
318
+
319
+ def ensure_models_loaded!
320
+ if defined?(Rails) && Rails.respond_to?(:application) && Rails.application
321
+ Rails.application.eager_load!
322
+ end
323
+ end
324
+
325
+ def discover_models_by_table
326
+ base_class_name = @configuration.base_model_class.to_s
327
+ base_class = base_class_name.safe_constantize
328
+ return {} unless base_class
329
+
330
+ models = {}
331
+ base_class.descendants.reject(&:abstract_class?).each do |klass|
332
+ next unless klass.name.present? && !klass.name.include?("#<")
333
+ next unless klass.table_name.present?
334
+
335
+ # Prefer primary application model if multiple classes share a table
336
+ models[klass.table_name] ||= klass
337
+ end
338
+ models
339
+ end
340
+
341
+ def inspect_model_encryption(model_klass, column_name)
342
+ return [false, false] unless model_klass && model_klass.respond_to?(:encrypted_attributes)
343
+
344
+ enc_attrs = model_klass.encrypted_attributes
345
+ return [false, false] if enc_attrs.blank?
346
+
347
+ is_encrypted = enc_attrs.map(&:to_s).include?(column_name.to_s)
348
+ return [false, false] unless is_encrypted
349
+
350
+ attr_type = model_klass.type_for_attribute(column_name.to_s)
351
+ is_deterministic = attr_type.respond_to?(:deterministic?) ? !!attr_type.deterministic? : false
352
+
353
+ [true, is_deterministic]
354
+ end
355
+ end
356
+ end
357
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MechaEncrypt
4
+ class Configuration
5
+ DEFAULT_INTERNAL_TABLES = %w[
6
+ schema_migrations
7
+ ar_internal_metadata
8
+ ].freeze
9
+
10
+ DEFAULT_FORBIDDEN_COMMENTS = %w[
11
+ todo
12
+ tbd
13
+ n/a
14
+ na
15
+ none
16
+ nil
17
+ null
18
+ unencrypted
19
+ not_encrypted
20
+ not\ encrypted
21
+ fixme
22
+ reason
23
+ comment
24
+ test
25
+ ok
26
+ yes
27
+ no
28
+ later
29
+ skip
30
+ ].freeze
31
+
32
+ attr_accessor :config_path,
33
+ :base_model_class,
34
+ :internal_tables,
35
+ :min_comment_length,
36
+ :forbidden_comments,
37
+ :debug_colorization,
38
+ :test_setup,
39
+ :test_teardown,
40
+ :test_records
41
+
42
+ def initialize
43
+ @config_path = if defined?(Rails) && Rails.respond_to?(:root) && Rails.root
44
+ Rails.root.join("config/mecha_encrypt.yml")
45
+ else
46
+ "config/mecha_encrypt.yml"
47
+ end
48
+ @base_model_class = "ApplicationRecord"
49
+ @internal_tables = DEFAULT_INTERNAL_TABLES.dup
50
+ @min_comment_length = 10
51
+ @forbidden_comments = DEFAULT_FORBIDDEN_COMMENTS.dup
52
+ @debug_colorization = {
53
+ encrypted: :green,
54
+ deterministic: :light_cyan,
55
+ unencrypted: :light_purple,
56
+ ignored: :yellow,
57
+ violation: :light_red,
58
+ unmapped: :dark_grey
59
+ }
60
+ @test_setup = nil
61
+ @test_teardown = nil
62
+ @test_records = nil
63
+ end
64
+
65
+ def configure_test_matrix(records: nil, setup: nil, teardown: nil)
66
+ @test_records = records if records
67
+ @test_setup = setup if setup
68
+ @test_teardown = teardown if teardown
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MechaEncrypt
4
+ module Errors
5
+ class Error < StandardError; end
6
+ class ConfigurationError < Error; end
7
+ end
8
+
9
+ Error = Errors::Error
10
+ ConfigurationError = Errors::ConfigurationError
11
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ unless defined?(ActionDispatch::IntegrationTest)
4
+ app_test_helper = File.join(Dir.pwd, "test/test_helper.rb")
5
+ require app_test_helper if File.exist?(app_test_helper)
6
+ end
7
+ require "mecha_encrypt"
8
+
9
+ module MechaEncrypt
10
+ class MatrixTest < ActionDispatch::IntegrationTest
11
+ setup do
12
+ if MechaEncrypt.configuration.test_setup
13
+ instance_exec(&MechaEncrypt.configuration.test_setup)
14
+ end
15
+ end
16
+
17
+ teardown do
18
+ if MechaEncrypt.configuration.test_teardown
19
+ instance_exec(&MechaEncrypt.configuration.test_teardown)
20
+ end
21
+ end
22
+
23
+ test "static database and model encryption audit passes with zero violations" do
24
+ result = MechaEncrypt::Auditor::Static.new.audit
25
+
26
+ assert_empty result.unmapped_tables, "Unmapped tables: #{result.unmapped_tables.inspect}"
27
+ assert_empty result.unmapped_columns, "Unmapped columns: #{result.unmapped_columns.inspect}"
28
+ assert_empty result.unencrypted_in_model, "Columns registered as encrypted but missing from model: #{result.unencrypted_in_model.inspect}"
29
+ assert_empty result.undeclared_encrypted_in_model, "Columns encrypted in model but marked unencrypted in YAML: #{result.undeclared_encrypted_in_model.inspect}"
30
+ assert_empty result.mode_mismatches, "Encryption determinism mismatches: #{result.mode_mismatches.inspect}"
31
+ assert_empty result.invalid_comments, "Invalid or missing comments: #{result.invalid_comments.inspect}"
32
+ assert_empty result.dead_tables, "Dead tables in YAML: #{result.dead_tables.inspect}"
33
+ assert_empty result.dead_columns, "Dead columns in YAML: #{result.dead_columns.inspect}"
34
+ assert_empty result.dead_ignored_patterns, "Dead ignored_tables patterns: #{result.dead_ignored_patterns.inspect}"
35
+ assert result.success?
36
+ end
37
+
38
+ test "behavioral prober verifies database index safety for all encrypted columns" do
39
+ prober = MechaEncrypt::Auditor::Prober.new
40
+ idx_result = prober.probe_index_safety
41
+
42
+ assert idx_result.success?, "MechaEncrypt index safety check failed:\n" +
43
+ idx_result.index_failures.map { |f| " - [#{f.table}.#{f.column}] #{f.message}" }.join("\n")
44
+ end
45
+
46
+ test "behavioral prober catches non-deterministic encrypted column covered by a database index" do
47
+ raw = YAML.load_file(MechaEncrypt.configuration.config_path)
48
+ indexed_table, indexed_col = find_first_indexed_table_and_column
49
+ return unless indexed_table && indexed_col
50
+
51
+ raw["tables"][indexed_table][indexed_col] = {
52
+ "encrypted" => true,
53
+ "deterministic" => false
54
+ }
55
+
56
+ registry = MechaEncrypt::Registry.new(raw)
57
+ prober = MechaEncrypt::Auditor::Prober.new(registry: registry)
58
+ idx_result = prober.probe_index_safety
59
+
60
+ refute idx_result.success?
61
+ assert idx_result.index_failures.any? { |f| f.table == indexed_table && f.column == indexed_col }
62
+ end
63
+
64
+ def self.define_encrypt_matrix_tests!
65
+ return if @encrypt_matrix_tests_defined
66
+ @encrypt_matrix_tests_defined = true
67
+
68
+ # 1. One test per encrypted column for type serialization, envelope format, and determinism
69
+ MechaEncrypt.registry.encrypted_columns.each do |entry|
70
+ table_name = entry[:table]
71
+ col_name = entry[:column]
72
+
73
+ test "behavioral ciphertext serialization and determinism for #{table_name}.#{col_name}" do
74
+ prober = MechaEncrypt::Auditor::Prober.new
75
+ result = prober.probe_single_column_type(entry)
76
+
77
+ assert result.success?, "MechaEncrypt column type probe failed for #{table_name}.#{col_name}:\n" +
78
+ result.failures.map { |f| " - [#{f.table}.#{f.column}] #{f.message}" }.join("\n")
79
+ end
80
+ end
81
+
82
+ # 2. One test per table with encrypted columns for raw SQL persisted record verification
83
+ encrypted_by_table = MechaEncrypt.registry.encrypted_columns.group_by { |e| e[:table] }
84
+ encrypted_by_table.each_key do |table_name|
85
+ test "behavioral raw SQL ciphertext persistence for table #{table_name}" do
86
+ records_proc = MechaEncrypt.configuration.test_records
87
+ assert_not_nil records_proc, "MechaEncrypt.configuration.test_records must be configured for behavioral matrix testing"
88
+
89
+ records_by_table = instance_exec(&records_proc)
90
+ prober = MechaEncrypt::Auditor::Prober.new
91
+ result = prober.probe_single_table_persisted_record(table_name, records_by_table)
92
+
93
+ assert result.success?, "MechaEncrypt raw SQL persistence probe failed for #{table_name}:\n" +
94
+ result.failures.map { |f| " - [#{f.table}.#{f.column}] #{f.message}" }.join("\n")
95
+ end
96
+ end
97
+ end
98
+
99
+ define_encrypt_matrix_tests!
100
+
101
+ private
102
+
103
+ def find_first_indexed_table_and_column
104
+ conn = ActiveRecord::Base.connection
105
+ MechaEncrypt.registry.tables.each_key do |table_name|
106
+ next unless conn.table_exists?(table_name)
107
+
108
+ indexes = conn.indexes(table_name)
109
+ indexes.each do |idx|
110
+ col = idx.columns.is_a?(Array) ? idx.columns.first : idx.columns.to_s
111
+ if col.present? && MechaEncrypt.registry.column_registered?(table_name, col)
112
+ return [table_name, col.to_s]
113
+ end
114
+ end
115
+ end
116
+ nil
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/railtie"
4
+
5
+ module MechaEncrypt
6
+ class Railtie < Rails::Railtie
7
+ rake_tasks do
8
+ load File.expand_path("../tasks/mecha_encrypt.rake", __dir__)
9
+ end
10
+ end
11
+ end