standard_audit 0.12.1 → 0.13.1
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 +4 -4
- data/CHANGELOG.md +207 -0
- data/README.md +199 -21
- data/app/models/standard_audit/audit_log.rb +190 -67
- data/lib/generators/standard_audit/add_anonymized_at/add_anonymized_at_generator.rb +4 -5
- data/lib/generators/standard_audit/add_previous_checksum/add_previous_checksum_generator.rb +4 -5
- data/lib/generators/standard_audit/install/install_generator.rb +2 -5
- data/lib/generators/standard_audit/migration_number.rb +40 -0
- data/lib/standard_audit/checksum/key_order_search.rb +153 -0
- data/lib/standard_audit/checksum.rb +208 -0
- data/lib/standard_audit/configuration.rb +33 -1
- data/lib/standard_audit/engine.rb +5 -2
- data/lib/standard_audit/event_subscriber.rb +2 -1
- data/lib/standard_audit/operation/audit.rb +2 -6
- data/lib/standard_audit/rspec/baseline.rb +27 -3
- data/lib/standard_audit/subscriber.rb +2 -1
- data/lib/standard_audit/version.rb +1 -1
- data/lib/standard_audit.rb +58 -19
- data/lib/tasks/standard_audit_tasks.rake +18 -1
- metadata +11 -11
- data/config/routes.rb +0 -2
- data/lib/generators/standard_audit/add_checksums/add_checksums_generator.rb +0 -30
- data/lib/generators/standard_audit/add_checksums/templates/add_checksum_to_audit_logs.rb.erb +0 -6
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "openssl"
|
|
3
|
+
|
|
4
|
+
module StandardAudit
|
|
5
|
+
# The row digest. There are two algorithms; which one a row uses is decided
|
|
6
|
+
# by its `created_at` against `config.canonical_checksum_since` (default
|
|
7
|
+
# StandardAudit::CANONICAL_CHECKSUM_CUTOVER), at write time and again at
|
|
8
|
+
# verification, from the same stored timestamp. Nothing extra is stored.
|
|
9
|
+
#
|
|
10
|
+
# == Legacy (rows created before the cutover)
|
|
11
|
+
#
|
|
12
|
+
# `SHA256("<parent>|field=value|field=value|…")`, where a Hash value was
|
|
13
|
+
# serialised with `to_json` in whatever key order the Ruby hash had. That is
|
|
14
|
+
# the bug in fundbright/delivery-ops#689: at write time the hash is the one
|
|
15
|
+
# the caller built (insertion order), but PostgreSQL `jsonb` — and MySQL
|
|
16
|
+
# `JSON` — store object keys in their own order (shortest first, then
|
|
17
|
+
# bytewise), so the value read back serialises differently and the digest
|
|
18
|
+
# cannot be reproduced. SQLite keeps JSON as text, so the gem's own suite
|
|
19
|
+
# never saw it. Kept byte-for-byte so legacy rows are verified exactly as
|
|
20
|
+
# they were signed.
|
|
21
|
+
#
|
|
22
|
+
# == Canonical (rows created at or after the cutover)
|
|
23
|
+
#
|
|
24
|
+
# `SHA256(canonical_json({"fields" => {…}, "previous_checksum" => …, "v" => 2}))`.
|
|
25
|
+
# Every input is reduced to a form the database cannot change:
|
|
26
|
+
#
|
|
27
|
+
# * Hash / Array values (the `metadata` jsonb column, or any other JSON
|
|
28
|
+
# column a host adds to the hashed set) are first round-tripped through
|
|
29
|
+
# `ActiveSupport::JSON.encode` + `JSON.parse` — the same encoding the
|
|
30
|
+
# column type applies on write — so symbol keys, Time values, BigDecimals
|
|
31
|
+
# and the like hash as the JSON the database actually stores. Object keys
|
|
32
|
+
# are then sorted bytewise at every depth; array order is kept (arrays are
|
|
33
|
+
# ordered in every JSON store).
|
|
34
|
+
# * Integral floats hash as integers (`1.0` → `1`, `1e20` →
|
|
35
|
+
# `100000000000000000000`), because a JSON store is free to hand back
|
|
36
|
+
# either spelling of the same number.
|
|
37
|
+
# * Times hash as UTC ISO 8601 with microseconds, as in the legacy digest.
|
|
38
|
+
# * Strings are escaped at the byte level (`"`, `\` and C0 controls only),
|
|
39
|
+
# so the output does not depend on the json gem's escaping options.
|
|
40
|
+
# * nil stays `null`, distinct from `""` — the legacy digest conflated them.
|
|
41
|
+
# * Fields are encoded as a JSON object rather than joined with `|`, so a
|
|
42
|
+
# value containing `|field=` can no longer move content between adjacent
|
|
43
|
+
# fields without changing the digest.
|
|
44
|
+
#
|
|
45
|
+
# Known limit: a host with `ActiveSupport.parse_json_times = true` reads
|
|
46
|
+
# ISO 8601 strings in JSON back as Time objects, which re-encode at
|
|
47
|
+
# millisecond precision in the app's zone. A metadata string that was not
|
|
48
|
+
# already in that exact form would then hash differently on read. The
|
|
49
|
+
# setting is off by default.
|
|
50
|
+
module Checksum
|
|
51
|
+
LEGACY = 1
|
|
52
|
+
CANONICAL = 2
|
|
53
|
+
ALGORITHMS = [LEGACY, CANONICAL].freeze
|
|
54
|
+
|
|
55
|
+
TIME_FORMAT = "%Y-%m-%dT%H:%M:%S.%6NZ".freeze
|
|
56
|
+
ESCAPE = /["\\\x00-\x1f]/n
|
|
57
|
+
|
|
58
|
+
module_function
|
|
59
|
+
|
|
60
|
+
# The algorithm for a row created at `created_at`: canonical at or after
|
|
61
|
+
# `config.canonical_checksum_since`, legacy before it. A row not yet
|
|
62
|
+
# timestamped is judged by the current time — what a write now would use.
|
|
63
|
+
def algorithm_for(created_at)
|
|
64
|
+
created_at ||= Time.current
|
|
65
|
+
created_at >= StandardAudit.config.canonical_checksum_since ? CANONICAL : LEGACY
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def digest(attrs, fields:, previous_checksum: nil, version:)
|
|
69
|
+
digester(attrs, fields: fields, version: version).call(previous_checksum)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# A callable `parent -> digest` for one row. The row's own serialisation
|
|
73
|
+
# is built once, so trying many candidate parents (the recovery search)
|
|
74
|
+
# costs one SHA-256 each.
|
|
75
|
+
def digester(attrs, fields:, version:)
|
|
76
|
+
case version
|
|
77
|
+
when LEGACY then legacy_digester(attrs, fields: fields)
|
|
78
|
+
when CANONICAL then canonical_digester(attrs, fields: fields)
|
|
79
|
+
else raise ArgumentError, "unknown checksum algorithm #{version.inspect} (known: #{ALGORITHMS.join(", ")})"
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# The legacy digest, unchanged since 0.3. Do not "fix" this: it is how
|
|
84
|
+
# every pre-cutover row is signed, and it must keep reproducing them.
|
|
85
|
+
def legacy_digest(attrs, fields:, previous_checksum: nil)
|
|
86
|
+
legacy_digester(attrs, fields: fields).call(previous_checksum)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def legacy_digester(attrs, fields:)
|
|
90
|
+
canonical = fields.map { |f|
|
|
91
|
+
value = attrs[f]
|
|
92
|
+
value = value.to_json if value.is_a?(Hash)
|
|
93
|
+
value = value.utc.strftime(TIME_FORMAT) if time_like?(value)
|
|
94
|
+
"#{f}=#{value}"
|
|
95
|
+
}.join("|")
|
|
96
|
+
|
|
97
|
+
lambda do |previous_checksum|
|
|
98
|
+
input = previous_checksum.present? ? "#{previous_checksum}|#{canonical}" : canonical
|
|
99
|
+
OpenSSL::Digest::SHA256.hexdigest(input)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def canonical_digest(attrs, fields:, previous_checksum: nil)
|
|
104
|
+
canonical_digester(attrs, fields: fields).call(previous_checksum)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# SHA-256 of canonical_json(canonical_payload(...)). The payload's keys
|
|
108
|
+
# sort as "fields" < "previous_checksum" < "v", so the row's part is
|
|
109
|
+
# serialised once and only the parent is spliced in per call — the
|
|
110
|
+
# result is byte-identical to serialising the whole payload.
|
|
111
|
+
def canonical_digester(attrs, fields:)
|
|
112
|
+
head = String.new("{\"fields\":", encoding: Encoding::BINARY)
|
|
113
|
+
canonical_json(fields.to_h { |f| [f, canonical_value(attrs[f])] }, head)
|
|
114
|
+
head << ",\"previous_checksum\":"
|
|
115
|
+
tail = ",\"v\":#{CANONICAL}}"
|
|
116
|
+
|
|
117
|
+
lambda do |previous_checksum|
|
|
118
|
+
parent = previous_checksum.presence
|
|
119
|
+
OpenSSL::Digest::SHA256.hexdigest(head + (parent ? json_string(parent.to_s, String.new(encoding: Encoding::BINARY)) : "null") + tail)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# The documented canonical payload for a row ("v" => 2 names the format), as a plain Hash.
|
|
124
|
+
def canonical_payload(attrs, fields:, previous_checksum: nil)
|
|
125
|
+
{
|
|
126
|
+
"fields" => fields.to_h { |f| [f, canonical_value(attrs[f])] },
|
|
127
|
+
"previous_checksum" => previous_checksum.presence,
|
|
128
|
+
"v" => CANONICAL
|
|
129
|
+
}
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# The stored-form value of one field, before canonical encoding.
|
|
133
|
+
def canonical_value(value)
|
|
134
|
+
case value
|
|
135
|
+
when nil, String, Integer, Float, true, false then value
|
|
136
|
+
when Hash, Array then json_round_trip(value)
|
|
137
|
+
else time_like?(value) ? value.utc.strftime(TIME_FORMAT) : value.to_s
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# What a JSON column hands back for `value`: encoded exactly as
|
|
142
|
+
# ActiveRecord's JSON type encodes on write, then parsed. Key ORDER is
|
|
143
|
+
# the one thing this does not settle — canonical_json does.
|
|
144
|
+
def json_round_trip(value)
|
|
145
|
+
JSON.parse(ActiveSupport::JSON.encode(value), max_nesting: false, create_additions: false)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Deterministic JSON: object keys sorted bytewise at every depth, no
|
|
149
|
+
# whitespace, integral floats as integers, byte-level string escaping.
|
|
150
|
+
# Returns a binary String.
|
|
151
|
+
def canonical_json(value, out = String.new(encoding: Encoding::BINARY))
|
|
152
|
+
case value
|
|
153
|
+
when Hash
|
|
154
|
+
out << "{"
|
|
155
|
+
value.map { |k, v| [k.to_s.b, v] }.sort_by(&:first).each_with_index do |(k, v), i|
|
|
156
|
+
out << "," if i.positive?
|
|
157
|
+
json_string(k, out)
|
|
158
|
+
out << ":"
|
|
159
|
+
canonical_json(v, out)
|
|
160
|
+
end
|
|
161
|
+
out << "}"
|
|
162
|
+
when Array
|
|
163
|
+
out << "["
|
|
164
|
+
value.each_with_index do |v, i|
|
|
165
|
+
out << "," if i.positive?
|
|
166
|
+
canonical_json(v, out)
|
|
167
|
+
end
|
|
168
|
+
out << "]"
|
|
169
|
+
when nil then out << "null"
|
|
170
|
+
when true then out << "true"
|
|
171
|
+
when false then out << "false"
|
|
172
|
+
when Integer then out << value.to_s
|
|
173
|
+
when Float then out << (value.finite? && value == value.floor ? value.to_i.to_s : value.to_s)
|
|
174
|
+
else json_string(value.to_s, out)
|
|
175
|
+
end
|
|
176
|
+
out
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def json_string(string, out)
|
|
180
|
+
out << '"'
|
|
181
|
+
out << string.b.gsub(ESCAPE) do |c|
|
|
182
|
+
case c
|
|
183
|
+
when '"' then '\\"'
|
|
184
|
+
when "\\" then "\\\\"
|
|
185
|
+
else format("\\u%04x", c.ord)
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
out << '"'
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def time_like?(value)
|
|
192
|
+
value.respond_to?(:strftime) && value.respond_to?(:utc)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# True when some JSON object inside `value` has more than one key, i.e.
|
|
196
|
+
# a store that reorders keys could have changed how the legacy digest serialised
|
|
197
|
+
# it. A row with no such object cannot fail the legacy digest because of key order.
|
|
198
|
+
def key_order_ambiguous?(value)
|
|
199
|
+
case value
|
|
200
|
+
when Hash then value.size > 1 || value.each_value.any? { |v| key_order_ambiguous?(v) }
|
|
201
|
+
when Array then value.any? { |v| key_order_ambiguous?(v) }
|
|
202
|
+
else false
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
require "standard_audit/checksum/key_order_search"
|
|
@@ -13,7 +13,8 @@ module StandardAudit
|
|
|
13
13
|
:anonymizable_metadata_keys, :retention_days,
|
|
14
14
|
:audit_catalogue, :verify_audit_declarations,
|
|
15
15
|
:raise_on_audit_write_error, :audit_write_error_handler,
|
|
16
|
-
:audit_error_context_key
|
|
16
|
+
:audit_error_context_key, :error_reporter,
|
|
17
|
+
:canonical_checksum_since
|
|
17
18
|
|
|
18
19
|
def initialize
|
|
19
20
|
@subscriptions = []
|
|
@@ -128,6 +129,16 @@ module StandardAudit
|
|
|
128
129
|
# computed. See Configuration#before_checksum.
|
|
129
130
|
@before_checksum_hooks = []
|
|
130
131
|
|
|
132
|
+
# Rows whose `created_at` is at or after this time are signed with the
|
|
133
|
+
# canonical checksum (object keys sorted, independent of how jsonb
|
|
134
|
+
# stores them) and verified strictly with it; earlier rows keep the
|
|
135
|
+
# legacy algorithm. Defaults to StandardAudit::CANONICAL_CHECKSUM_CUTOVER.
|
|
136
|
+
# Override only if your rollout of this release slips past that date,
|
|
137
|
+
# set it BEFORE the new time arrives, and never change it afterwards:
|
|
138
|
+
# verification recomputes the same decision from each row's stored
|
|
139
|
+
# created_at, so moving it re-judges rows under the other algorithm.
|
|
140
|
+
@canonical_checksum_since = StandardAudit::CANONICAL_CHECKSUM_CUTOVER
|
|
141
|
+
|
|
131
142
|
@anonymizable_metadata_keys = %i[email name ip_address]
|
|
132
143
|
|
|
133
144
|
# ── StandardAudit::Operation (the operation-audit DSL) ────────────────
|
|
@@ -191,6 +202,27 @@ module StandardAudit
|
|
|
191
202
|
# improvement to the built-in reporter. Default keeps existing behaviour.
|
|
192
203
|
@audit_error_context_key = :audit_action
|
|
193
204
|
|
|
205
|
+
# Where the gem sends the errors it swallows: a failed subscriber write,
|
|
206
|
+
# a failed `record(raise: false)`, a raising `before_checksum` hook, and
|
|
207
|
+
# a failed `audit!` write under the default report-and-swallow policy.
|
|
208
|
+
# A callable taking `(error, context)`, where context is a Hash such as
|
|
209
|
+
# `{ audit_action: "orders.created", subscriber: "StandardAudit::Subscriber" }`
|
|
210
|
+
# (keyed by `audit_error_context_key`).
|
|
211
|
+
#
|
|
212
|
+
# nil (the default) means `Rails.error.report(error, handled: true,
|
|
213
|
+
# context: context)`. That reaches Sentry only when a `Rails.error`
|
|
214
|
+
# subscriber is registered (sentry-rails registers one); a host that
|
|
215
|
+
# never forwards `Rails.error` can point this straight at its tracker:
|
|
216
|
+
#
|
|
217
|
+
# config.error_reporter = ->(error, context) { Sentry.capture_exception(error, extra: context) }
|
|
218
|
+
#
|
|
219
|
+
# It replaces the built-in `Rails.error` call; call `Rails.error.report`
|
|
220
|
+
# from it too if you want both. A reporter that raises is logged and
|
|
221
|
+
# ignored, so it can never turn a swallowed audit failure into a raised
|
|
222
|
+
# one. `audit_write_error_handler`, when set, still takes precedence for
|
|
223
|
+
# `audit!` write failures.
|
|
224
|
+
@error_reporter = nil
|
|
225
|
+
|
|
194
226
|
# Retention defaults from ENV so it can be set per-environment without a
|
|
195
227
|
# code change. Unset/blank/non-positive => nil (infinite retention, the
|
|
196
228
|
# compliance-safe default that never auto-deletes). A host app can still
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
module StandardAudit
|
|
2
|
+
# A plain (non-isolated) engine: it contributes the AuditLog model, the two
|
|
3
|
+
# jobs and the subscriber wiring, and has no routes, controllers or views.
|
|
4
|
+
# 0.13.0 dropped `isolate_namespace` and the empty `config/routes.rb` it
|
|
5
|
+
# carried. Nothing depended on them: AuditLog sets its own `table_name`, and
|
|
6
|
+
# no host mounted the engine.
|
|
2
7
|
class Engine < ::Rails::Engine
|
|
3
|
-
isolate_namespace StandardAudit
|
|
4
|
-
|
|
5
8
|
initializer "standard_audit.subscriber" do
|
|
6
9
|
ActiveSupport.on_load(:active_record) do
|
|
7
10
|
StandardAudit.subscriber.setup!
|
|
@@ -47,7 +47,8 @@ module StandardAudit
|
|
|
47
47
|
ip_address: context[:ip_address] || payload[:ip_address],
|
|
48
48
|
user_agent: context[:user_agent] || payload[:user_agent],
|
|
49
49
|
session_id: context[:session_id] || payload[:session_id]
|
|
50
|
-
}
|
|
50
|
+
},
|
|
51
|
+
via: :rails_event
|
|
51
52
|
)
|
|
52
53
|
rescue => e
|
|
53
54
|
StandardAudit.report_write_error(e, name, subscriber: self.class.name)
|
|
@@ -282,13 +282,9 @@ module StandardAudit
|
|
|
282
282
|
message = "[StandardAudit] Failed to record #{action}: #{error.class} #{error.message}"
|
|
283
283
|
Rails.logger&.error(message) if defined?(Rails) && Rails.respond_to?(:logger)
|
|
284
284
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
Rails.error.report(
|
|
285
|
+
StandardAudit.report_error(
|
|
288
286
|
error,
|
|
289
|
-
|
|
290
|
-
context: { StandardAudit.config.audit_error_context_key => action,
|
|
291
|
-
operation: operation.class.name }
|
|
287
|
+
{ StandardAudit.config.audit_error_context_key => action, operation: operation.class.name }
|
|
292
288
|
)
|
|
293
289
|
end
|
|
294
290
|
end
|
|
@@ -18,12 +18,19 @@ require "standard_audit"
|
|
|
18
18
|
# catalogue: -> { AuditCatalogue::ACTIONS },
|
|
19
19
|
# sensitive_keys: %i[source_payload],
|
|
20
20
|
# sensitive_key_patterns: [/secret/i],
|
|
21
|
-
# present: %i[metadata_builder before_write current_scope_resolver]
|
|
21
|
+
# present: %i[metadata_builder before_write current_scope_resolver],
|
|
22
|
+
# hooks: 2 # or %i[backfill_scope classify_actor]
|
|
22
23
|
# end
|
|
23
24
|
#
|
|
24
25
|
# Every option is optional. Behaviour held in lambdas (resolvers, builders)
|
|
25
26
|
# can't be compared by value, so list them under `present:` to assert they
|
|
26
27
|
# survive a reset, and keep an app-specific example for what they return.
|
|
28
|
+
#
|
|
29
|
+
# `hooks:` covers `before_checksum` hooks, which live in a list rather than a
|
|
30
|
+
# named setting. Pass an Integer to assert exactly that many are registered,
|
|
31
|
+
# or an Array of the Symbol hook names (`config.before_checksum :name`) to
|
|
32
|
+
# assert each is registered. The mutation example also clears the hooks
|
|
33
|
+
# before the reset, so it fails if the baseline block doesn't re-add them.
|
|
27
34
|
RSpec.shared_examples "a standard_audit baseline" do |options = {}|
|
|
28
35
|
subscriptions = Array(options[:subscriptions])
|
|
29
36
|
settings = options.fetch(:settings, {})
|
|
@@ -31,9 +38,14 @@ RSpec.shared_examples "a standard_audit baseline" do |options = {}|
|
|
|
31
38
|
sensitive_keys = Array(options[:sensitive_keys])
|
|
32
39
|
sensitive_key_patterns = Array(options[:sensitive_key_patterns])
|
|
33
40
|
present = Array(options[:present])
|
|
41
|
+
hooks = options[:hooks]
|
|
42
|
+
|
|
43
|
+
unless hooks.nil? || hooks.is_a?(Integer) || (hooks.is_a?(Array) && hooks.all? { |h| h.is_a?(Symbol) || h.is_a?(String) })
|
|
44
|
+
raise ArgumentError, "hooks: must be an Integer (hook count) or an Array of Symbol hook names; got #{hooks.inspect}"
|
|
45
|
+
end
|
|
34
46
|
|
|
35
47
|
def standard_audit_baseline_assertions(subscriptions:, settings:, catalogue:, sensitive_keys:,
|
|
36
|
-
sensitive_key_patterns:, present:)
|
|
48
|
+
sensitive_key_patterns:, present:, hooks:)
|
|
37
49
|
config = StandardAudit.config
|
|
38
50
|
|
|
39
51
|
expect(config.subscriptions).to include(*subscriptions) if subscriptions.any?
|
|
@@ -49,11 +61,22 @@ RSpec.shared_examples "a standard_audit baseline" do |options = {}|
|
|
|
49
61
|
present.each do |name|
|
|
50
62
|
expect(config.public_send(name)).not_to be_nil, "expected config.#{name} to survive a reset"
|
|
51
63
|
end
|
|
64
|
+
case hooks
|
|
65
|
+
when Integer
|
|
66
|
+
expect(config.before_checksum_hooks.size).to eq(hooks),
|
|
67
|
+
"expected #{hooks} before_checksum hook(s) after a reset, found #{config.before_checksum_hooks.size}"
|
|
68
|
+
when Array
|
|
69
|
+
registered = config.before_checksum_hooks.select { |h| h.is_a?(Symbol) || h.is_a?(String) }.map(&:to_sym)
|
|
70
|
+
hooks.map(&:to_sym).each do |name|
|
|
71
|
+
expect(registered).to include(name), "expected before_checksum :#{name} to survive a reset"
|
|
72
|
+
end
|
|
73
|
+
end
|
|
52
74
|
end
|
|
53
75
|
|
|
54
76
|
let(:standard_audit_baseline_options) do
|
|
55
77
|
{ subscriptions: subscriptions, settings: settings, catalogue: catalogue,
|
|
56
|
-
sensitive_keys: sensitive_keys, sensitive_key_patterns: sensitive_key_patterns, present: present
|
|
78
|
+
sensitive_keys: sensitive_keys, sensitive_key_patterns: sensitive_key_patterns, present: present,
|
|
79
|
+
hooks: hooks }
|
|
57
80
|
end
|
|
58
81
|
|
|
59
82
|
it "is registered with configure(baseline: true)" do
|
|
@@ -71,6 +94,7 @@ RSpec.shared_examples "a standard_audit baseline" do |options = {}|
|
|
|
71
94
|
StandardAudit.config.subscribe_to "standard_audit.isolation_canary"
|
|
72
95
|
settings.each_key { |name| StandardAudit.config.public_send(:"#{name}=", nil) }
|
|
73
96
|
present.each { |name| StandardAudit.config.public_send(:"#{name}=", nil) }
|
|
97
|
+
StandardAudit.config.before_checksum_hooks = [] unless hooks.nil?
|
|
74
98
|
|
|
75
99
|
StandardAudit.reset_configuration!
|
|
76
100
|
|
|
@@ -46,7 +46,8 @@ module StandardAudit
|
|
|
46
46
|
target: config.target_extractor.call(payload),
|
|
47
47
|
scope: config.scope_extractor.call(payload),
|
|
48
48
|
metadata: payload.except(*EXCLUDED_PAYLOAD_KEYS),
|
|
49
|
-
context: payload.slice(:request_id, :ip_address, :user_agent, :session_id)
|
|
49
|
+
context: payload.slice(:request_id, :ip_address, :user_agent, :session_id),
|
|
50
|
+
via: :notification
|
|
50
51
|
)
|
|
51
52
|
rescue => e
|
|
52
53
|
StandardAudit.report_write_error(e, event.name, subscriber: self.class.name)
|
data/lib/standard_audit.rb
CHANGED
|
@@ -1,6 +1,17 @@
|
|
|
1
1
|
require "standard_audit/version"
|
|
2
|
+
|
|
3
|
+
module StandardAudit
|
|
4
|
+
# From this instant (by each row's `created_at`) new rows get the canonical
|
|
5
|
+
# checksum and are verified strictly with it; earlier rows are legacy. See
|
|
6
|
+
# StandardAudit::Checksum and fundbright/delivery-ops#689. Every host must
|
|
7
|
+
# run 0.13.1+ BEFORE this time: an older gem still writing after it produces
|
|
8
|
+
# legacy-hashed rows that fail strict canonical verification.
|
|
9
|
+
# Overridable per host via `config.canonical_checksum_since`.
|
|
10
|
+
CANONICAL_CHECKSUM_CUTOVER = Time.utc(2026, 10, 1, 0, 0, 0).freeze
|
|
11
|
+
end
|
|
2
12
|
require "standard_audit/engine"
|
|
3
13
|
require "standard_audit/configuration"
|
|
14
|
+
require "standard_audit/checksum"
|
|
4
15
|
require "standard_audit/metadata_filter"
|
|
5
16
|
require "standard_audit/record_reference"
|
|
6
17
|
require "standard_audit/sensitive_keys_dry_run"
|
|
@@ -17,6 +28,16 @@ module StandardAudit
|
|
|
17
28
|
# `sensitive_keys` even if a user adds them there.
|
|
18
29
|
RESERVED_METADATA_KEYS = %w[_tags _source].freeze
|
|
19
30
|
|
|
31
|
+
# Values of `entry[:via]` as `before_write` sees it:
|
|
32
|
+
#
|
|
33
|
+
# * `:direct` — `StandardAudit.record` without a block, and therefore
|
|
34
|
+
# `Auditable#record_audit` and `Operation#audit!`.
|
|
35
|
+
# * `:notification` — the ActiveSupport::Notifications subscriber
|
|
36
|
+
# (`subscribe_to` patterns, and `StandardAudit.record` WITH a block, which
|
|
37
|
+
# instruments the event and lets this subscriber write it).
|
|
38
|
+
# * `:rails_event` — the `Rails.event` subscriber (Rails 8.1+).
|
|
39
|
+
VIA = %i[direct notification rails_event].freeze
|
|
40
|
+
|
|
20
41
|
class << self
|
|
21
42
|
# Applies configuration to the single mutable Configuration instance.
|
|
22
43
|
#
|
|
@@ -58,7 +79,8 @@ module StandardAudit
|
|
|
58
79
|
# here), so it records only when the event is subscribed to.
|
|
59
80
|
#
|
|
60
81
|
# `raise: false` makes a failed write non-fatal: the error is logged and
|
|
61
|
-
# reported
|
|
82
|
+
# reported through `config.error_reporter` (default: `Rails.error`, as
|
|
83
|
+
# handled), and nil is returned. For call
|
|
62
84
|
# sites where a missing audit row must never break the request (auth
|
|
63
85
|
# failure logging, say). It governs only the audit write — in block form
|
|
64
86
|
# the subscriber already rescues, and the block's own errors always
|
|
@@ -80,7 +102,7 @@ module StandardAudit
|
|
|
80
102
|
|
|
81
103
|
begin
|
|
82
104
|
write_entry(event_type, actor: actor, target: target, scope: scope,
|
|
83
|
-
metadata: metadata, context: options)
|
|
105
|
+
metadata: metadata, context: options, via: :direct)
|
|
84
106
|
rescue => e
|
|
85
107
|
raise if raise_errors
|
|
86
108
|
|
|
@@ -96,7 +118,11 @@ module StandardAudit
|
|
|
96
118
|
# values; nil entries fall back to the Current resolvers. `reserved` is
|
|
97
119
|
# merged into metadata AFTER `metadata_builder` (the Rails.event subscriber
|
|
98
120
|
# uses it for `_tags` / `_source`, which a builder never saw before 0.12).
|
|
99
|
-
|
|
121
|
+
# `via` names the entry point (see VIA) and is handed to `before_write`
|
|
122
|
+
# as `entry[:via]`; it is not persisted.
|
|
123
|
+
def write_entry(event_type, actor:, target:, scope:, metadata:, context: {}, reserved: {}, via: :direct)
|
|
124
|
+
raise ArgumentError, "via must be one of #{VIA.inspect}; got #{via.inspect}" unless VIA.include?(via)
|
|
125
|
+
|
|
100
126
|
actor ||= config.current_actor_resolver.call
|
|
101
127
|
scope ||= config.current_scope_resolver&.call
|
|
102
128
|
|
|
@@ -113,14 +139,16 @@ module StandardAudit
|
|
|
113
139
|
request_id: context[:request_id] || config.current_request_id_resolver.call,
|
|
114
140
|
ip_address: context[:ip_address] || config.current_ip_address_resolver.call,
|
|
115
141
|
user_agent: context[:user_agent] || config.current_user_agent_resolver.call,
|
|
116
|
-
session_id: context[:session_id] || config.current_session_id_resolver.call
|
|
142
|
+
session_id: context[:session_id] || config.current_session_id_resolver.call,
|
|
143
|
+
via: via
|
|
117
144
|
}
|
|
118
145
|
|
|
119
|
-
# Runs on EVERY path, before redaction, so
|
|
120
|
-
#
|
|
121
|
-
#
|
|
122
|
-
#
|
|
123
|
-
#
|
|
146
|
+
# Runs on EVERY path, AFTER `metadata_builder` and before redaction, so
|
|
147
|
+
# it sees the builder's output and anything it injects into metadata is
|
|
148
|
+
# still subject to `sensitive_keys` and dereferencing. It may mutate
|
|
149
|
+
# `entry` in place; its return value is ignored. It may raise — that is
|
|
150
|
+
# how a host guard rejects a write — and the error propagates exactly as
|
|
151
|
+
# a failed save would. `entry[:via]` says which entry point wrote it.
|
|
124
152
|
config.before_write&.call(entry)
|
|
125
153
|
|
|
126
154
|
persist(entry)
|
|
@@ -147,20 +175,29 @@ module StandardAudit
|
|
|
147
175
|
Thread.current[:standard_audit_batch] = previous
|
|
148
176
|
end
|
|
149
177
|
|
|
150
|
-
# @api private — logs a failed audit write and reports it
|
|
151
|
-
#
|
|
152
|
-
# failure break the instrumented code path
|
|
178
|
+
# @api private — logs a failed audit write and reports it through
|
|
179
|
+
# `report_error`. Used by the subscribers, which must never let an audit
|
|
180
|
+
# failure break the instrumented code path, and by `record(raise: false)`.
|
|
153
181
|
def report_write_error(error, event_type, **context)
|
|
154
182
|
Rails.logger.error("[StandardAudit] Error creating audit log for #{event_type}: #{error.class}: #{error.message}")
|
|
155
|
-
|
|
183
|
+
report_error(error, { config.audit_error_context_key => event_type, **context })
|
|
184
|
+
end
|
|
156
185
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
186
|
+
# @api private — the one place the gem reports an error it swallows.
|
|
187
|
+
# Calls `config.error_reporter` when set, otherwise
|
|
188
|
+
# `Rails.error.report(error, handled: true, context:)`. A reporter that
|
|
189
|
+
# raises is logged and ignored.
|
|
190
|
+
def report_error(error, context)
|
|
191
|
+
if config.error_reporter
|
|
192
|
+
config.error_reporter.call(error, context)
|
|
193
|
+
elsif defined?(Rails) && Rails.respond_to?(:error) && Rails.error
|
|
194
|
+
Rails.error.report(error, handled: true, context: context)
|
|
195
|
+
end
|
|
196
|
+
nil
|
|
162
197
|
rescue => report_failure
|
|
163
|
-
|
|
198
|
+
message = "[StandardAudit] Error reporting audit failure: #{report_failure.class}: #{report_failure.message}"
|
|
199
|
+
Rails.logger&.error(message) if defined?(Rails) && Rails.respond_to?(:logger)
|
|
200
|
+
nil
|
|
164
201
|
end
|
|
165
202
|
|
|
166
203
|
def subscriber
|
|
@@ -260,6 +297,8 @@ module StandardAudit
|
|
|
260
297
|
created_at: now,
|
|
261
298
|
updated_at: now
|
|
262
299
|
))
|
|
300
|
+
# created_at is set above, so the algorithm is decided by the same
|
|
301
|
+
# stored timestamp verification will read.
|
|
263
302
|
checksum = StandardAudit::AuditLog.compute_checksum_value(
|
|
264
303
|
row.stringify_keys,
|
|
265
304
|
previous_checksum: previous_checksum
|
|
@@ -85,17 +85,34 @@ namespace :standard_audit do
|
|
|
85
85
|
|
|
86
86
|
desc "Verify audit log chain integrity (tamper detection)"
|
|
87
87
|
task verify: :environment do
|
|
88
|
-
|
|
88
|
+
# FAIL_ON_LEGACY_UNVERIFIABLE=1 — treat pre-cutover rows whose metadata
|
|
89
|
+
# key order cannot be reconstructed as failures (see
|
|
90
|
+
# AuditLog.verify_chain). KEY_ORDER_SEARCH_LIMIT=5040 — orderings tried
|
|
91
|
+
# per legacy row.
|
|
92
|
+
options = {}
|
|
93
|
+
options[:fail_on_legacy_unverifiable] = true if %w[1 true].include?(ENV["FAIL_ON_LEGACY_UNVERIFIABLE"])
|
|
94
|
+
if (limit = ENV["KEY_ORDER_SEARCH_LIMIT"].presence)
|
|
95
|
+
options[:key_order_search_limit] = Integer(limit, 10)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
result = StandardAudit::AuditLog.verify_chain(**options)
|
|
89
99
|
|
|
90
100
|
puts "Audit Log Chain Verification"
|
|
91
101
|
puts "============================="
|
|
102
|
+
puts "Canonical checksums since: #{StandardAudit.config.canonical_checksum_since.utc.iso8601}"
|
|
92
103
|
puts "Records verified: #{result[:verified]}"
|
|
93
104
|
puts "Chain valid: #{result[:valid]}"
|
|
94
105
|
puts "Forked links recovered: #{result[:recovered]}"
|
|
106
|
+
puts "Legacy rows verified by key-order reconstruction: #{result[:reordered]}" if result[:reordered].to_i.positive?
|
|
95
107
|
puts "Anonymized (redacted) records: #{result[:redacted]}" if result[:redacted].to_i.positive?
|
|
108
|
+
if result[:legacy_unverifiable].to_i.positive?
|
|
109
|
+
puts "Legacy rows unverifiable (metadata key order lost): #{result[:legacy_unverifiable]} " \
|
|
110
|
+
"— cannot be proven either way; this count must not grow after the cutover"
|
|
111
|
+
end
|
|
96
112
|
|
|
97
113
|
if result[:failures].any?
|
|
98
114
|
puts "\nUnverifiable records detected: #{result[:failures].size}"
|
|
115
|
+
result[:failures].map { |failure| failure[:reason] }.tally.each { |reason, n| puts " #{reason}: #{n}" }
|
|
99
116
|
result[:failures].each do |failure|
|
|
100
117
|
puts " #{failure[:id]} (#{failure[:event_type]}) at #{failure[:created_at]} — #{failure[:reason]}"
|
|
101
118
|
end
|
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.
|
|
4
|
+
version: 0.13.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Jaryl Sim
|
|
@@ -15,42 +15,42 @@ dependencies:
|
|
|
15
15
|
requirements:
|
|
16
16
|
- - ">="
|
|
17
17
|
- !ruby/object:Gem::Version
|
|
18
|
-
version: '8.
|
|
18
|
+
version: '8.1'
|
|
19
19
|
type: :runtime
|
|
20
20
|
prerelease: false
|
|
21
21
|
version_requirements: !ruby/object:Gem::Requirement
|
|
22
22
|
requirements:
|
|
23
23
|
- - ">="
|
|
24
24
|
- !ruby/object:Gem::Version
|
|
25
|
-
version: '8.
|
|
25
|
+
version: '8.1'
|
|
26
26
|
- !ruby/object:Gem::Dependency
|
|
27
27
|
name: activejob
|
|
28
28
|
requirement: !ruby/object:Gem::Requirement
|
|
29
29
|
requirements:
|
|
30
30
|
- - ">="
|
|
31
31
|
- !ruby/object:Gem::Version
|
|
32
|
-
version: '8.
|
|
32
|
+
version: '8.1'
|
|
33
33
|
type: :runtime
|
|
34
34
|
prerelease: false
|
|
35
35
|
version_requirements: !ruby/object:Gem::Requirement
|
|
36
36
|
requirements:
|
|
37
37
|
- - ">="
|
|
38
38
|
- !ruby/object:Gem::Version
|
|
39
|
-
version: '8.
|
|
39
|
+
version: '8.1'
|
|
40
40
|
- !ruby/object:Gem::Dependency
|
|
41
41
|
name: activesupport
|
|
42
42
|
requirement: !ruby/object:Gem::Requirement
|
|
43
43
|
requirements:
|
|
44
44
|
- - ">="
|
|
45
45
|
- !ruby/object:Gem::Version
|
|
46
|
-
version: '8.
|
|
46
|
+
version: '8.1'
|
|
47
47
|
type: :runtime
|
|
48
48
|
prerelease: false
|
|
49
49
|
version_requirements: !ruby/object:Gem::Requirement
|
|
50
50
|
requirements:
|
|
51
51
|
- - ">="
|
|
52
52
|
- !ruby/object:Gem::Version
|
|
53
|
-
version: '8.
|
|
53
|
+
version: '8.1'
|
|
54
54
|
- !ruby/object:Gem::Dependency
|
|
55
55
|
name: globalid
|
|
56
56
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -96,20 +96,20 @@ files:
|
|
|
96
96
|
- app/jobs/standard_audit/create_audit_log_job.rb
|
|
97
97
|
- app/models/standard_audit/application_record.rb
|
|
98
98
|
- app/models/standard_audit/audit_log.rb
|
|
99
|
-
- config/routes.rb
|
|
100
99
|
- lib/generators/standard_audit/add_anonymized_at/add_anonymized_at_generator.rb
|
|
101
100
|
- lib/generators/standard_audit/add_anonymized_at/templates/add_anonymized_at_to_audit_logs.rb.erb
|
|
102
|
-
- lib/generators/standard_audit/add_checksums/add_checksums_generator.rb
|
|
103
|
-
- lib/generators/standard_audit/add_checksums/templates/add_checksum_to_audit_logs.rb.erb
|
|
104
101
|
- lib/generators/standard_audit/add_previous_checksum/add_previous_checksum_generator.rb
|
|
105
102
|
- lib/generators/standard_audit/add_previous_checksum/templates/add_previous_checksum_to_audit_logs.rb.erb
|
|
106
103
|
- lib/generators/standard_audit/install/install_generator.rb
|
|
107
104
|
- lib/generators/standard_audit/install/templates/create_audit_logs.rb.erb
|
|
108
105
|
- lib/generators/standard_audit/install/templates/initializer.rb.erb
|
|
106
|
+
- lib/generators/standard_audit/migration_number.rb
|
|
109
107
|
- lib/standard_audit.rb
|
|
110
108
|
- lib/standard_audit/audit_scope.rb
|
|
111
109
|
- lib/standard_audit/auditable.rb
|
|
112
110
|
- lib/standard_audit/checks/retention.rb
|
|
111
|
+
- lib/standard_audit/checksum.rb
|
|
112
|
+
- lib/standard_audit/checksum/key_order_search.rb
|
|
113
113
|
- lib/standard_audit/configuration.rb
|
|
114
114
|
- lib/standard_audit/engine.rb
|
|
115
115
|
- lib/standard_audit/event_subscriber.rb
|
|
@@ -148,7 +148,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
148
148
|
- !ruby/object:Gem::Version
|
|
149
149
|
version: '0'
|
|
150
150
|
requirements: []
|
|
151
|
-
rubygems_version: 4.0.
|
|
151
|
+
rubygems_version: 4.0.10
|
|
152
152
|
specification_version: 4
|
|
153
153
|
summary: Database-backed audit logging for Rails via Rails.event and ActiveSupport::Notifications.
|
|
154
154
|
test_files: []
|
data/config/routes.rb
DELETED