standard_audit 0.6.0 → 0.8.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,177 @@
1
+ require "set"
2
+
3
+ module StandardAudit
4
+ # Answers "is this redaction rule safe for MY data?" against the rows an app
5
+ # already has, before the rule is switched on.
6
+ #
7
+ # This matters more here than in most gems: audit rows are append-only, so a
8
+ # rule that swallows real audit content cannot be undone after the fact — the
9
+ # content is simply never written from then on. Reading the historical rows
10
+ # first turns the judgement call into a command.
11
+ #
12
+ # Keys are extracted **in Ruby**, not with `jsonb_object_keys`, so this works
13
+ # against SQLite (the dummy app), MySQL, and Postgres alike. The install
14
+ # template's migration uses jsonb + GIN, but the gem itself stays
15
+ # backend-neutral.
16
+ #
17
+ # StandardAudit::SensitiveKeysDryRun.call(sensitive_key_patterns: [/secret/i])
18
+ # # => #<Report rows_scanned=1204 stripped={"client_secret"=>18, ...} ...>
19
+ #
20
+ class SensitiveKeysDryRun
21
+ # [rows_scanned] how many audit rows were read
22
+ # [stripped] key path => row count, for keys the candidate rule
23
+ # would redact
24
+ # [kept] key path => row count, for keys it would keep
25
+ # [nested_unfiltered] key path => row count, for *nested* keys the rule
26
+ # matches but which are NOT redacted because
27
+ # `filter_nested_metadata` is off. This is the
28
+ # exposure `filter_nested_metadata` exists to close;
29
+ # empty when nested filtering is enabled.
30
+ Report = Struct.new(:rows_scanned, :stripped, :kept, :nested_unfiltered, :nested, keyword_init: true) do
31
+ def any_stripped?
32
+ stripped.any?
33
+ end
34
+
35
+ def to_s
36
+ lines = []
37
+ lines << "Rows scanned: #{rows_scanned}"
38
+ lines << "Nested filtering: #{nested ? 'on' : 'off'}"
39
+ lines << ""
40
+
41
+ lines << section("WOULD BE STRIPPED", stripped, "Nothing would be stripped.")
42
+ lines << ""
43
+ lines << section("KEPT", kept, "No metadata keys found.")
44
+
45
+ if nested_unfiltered.any?
46
+ lines << ""
47
+ lines << section(
48
+ "MATCHES BUT NOT STRIPPED (nested; set config.filter_nested_metadata = true to redact)",
49
+ nested_unfiltered,
50
+ nil
51
+ )
52
+ end
53
+
54
+ lines.join("\n")
55
+ end
56
+
57
+ private
58
+
59
+ def section(title, counts, empty_message)
60
+ out = [title, "=" * title.length]
61
+
62
+ if counts.empty?
63
+ out << (empty_message || "None.")
64
+ else
65
+ width = counts.keys.map(&:length).max
66
+ counts.sort_by { |key, count| [-count, key] }.each do |key, count|
67
+ out << format(" %-#{width}s %d row(s)", key, count)
68
+ end
69
+ end
70
+
71
+ out.join("\n")
72
+ end
73
+ end
74
+
75
+ class << self
76
+ # Every keyword defaults to the app's live configuration, so calling it
77
+ # with no arguments reports what the *current* config does.
78
+ def call(sensitive_keys: nil, sensitive_key_patterns: nil, sensitive_key_exceptions: nil,
79
+ nested: nil, relation: nil, batch_size: 1000)
80
+ new(
81
+ sensitive_keys: sensitive_keys,
82
+ sensitive_key_patterns: sensitive_key_patterns,
83
+ sensitive_key_exceptions: sensitive_key_exceptions,
84
+ nested: nested
85
+ ).call(relation: relation, batch_size: batch_size)
86
+ end
87
+ end
88
+
89
+ def initialize(sensitive_keys: nil, sensitive_key_patterns: nil, sensitive_key_exceptions: nil, nested: nil)
90
+ live = StandardAudit.config
91
+
92
+ candidate = Configuration.new
93
+ candidate.sensitive_keys = sensitive_keys || live.sensitive_keys
94
+ candidate.sensitive_key_patterns = sensitive_key_patterns || live.sensitive_key_patterns
95
+ candidate.sensitive_key_exceptions = sensitive_key_exceptions || live.sensitive_key_exceptions
96
+
97
+ @nested = nested.nil? ? live.filter_nested_metadata : nested
98
+ @filter = MetadataFilter.new(config: candidate)
99
+ end
100
+
101
+ def call(relation: nil, batch_size: 1000)
102
+ relation ||= StandardAudit::AuditLog.all
103
+
104
+ rows = 0
105
+ stripped = Hash.new(0)
106
+ kept = Hash.new(0)
107
+ nested_unfiltered = Hash.new(0)
108
+
109
+ relation.select(:id, :metadata).find_each(batch_size: batch_size) do |log|
110
+ rows += 1
111
+ metadata = log.metadata
112
+ next unless metadata.respond_to?(:each_pair)
113
+
114
+ # Collect distinct paths per row first, then increment once each. A row
115
+ # whose `charges[]` array holds two matching hashes is ONE affected row,
116
+ # not two — the counts are documented as row counts.
117
+ row = { stripped: Set.new, kept: Set.new, nested_unfiltered: Set.new }
118
+ walk(metadata, nil, row, top_level: true)
119
+
120
+ row[:stripped].each { |path| stripped[path] += 1 }
121
+ row[:kept].each { |path| kept[path] += 1 }
122
+ row[:nested_unfiltered].each { |path| nested_unfiltered[path] += 1 }
123
+ end
124
+
125
+ Report.new(
126
+ rows_scanned: rows,
127
+ stripped: stripped,
128
+ kept: kept,
129
+ nested_unfiltered: nested_unfiltered,
130
+ nested: @nested
131
+ )
132
+ end
133
+
134
+ private
135
+
136
+ def walk(hash, prefix, row, top_level:)
137
+ hash.each_pair do |key, value|
138
+ name = key.to_s
139
+ path = prefix ? "#{prefix}.#{name}" : name
140
+
141
+ # Reserved keys are immune at every depth, and their subtree is
142
+ # gem-owned — never descended into. Mirrors MetadataFilter exactly, so
143
+ # the dry run cannot misrepresent the rule it is modelling.
144
+ if StandardAudit::RESERVED_METADATA_KEYS.include?(name)
145
+ row[:kept] << path
146
+ next
147
+ end
148
+
149
+ matches = @filter.filter?(name)
150
+
151
+ if matches && (top_level || @nested)
152
+ row[:stripped] << path
153
+ next
154
+ end
155
+
156
+ # A nested match that survives because nested filtering is off. Reported
157
+ # separately rather than silently counted as "kept" — this is exactly
158
+ # the leak `filter_nested_metadata` closes.
159
+ if matches
160
+ row[:nested_unfiltered] << path
161
+ else
162
+ row[:kept] << path
163
+ end
164
+
165
+ descend(value, path, row)
166
+ end
167
+ end
168
+
169
+ def descend(value, path, row)
170
+ if value.respond_to?(:each_pair)
171
+ walk(value, path, row, top_level: false)
172
+ elsif value.is_a?(Array)
173
+ value.each { |element| descend(element, "#{path}[]", row) }
174
+ end
175
+ end
176
+ end
177
+ end
@@ -79,9 +79,11 @@ module StandardAudit
79
79
  raw_metadata = config.metadata_builder.call(raw_metadata)
