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,195 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module MechaEncrypt
6
+ class Registry
7
+ attr_reader :tables, :ignored_tables, :colorization
8
+
9
+ def initialize(config_path_or_hash = nil)
10
+ @tables = {}
11
+ @ignored_tables = []
12
+ @colorization = {}
13
+
14
+ if config_path_or_hash.is_a?(Hash)
15
+ load_from_hash(config_path_or_hash)
16
+ elsif config_path_or_hash
17
+ load_from_file(config_path_or_hash)
18
+ end
19
+ end
20
+
21
+ def load_from_file(path)
22
+ raise ConfigurationError, "Encryption configuration file missing: #{path}" unless File.exist?(path)
23
+
24
+ data = begin
25
+ YAML.load_file(path)
26
+ rescue => e
27
+ raise ConfigurationError, "Invalid YAML syntax in #{path}: #{e.message}"
28
+ end
29
+
30
+ load_from_hash(data)
31
+ end
32
+
33
+ def load_from_hash(data)
34
+ unless data.is_a?(Hash) && data.key?("tables") && (data["tables"].is_a?(Hash) || data["tables"].is_a?(Array))
35
+ raise ConfigurationError, "Encryption config must have a top-level 'tables' mapping or list"
36
+ end
37
+
38
+ @ignored_tables = parse_ignored_tables(data["ignored_tables"] || [])
39
+ @tables = parse_tables(data["tables"])
40
+
41
+ if data["debug_colorization"].is_a?(Hash)
42
+ @colorization = data["debug_colorization"].transform_keys(&:to_sym).transform_values do |v|
43
+ v.to_s.sub(/^:/, "").to_sym
44
+ end
45
+ end
46
+
47
+ @ignored_tables.freeze
48
+ @tables.freeze
49
+ self
50
+ end
51
+
52
+ def table_registered?(table_name)
53
+ @tables.key?(table_name.to_s)
54
+ end
55
+
56
+ def table_columns(table_name)
57
+ @tables[table_name.to_s] || {}
58
+ end
59
+
60
+ def column_entry(table_name, column_name)
61
+ table_columns(table_name)[column_name.to_s]
62
+ end
63
+
64
+ def column_registered?(table_name, column_name)
65
+ !column_entry(table_name, column_name).nil?
66
+ end
67
+
68
+ def ignored_table_entry_for(table_name)
69
+ t_name = table_name.to_s
70
+ @ignored_tables.find do |entry|
71
+ File.fnmatch?(entry[:pattern], t_name)
72
+ end
73
+ end
74
+
75
+ def table_ignored?(table_name)
76
+ !ignored_table_entry_for(table_name).nil?
77
+ end
78
+
79
+ def encrypted_columns
80
+ result = []
81
+ @tables.each_value do |cols|
82
+ cols.each_value do |col_entry|
83
+ result << col_entry if col_entry[:encrypted]
84
+ end
85
+ end
86
+ result
87
+ end
88
+
89
+ private
90
+
91
+ def parse_ignored_tables(raw_list)
92
+ unless raw_list.is_a?(Array)
93
+ raise ConfigurationError, "'ignored_tables' must be an Array"
94
+ end
95
+
96
+ seen_patterns = {}
97
+ raw_list.each_with_index.map do |item, idx|
98
+ unless item.is_a?(Hash)
99
+ raise ConfigurationError, "ignored_tables entry at index #{idx} must be a Hash"
100
+ end
101
+
102
+ pattern = (item["pattern"] || item["table"]).to_s.strip
103
+ if pattern.empty?
104
+ raise ConfigurationError, "ignored_tables entry at index #{idx} must specify a non-empty 'pattern' or 'table'"
105
+ end
106
+
107
+ if seen_patterns.key?(pattern)
108
+ raise ConfigurationError, "Duplicate ignored_tables pattern '#{pattern}'"
109
+ end
110
+ seen_patterns[pattern] = true
111
+
112
+ {
113
+ pattern: pattern,
114
+ comment: item["comment"]&.to_s&.strip
115
+ }.freeze
116
+ end
117
+ end
118
+
119
+ def parse_tables(raw_tables)
120
+ parsed = {}
121
+
122
+ if raw_tables.is_a?(Hash)
123
+ raw_tables.each do |table_name, raw_columns|
124
+ t_name = table_name.to_s.strip
125
+ if t_name.empty?
126
+ raise ConfigurationError, "Table name in 'tables' cannot be empty"
127
+ end
128
+ parsed[t_name] = parse_columns_for_table(t_name, raw_columns)
129
+ end
130
+ elsif raw_tables.is_a?(Array)
131
+ raw_tables.each_with_index do |entry, idx|
132
+ unless entry.is_a?(Hash) && entry["table"].is_a?(String) && !entry["table"].strip.empty?
133
+ raise ConfigurationError, "Table entry at index #{idx} must be a Hash with a non-empty 'table' name"
134
+ end
135
+ t_name = entry["table"].strip
136
+ if parsed.key?(t_name)
137
+ raise ConfigurationError, "Duplicate table entry '#{t_name}' in encryption configuration"
138
+ end
139
+ parsed[t_name] = parse_columns_for_table(t_name, entry["columns"])
140
+ end
141
+ end
142
+
143
+ parsed
144
+ end
145
+
146
+ def parse_columns_for_table(table_name, raw_columns)
147
+ unless raw_columns.is_a?(Hash)
148
+ raise ConfigurationError, "Columns for table '#{table_name}' must be a Hash mapping column names to definitions"
149
+ end
150
+
151
+ cols = {}
152
+ raw_columns.each do |col_name, spec|
153
+ c_name = col_name.to_s.strip
154
+ if c_name.empty?
155
+ raise ConfigurationError, "Column name in table '#{table_name}' cannot be empty"
156
+ end
157
+
158
+ unless spec.is_a?(Hash)
159
+ raise ConfigurationError, "Definition for '#{table_name}.#{c_name}' must be a Hash"
160
+ end
161
+
162
+ unless [true, false].include?(spec["encrypted"])
163
+ raise ConfigurationError,
164
+ "Definition for '#{table_name}.#{c_name}' must explicitly specify boolean 'encrypted: true' or 'encrypted: false'"
165
+ end
166
+
167
+ encrypted = spec["encrypted"]
168
+ deterministic = if spec.key?("deterministic")
169
+ unless [true, false].include?(spec["deterministic"])
170
+ raise ConfigurationError,
171
+ "Definition for '#{table_name}.#{c_name}' has invalid 'deterministic' value (must be boolean true or false)"
172
+ end
173
+ spec["deterministic"]
174
+ else
175
+ false
176
+ end
177
+
178
+ if !encrypted && deterministic
179
+ raise ConfigurationError,
180
+ "Definition for '#{table_name}.#{c_name}' cannot specify 'deterministic: true' when 'encrypted: false'"
181
+ end
182
+
183
+ cols[c_name] = {
184
+ table: table_name,
185
+ column: c_name,
186
+ encrypted: encrypted,
187
+ deterministic: deterministic,
188
+ comment: spec["comment"]&.to_s&.strip
189
+ }.freeze
190
+ end
191
+
192
+ cols.freeze
193
+ end
194
+ end
195
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MechaEncrypt
4
+ VERSION = "0.1.0"
5
+ Version = VERSION
6
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "mecha_encrypt/version"
4
+ require_relative "mecha_encrypt/errors"
5
+ require_relative "mecha_encrypt/configuration"
6
+ require_relative "mecha_encrypt/registry"
7
+ require_relative "mecha_encrypt/auditor/static"
8
+ require_relative "mecha_encrypt/auditor/prober"
9
+ require_relative "mecha_encrypt/railtie" if defined?(Rails::Railtie)
10
+
11
+ module MechaEncrypt
12
+ class << self
13
+ def configuration
14
+ @configuration ||= Configuration.new
15
+ end
16
+
17
+ def configure
18
+ yield(configuration) if block_given?
19
+ reload!
20
+ end
21
+
22
+ def reset_configuration!
23
+ @configuration = Configuration.new
24
+ @registry = nil
25
+ end
26
+
27
+ def registry
28
+ @registry ||= Registry.new(configuration.config_path)
29
+ end
30
+
31
+ def reload!
32
+ @registry = Registry.new(configuration.config_path)
33
+ if @registry.colorization.any?
34
+ @configuration.debug_colorization = @configuration.debug_colorization.merge(@registry.colorization)
35
+ end
36
+ @registry
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,180 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tiny_color"
4
+ require_relative "../mecha_encrypt"
5
+
6
+ class String
7
+ def dark_grey
8
+ respond_to?(:grey) ? grey : self
9
+ end unless method_defined?(:dark_grey)
10
+
11
+ def dark_gray
12
+ respond_to?(:gray) ? gray : self
13
+ end unless method_defined?(:dark_gray)
14
+ end
15
+
16
+ desc "Print all database tables and columns in alphabetical order with their MechaEncrypt status"
17
+ task mecha_encrypt: :environment do
18
+ auditor = MechaEncrypt::Auditor::Static.new
19
+ result = auditor.audit
20
+ colors = MechaEncrypt.configuration.debug_colorization
21
+
22
+ colorize = ->(text, key) {
23
+ color = colors[key]
24
+ if color && text.respond_to?(color)
25
+ text.send(color)
26
+ else
27
+ text
28
+ end
29
+ }
30
+
31
+ all_cols = result.all_column_audits
32
+ max_col_len = [all_cols.map { |c| c.column.to_s.length }.max || 6, 6].max
33
+ max_type_len = [all_cols.map { |c| c.sql_type.to_s.length }.max || 8, 8].max
34
+ max_status_len = 25
35
+
36
+ puts "=== MechaEncrypt Database Column Encryption Registry ==="
37
+
38
+ result.table_audits.each do |table_audit|
39
+ model_label = table_audit.model ? " (#{table_audit.model})" : ""
40
+ puts "\n#{table_audit.table}#{model_label}"
41
+
42
+ if table_audit.ignored
43
+ padded_status = sprintf("%-#{max_status_len}s", "IGNORED TABLE")
44
+ puts sprintf(
45
+ " %-#{max_col_len}s %-#{max_type_len}s %s %s",
46
+ "*",
47
+ "-",
48
+ colorize.call(padded_status, :ignored),
49
+ table_audit.ignore_comment
50
+ )
51
+ next
52
+ end
53
+
54
+ puts sprintf(
55
+ " %-#{max_col_len}s %-#{max_type_len}s %-#{max_status_len}s %s",
56
+ "Column",
57
+ "SQL Type",
58
+ "Encryption Status",
59
+ "Comment / Details"
60
+ )
61
+
62
+ table_audit.columns.each do |col|
63
+ padded_col = sprintf("%-#{max_col_len}s", col.column)
64
+ padded_type = sprintf("%-#{max_type_len}s", col.sql_type)
65
+
66
+ status_label = case col.status
67
+ when :encrypted
68
+ "ENCRYPTED"
69
+ when :deterministic
70
+ "ENCRYPTED (DETERMINISTIC)"
71
+ when :unencrypted
72
+ "NOT ENCRYPTED"
73
+ when :unmapped
74
+ "UNMAPPED"
75
+ when :violation
76
+ "VIOLATION"
77
+ else
78
+ col.status.to_s.upcase
79
+ end
80
+
81
+ padded_status = sprintf("%-#{max_status_len}s", status_label)
82
+
83
+ detail = if col.status == :violation || col.status == :unmapped
84
+ col.violation_message
85
+ else
86
+ col.comment.presence || "-"
87
+ end
88
+
89
+ puts sprintf(
90
+ " %s %s %s %s",
91
+ colorize.call(padded_col, col.status),
92
+ padded_type,
93
+ colorize.call(padded_status, col.status),
94
+ detail
95
+ )
96
+ end
97
+ end
98
+ end
99
+
100
+ namespace :mecha_encrypt do
101
+ namespace :verify do
102
+ desc "Statically verify database column encryption registry parity, model declarations, and comments"
103
+ task static: :environment do
104
+ puts "Running MechaEncrypt static database & model encryption audit..."
105
+ auditor = MechaEncrypt::Auditor::Static.new
106
+ result = auditor.audit
107
+
108
+ audited_tables_count = result.audited_tables.size
109
+ ignored_tables_count = result.ignored_tables.size
110
+ all_cols = result.all_column_audits
111
+ encrypted_count = all_cols.count { |c| c.status == :encrypted }
112
+ deterministic_count = all_cols.count { |c| c.status == :deterministic }
113
+ unencrypted_count = all_cols.count { |c| c.status == :unencrypted }
114
+
115
+ puts " -> #{audited_tables_count} tables audited (#{ignored_tables_count} framework/engine tables ignored via pattern)"
116
+ puts " -> #{all_cols.size} columns audited (#{encrypted_count} encrypted, #{deterministic_count} deterministic, #{unencrypted_count} unencrypted)"
117
+
118
+ if result.unmapped_tables.any?
119
+ puts "\nERROR: Found #{result.unmapped_tables.size} unregistered table(s) missing from config/mecha_encrypt.yml:".light_red
120
+ result.unmapped_tables.each { |t| puts " - #{t}" }
121
+ end
122
+
123
+ if result.unmapped_columns.any?
124
+ puts "\nERROR: Found #{result.unmapped_columns.size} unregistered database column(s) missing from config/mecha_encrypt.yml:".light_red
125
+ result.unmapped_columns.each { |c| puts " - #{c}" }
126
+ end
127
+
128
+ if result.unencrypted_in_model.any?
129
+ puts "\nERROR: Found #{result.unencrypted_in_model.size} column(s) registered as encrypted in YAML but NOT encrypted on the model:".light_red
130
+ result.unencrypted_in_model.each { |err| puts " - #{err}" }
131
+ end
132
+
133
+ if result.undeclared_encrypted_in_model.any?
134
+ puts "\nERROR: Found #{result.undeclared_encrypted_in_model.size} column(s) encrypted on the model but marked unencrypted in YAML:".light_red
135
+ result.undeclared_encrypted_in_model.each { |err| puts " - #{err}" }
136
+ end
137
+
138
+ if result.mode_mismatches.any?
139
+ puts "\nERROR: Found #{result.mode_mismatches.size} encryption determinism mismatch(es) between YAML and model:".light_red
140
+ result.mode_mismatches.each { |err| puts " - #{err}" }
141
+ end
142
+
143
+ if result.invalid_comments.any?
144
+ puts "\nERROR: Found #{result.invalid_comments.size} non-encrypted field/table entry(ies) without a valid comment:".light_red
145
+ result.invalid_comments.each { |err| puts " - #{err}" }
146
+ end
147
+
148
+ if result.dead_tables.any?
149
+ puts "\nERROR: Found #{result.dead_tables.size} dead/stale table(s) in config/mecha_encrypt.yml:".light_red
150
+ result.dead_tables.each { |t| puts " - #{t}" }
151
+ end
152
+
153
+ if result.dead_columns.any?
154
+ puts "\nERROR: Found #{result.dead_columns.size} dead/stale column(s) in config/mecha_encrypt.yml:".light_red
155
+ result.dead_columns.each { |c| puts " - #{c}" }
156
+ end
157
+
158
+ if result.dead_ignored_patterns.any?
159
+ puts "\nERROR: Found #{result.dead_ignored_patterns.size} dead ignored_tables pattern(s) matching 0 tables:".light_red
160
+ result.dead_ignored_patterns.each { |p| puts " - #{p}" }
161
+ end
162
+
163
+ if result.success?
164
+ puts "\n✓ Static database encryption audit passed: 0 unregistered columns, 0 model mismatches, 0 missing comments.".green
165
+ else
166
+ abort("\n✗ Static database encryption audit failed. Please resolve the errors above.")
167
+ end
168
+ end
169
+
170
+ desc "Run behavioral ciphertext round-trip and index safety test suite"
171
+ task behavioral: :environment do
172
+ puts "Running MechaEncrypt behavioral encryption & index safety test suite..."
173
+ matrix_test_path = File.expand_path("../mecha_encrypt/matrix_test.rb", __dir__)
174
+ system({ "MECHA_MATRIX_STANDALONE" => "1" }, "bin/rails", "test", matrix_test_path) || abort("Behavioral encryption verification failed.")
175
+ end
176
+ end
177
+
178
+ desc "Run both static schema/model verification and behavioral ciphertext verification"
179
+ task verify: %w[verify:static verify:behavioral]
180
+ end
metadata ADDED
@@ -0,0 +1,83 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: mecha_encrypt
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Jeff Lunt
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: activesupport
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '7.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '7.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: tiny_color
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '0'
40
+ description: MechaEncrypt enforces database column encryption parity in Rails applications
41
+ via a YAML schema registry, static schema-to-model auditing, and behavioral ciphertext
42
+ & index safety probing.
43
+ email:
44
+ - jefflunt@gmail.com
45
+ executables: []
46
+ extensions: []
47
+ extra_rdoc_files: []
48
+ files:
49
+ - LICENSE
50
+ - README.md
51
+ - Rakefile
52
+ - lib/mecha_encrypt.rb
53
+ - lib/mecha_encrypt/auditor/prober.rb
54
+ - lib/mecha_encrypt/auditor/static.rb
55
+ - lib/mecha_encrypt/configuration.rb
56
+ - lib/mecha_encrypt/errors.rb
57
+ - lib/mecha_encrypt/matrix_test.rb
58
+ - lib/mecha_encrypt/railtie.rb
59
+ - lib/mecha_encrypt/registry.rb
60
+ - lib/mecha_encrypt/version.rb
61
+ - lib/tasks/mecha_encrypt.rake
62
+ homepage: https://github.com/jefflunt/mecha_encrypt
63
+ licenses:
64
+ - MIT
65
+ metadata: {}
66
+ rdoc_options: []
67
+ require_paths:
68
+ - lib
69
+ required_ruby_version: !ruby/object:Gem::Requirement
70
+ requirements:
71
+ - - ">="
72
+ - !ruby/object:Gem::Version
73
+ version: 3.2.0
74
+ required_rubygems_version: !ruby/object:Gem::Requirement
75
+ requirements:
76
+ - - ">="
77
+ - !ruby/object:Gem::Version
78
+ version: '0'
79
+ requirements: []
80
+ rubygems_version: 4.0.16
81
+ specification_version: 4
82
+ summary: Closed-by-default database column encryption registry and auditing for Rails
83
+ test_files: []