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.
- checksums.yaml +7 -0
- data/LICENSE +19 -0
- data/README.md +123 -0
- data/Rakefile +11 -0
- data/lib/mecha_encrypt/auditor/prober.rb +307 -0
- data/lib/mecha_encrypt/auditor/static.rb +357 -0
- data/lib/mecha_encrypt/configuration.rb +71 -0
- data/lib/mecha_encrypt/errors.rb +11 -0
- data/lib/mecha_encrypt/matrix_test.rb +119 -0
- data/lib/mecha_encrypt/railtie.rb +11 -0
- data/lib/mecha_encrypt/registry.rb +195 -0
- data/lib/mecha_encrypt/version.rb +6 -0
- data/lib/mecha_encrypt.rb +39 -0
- data/lib/tasks/mecha_encrypt.rake +180 -0
- metadata +83 -0
|
@@ -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,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
|