80
80
  end
81
81
 
82
- # Filter sensitive keys
83
- sensitive = config.sensitive_keys.map(&:to_s)
84
- raw_metadata.reject { |k, _| sensitive.include?(k.to_s) }
82
+ # Redaction lives in MetadataFilter, shared with StandardAudit.record.
83
+ # This path previously carried its own copy that did *not* subtract
84
+ # RESERVED_METADATA_KEYS, so `_tags`/`_source` were strippable here and
85
+ # not there.
86
+ StandardAudit::MetadataFilter.call(raw_metadata, config: config)
85
87
  end
86
88
  end
87
89
  end
@@ -1,3 +1,3 @@
1
1
  module StandardAudit
2
- VERSION = "0.6.0"
2
+ VERSION = "0.8.0"
3
3
  end
@@ -1,8 +1,11 @@
1
1
  require "standard_audit/version"
2
2
  require "standard_audit/engine"
3
3
  require "standard_audit/configuration"
4
+ require "standard_audit/metadata_filter"
5
+ require "standard_audit/sensitive_keys_dry_run"
4
6
  require "standard_audit/subscriber"
5
7
  require "standard_audit/event_subscriber"
8
+ require "standard_audit/reference_preloading"
6
9
  require "standard_audit/auditable"
7
10
  require "standard_audit/audit_scope"
8
11
  require "standard_audit/checks/retention"
@@ -13,8 +16,30 @@ module StandardAudit
13
16
  RESERVED_METADATA_KEYS = %w[_tags _source].freeze
14
17
 
15
18
  class << self
16
- def configure
17
- yield(config) if block_given?
19
+ # Applies configuration to the single mutable Configuration instance.
20
+ #
21
+ # `baseline: true` also *remembers* the block, so `reset_configuration!`
22
+ # replays it. That matters because the config object holds behaviour, not
23
+ # just data — `before_checksum_hooks` in particular. Without a baseline, a
24
+ # suite that installs the rspec plugin (which calls `reset_configuration!`
25
+ # before every example) silently loses write-time hooks after the first
26
+ # example, and the specs that would notice pass vacuously.
27
+ #
28
+ # Idiomatic host usage, in config/initializers/standard_audit.rb:
29
+ #
30
+ # StandardAudit.configure(baseline: true) do |config|
31
+ # config.subscribe_to "myapp.**"
32
+ # config.before_checksum :backfill_scope
33
+ # end
34
+ #
35
+ # Only the most recent `baseline: true` block is remembered; call it once,
36
+ # from the initializer.
37
+ def configure(baseline: false, &block)
38
+ return config unless block
39
+
40
+ @baseline_configuration = block if baseline
41
+ block.call(config)
42
+ config
18
43
  end
19
44
 
20
45
  def config
@@ -26,10 +51,9 @@ module StandardAudit
26
51
 
27
52
  actor ||= config.current_actor_resolver.call
28
53
 
29
- # Filter sensitive keys. `_tags` and `_source` are reserved internal
30
- # metadata keys owned by EventSubscriber and are never stripped.
31
- sensitive = config.sensitive_keys.map(&:to_s) - RESERVED_METADATA_KEYS
32
- filtered_metadata = metadata.reject { |k, _| sensitive.include?(k.to_s) }
54
+ # Redaction lives in MetadataFilter, shared with Subscriber, so the two
55
+ # write paths cannot drift apart.
56
+ filtered_metadata = MetadataFilter.call(metadata, config: config)
33
57
 
34
58
  attrs = {
35
59
  event_type: event_type,
@@ -101,8 +125,24 @@ module StandardAudit
101
125
  @event_subscriber ||= EventSubscriber.new
102
126
  end
103
127
 
104
- def reset_configuration!
128
+ # Drops the memoized Configuration. Any block registered with
129
+ # `configure(baseline: true)` is replayed onto the fresh instance, so a
130
+ # per-example reset restores the app's real configuration rather than the
131
+ # gem defaults.
132
+ def reset_configuration!(replay_baseline: true)
105
133
  @configuration = nil
134
+ @baseline_configuration&.call(config) if replay_baseline
135
+ config
136
+ end
137
+
138
+ # Forgets the `configure(baseline: true)` block. Mainly for the gem's own
139
+ # specs and for a host that needs a genuinely pristine configuration.
140
+ def clear_baseline_configuration!
141
+ @baseline_configuration = nil
142
+ end
143
+
144
+ def baseline_configured?
145
+ !@baseline_configuration.nil?
106
146
  end
107
147
 
108
148
  private
@@ -113,10 +153,8 @@ module StandardAudit
113
153
 
114
154
  def flush_batch(buffer)
115
155
  now = Time.current
116
- previous_checksum = StandardAudit::AuditLog
117
- .order(created_at: :desc, id: :desc)
118
- .limit(1)
119
- .pick(:checksum)
156
+ records_parent = StandardAudit::AuditLog.chain_parent_column?
157
+ previous_checksum = StandardAudit::AuditLog.chain_tip_checksum
120
158
 
121
159
  # Generate sorted UUIDs to ensure batch ordering matches id ordering.
122
160
  # UUIDv7 within the same millisecond can have non-monotonic lower bits;
@@ -135,6 +173,7 @@ module StandardAudit
135
173
  row.stringify_keys,
136
174
  previous_checksum: previous_checksum
137
175
  )
176
+ row[:previous_checksum] = previous_checksum if records_parent
138
177
  row[:checksum] = checksum
139
178
  previous_checksum = checksum
140
179
  row
@@ -63,22 +63,81 @@ namespace :standard_audit do
63
63
  puts "============================="
64
64
  puts "Records verified: #{result[:verified]}"
65
65
  puts "Chain valid: #{result[:valid]}"
66
+ puts "Forked links recovered: #{result[:recovered]}"
66
67
 
67
68
  if result[:failures].any?
68
- puts "\nTampered records detected: #{result[:failures].size}"
69
+ puts "\nUnverifiable records detected: #{result[:failures].size}"
69
70
  result[:failures].each do |failure|
70
- puts " #{failure[:id]} (#{failure[:event_type]}) at #{failure[:created_at]}"
71
+ puts " #{failure[:id]} (#{failure[:event_type]}) at #{failure[:created_at]} — #{failure[:reason]}"
71
72
  end
72
73
  abort "Chain verification failed"
73
74
  end
74
75
  end
75
76
 
77
+ desc "Record the parent digest each existing row was signed against (never re-signs)"
78
+ task relink_checksums: :environment do
79
+ unless StandardAudit::AuditLog.chain_parent_column?
80
+ abort "audit_logs has no previous_checksum column — run `rails generate standard_audit:add_previous_checksum` first"
81
+ end
82
+
83
+ result = StandardAudit::AuditLog.relink_checksums!
84
+
85
+ puts "Relinked: #{result[:relinked]}"
86
+ puts "Left as-is (already linked, or a segment root): #{result[:skipped]}"
87
+ puts "Unresolved: #{result[:unresolved]}"
88
+ puts ""
89
+ puts "No checksum was rewritten. A parent is recorded only when it reproduces"
90
+ puts "the digest the row has held since it was written; unresolved rows keep"
91
+ puts "failing verification, which is the point."
92
+ end
93
+
76
94
  desc "Backfill checksums for records that don't have them"
77
95
  task backfill_checksums: :environment do
78
96
  count = StandardAudit::AuditLog.backfill_checksums!
79
97
  puts "Backfilled checksums for #{count} audit log records"
80
98
  end
81
99
 
100
+ namespace :sensitive_keys do
101
+ desc "Report which historical metadata keys a redaction rule would strip (read-only)"
102
+ task :dry_run, [:pattern] => :environment do |_t, args|
103
+ # Audit rows are append-only, so a redaction rule that swallows real
104
+ # content cannot be undone. This reads the rows you already have and
105
+ # reports, per key, what the rule would have stripped.
106
+ #
107
+ # rake standard_audit:sensitive_keys:dry_run
108
+ # rake "standard_audit:sensitive_keys:dry_run[secret]"
109
+ # NESTED=1 rake "standard_audit:sensitive_keys:dry_run[secret|token]"
110
+ #
111
+ # The argument is a Regexp source, matched case-insensitively, and is
112
+ # applied *in addition to* the app's configured sensitive_keys.
113
+ # NESTED=1/0 overrides config.filter_nested_metadata for the run.
114
+ pattern = args[:pattern].presence || ENV["PATTERN"].presence
115
+ patterns = StandardAudit.config.sensitive_key_patterns.dup
116
+ patterns << Regexp.new(pattern, Regexp::IGNORECASE) if pattern
117
+
118
+ nested =
119
+ case ENV["NESTED"]
120
+ when nil, "" then nil
121
+ when "0", "false" then false
122
+ else true
123
+ end
124
+
125
+ report = StandardAudit::SensitiveKeysDryRun.call(
126
+ sensitive_key_patterns: patterns,
127
+ nested: nested,
128
+ batch_size: (ENV["BATCH_SIZE"] || 1000).to_i
129
+ )
130
+
131
+ puts "StandardAudit sensitive-key dry run"
132
+ puts "==================================="
133
+ puts "Candidate pattern: #{pattern ? Regexp.new(pattern, Regexp::IGNORECASE).inspect : '(none — reporting current config)'}"
134
+ puts ""
135
+ puts report
136
+ puts ""
137
+ puts "Nothing was written. Rows are append-only; a rule you enable applies only to future writes."
138
+ end
139
+ end
140
+
82
141
  desc "Export audit logs for a specific actor (GDPR right to access)"
83
142
  task :export_actor, [:actor_gid, :output] => :environment do |_t, args|
84
143
  raise "actor_gid is required" unless args[:actor_gid].present?
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: standard_audit
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.0
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jaryl Sim
@@ -99,6 +99,8 @@ files:
99
99
  - config/routes.rb
100
100
  - lib/generators/standard_audit/add_checksums/add_checksums_generator.rb
101
101
  - lib/generators/standard_audit/add_checksums/templates/add_checksum_to_audit_logs.rb.erb
102
+ - lib/generators/standard_audit/add_previous_checksum/add_previous_checksum_generator.rb
103
+ - lib/generators/standard_audit/add_previous_checksum/templates/add_previous_checksum_to_audit_logs.rb.erb
102
104
  - lib/generators/standard_audit/install/install_generator.rb
103
105
  - lib/generators/standard_audit/install/templates/create_audit_logs.rb.erb
104
106
  - lib/generators/standard_audit/install/templates/initializer.rb.erb
@@ -109,7 +111,10 @@ files:
109
111
  - lib/standard_audit/configuration.rb
110
112
  - lib/standard_audit/engine.rb
111
113
  - lib/standard_audit/event_subscriber.rb
114
+ - lib/standard_audit/metadata_filter.rb
115
+ - lib/standard_audit/reference_preloading.rb
112
116
  - lib/standard_audit/rspec.rb
117
+ - lib/standard_audit/sensitive_keys_dry_run.rb
113
118
  - lib/standard_audit/subscriber.rb
114
119
  - lib/standard_audit/version.rb
115
120
  - lib/tasks/standard_audit_tasks.rake