reeve 0.5.0 → 0.7.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 +4 -4
- data/CHANGELOG.md +281 -3
- data/README.md +151 -6
- data/lib/generators/reeve/install/templates/create_audit_entries.rb.tt +63 -3
- data/lib/generators/reeve/install/templates/initializer.rb.tt +20 -0
- data/lib/generators/reeve/upgrade/templates/add_key_epochs_to_reeve_audit_entries.rb.tt +35 -0
- data/lib/generators/reeve/upgrade/templates/add_ledger_chain_to_reeve_audit_entries.rb.tt +51 -0
- data/lib/generators/reeve/upgrade/upgrade_generator.rb +25 -2
- data/lib/generators/reeve/upgrade/upgrade_notes.rb +51 -0
- data/lib/reeve/audit/chain/canonical.rb +144 -0
- data/lib/reeve/audit/chain/epoch_rules.rb +159 -0
- data/lib/reeve/audit/chain/epochs.rb +77 -0
- data/lib/reeve/audit/chain/head.rb +122 -0
- data/lib/reeve/audit/chain/keyring.rb +53 -0
- data/lib/reeve/audit/chain/normalizer.rb +88 -0
- data/lib/reeve/audit/chain/report.rb +177 -0
- data/lib/reeve/audit/chain/rotation.rb +124 -0
- data/lib/reeve/audit/chain/schema.rb +59 -0
- data/lib/reeve/audit/chain/verification.rb +131 -0
- data/lib/reeve/audit/chain/verifier.rb +168 -0
- data/lib/reeve/audit/chain/walk.rb +137 -0
- data/lib/reeve/audit/chain.rb +207 -0
- data/lib/reeve/audit/chain_epoch.rb +24 -0
- data/lib/reeve/audit/chain_record.rb +66 -0
- data/lib/reeve/audit/isolated_recorder.rb +5 -0
- data/lib/reeve/audit/recorder.rb +32 -19
- data/lib/reeve/audit.rb +37 -6
- data/lib/reeve/configuration.rb +66 -2
- data/lib/reeve/errors.rb +15 -0
- data/lib/reeve/ledger_key.rb +47 -0
- data/lib/reeve/railtie.rb +16 -0
- data/lib/reeve/tasks.rb +91 -0
- data/lib/reeve/testing/checks/contract_version.rb +2 -1
- data/lib/reeve/testing/checks/ledger_chain.rb +116 -0
- data/lib/reeve/testing/checks.rb +29 -4
- data/lib/reeve/testing/compliance_assertions.rb +3 -2
- data/lib/reeve/testing/compliance_suite.rb +2 -2
- data/lib/reeve/testing/ledger.rb +11 -1
- data/lib/reeve/testing/report.rb +8 -0
- data/lib/reeve/testing.rb +1 -1
- data/lib/reeve/version.rb +1 -1
- data/lib/reeve.rb +2 -0
- metadata +24 -2
data/lib/reeve/audit/recorder.rb
CHANGED
|
@@ -10,9 +10,10 @@ module Reeve
|
|
|
10
10
|
# ActiveJob. An asynchronous ledger cannot be relied on and would make FR-012
|
|
11
11
|
# unenforceable.
|
|
12
12
|
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
13
|
+
# Every row goes through Chain#append! (specs/002), which links it to the row before it
|
|
14
|
+
# and runs the insert in `requires_new: true` — what makes the trace survive a tool body
|
|
15
|
+
# that opens a transaction and rolls it back (R5), the case this was built for and the
|
|
16
|
+
# one it does solve.
|
|
16
17
|
#
|
|
17
18
|
# What it does **not** do, corrected after review: `requires_new` is a SAVEPOINT, not
|
|
18
19
|
# an independent transaction. If the host has already opened a transaction *around*
|
|
@@ -54,20 +55,28 @@ module Reeve
|
|
|
54
55
|
|
|
55
56
|
# Returns the entry. Raises if the row could not be written, so the envelope can
|
|
56
57
|
# fail the invocation.
|
|
58
|
+
#
|
|
59
|
+
# A replayed invocation is the same invocation: one row per invocation_id means a
|
|
60
|
+
# retry returns the existing row and never writes a second (contracts/audit-entry.md).
|
|
61
|
+
# Chain#append! owns that, because it must also consume no sequence number.
|
|
62
|
+
#
|
|
63
|
+
# With no ledger key (specs/002 FR-010): under :fail, Chain raises LedgerKeyError and
|
|
64
|
+
# the envelope fails the call like any other ledger failure. Under :warn the row is
|
|
65
|
+
# still written, at contract 3 and unchained, and the log says so every time —
|
|
66
|
+
# :warn keeps calls running, it does not drop traces, and verification reports the
|
|
67
|
+
# row as unchained rather than letting it pass unseen.
|
|
57
68
|
def record(attributes)
|
|
58
69
|
row = row_for(attributes)
|
|
59
70
|
warn_about_enclosing_transaction(row[:invocation_id])
|
|
60
71
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
rescue ActiveRecord::RecordNotUnique, ActiveRecord::RecordInvalid => e
|
|
65
|
-
# A replayed invocation is the same invocation. One row per invocation_id means
|
|
66
|
-
# a retry is a no-op insert, never a second row (contracts/audit-entry.md).
|
|
67
|
-
existing = entry_class.find_by(invocation_id: row[:invocation_id])
|
|
68
|
-
raise e if existing.nil?
|
|
72
|
+
keyring = Chain::Keyring.from(config)
|
|
73
|
+
chain = Chain.new(entry_class: entry_class, keyring: keyring)
|
|
74
|
+
return chain.append!(row) if keyring.signing? || config.audit_failure_mode == :fail
|
|
69
75
|
|
|
70
|
-
|
|
76
|
+
warn("reeve: invocation #{row[:invocation_id]} was recorded unchained: no ledger key is " \
|
|
77
|
+
"configured and audit_failure_mode is :warn. Verification will report the row as " \
|
|
78
|
+
"unchained. Set config.ledger_key to chain the ledger.")
|
|
79
|
+
chain.append_unchained!(row)
|
|
71
80
|
end
|
|
72
81
|
|
|
73
82
|
private
|
|
@@ -78,6 +87,11 @@ module Reeve
|
|
|
78
87
|
@config || Reeve.config
|
|
79
88
|
end
|
|
80
89
|
|
|
90
|
+
def warn(message)
|
|
91
|
+
logger = config.logger
|
|
92
|
+
logger ? logger.warn(message) : Kernel.warn(message)
|
|
93
|
+
end
|
|
94
|
+
|
|
81
95
|
def row_for(attributes)
|
|
82
96
|
ids, count, truncated = identifiers(attributes)
|
|
83
97
|
|
|
@@ -163,13 +177,12 @@ module Reeve
|
|
|
163
177
|
def warn_about_enclosing_transaction(invocation_id)
|
|
164
178
|
return unless enclosing_transaction?
|
|
165
179
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
logger ? logger.warn(message) : Kernel.warn(message)
|
|
180
|
+
warn("reeve: invocation #{invocation_id} was recorded inside a transaction " \
|
|
181
|
+
"the host opened around it, so the ledger row will be rolled back with " \
|
|
182
|
+
"it, and the ledger chain stays locked until it ends, so every other " \
|
|
183
|
+
"guarded call waits behind it. Set Reeve.config.audit_recorder = " \
|
|
184
|
+
"Reeve::Audit::IsolatedRecorder to write on a connection the transaction " \
|
|
185
|
+
"cannot reach (needs a database with concurrent writers; not SQLite).")
|
|
173
186
|
end
|
|
174
187
|
|
|
175
188
|
def enclosing_transaction?
|
data/lib/reeve/audit.rb
CHANGED
|
@@ -44,9 +44,14 @@ module Reeve
|
|
|
44
44
|
# documents the `GRANT INSERT, SELECT` that enforces the rest where it can actually be
|
|
45
45
|
# enforced, and the gem makes no stronger claim than that.
|
|
46
46
|
# * No retention, rotation or archival yet — the table is host-owned and the host's
|
|
47
|
-
# existing policies apply.
|
|
48
|
-
# *
|
|
49
|
-
#
|
|
47
|
+
# existing policies apply. Deleting rows breaks the chain, by design.
|
|
48
|
+
# * Tamper-evidence, not tamper-proofing. Every row is chained to the one before it
|
|
49
|
+
# (Reeve::Audit::Chain, contract 3), so a change made outside the library is reported
|
|
50
|
+
# by verification — after the fact; it prevents nothing. It does not detect a change
|
|
51
|
+
# made by someone holding the ledger key, the newest rows deleted by a role that can
|
|
52
|
+
# also rewind the chain record's head, the whole ledger deleted, or anything about
|
|
53
|
+
# rows written before contract 3. specs/001-guardrails-core/contracts/audit-entry.md,
|
|
54
|
+
# guarantee 9, is the authoritative statement.
|
|
50
55
|
#
|
|
51
56
|
# ("yet" rather than "in v1": the gem version and the audit-entry contract version are
|
|
52
57
|
# different numbers that move independently, and writing v1 for one of them read as the
|
|
@@ -54,14 +59,25 @@ module Reeve
|
|
|
54
59
|
module Audit
|
|
55
60
|
# The version of the audit-entry shape, as documented in
|
|
56
61
|
# specs/001-guardrails-core/contracts/audit-entry.md (FR-015). Adding a nullable
|
|
57
|
-
# column is a MINOR change and leaves this alone
|
|
58
|
-
#
|
|
62
|
+
# column is a MINOR change and leaves this alone — unless what its absence means, or
|
|
63
|
+
# what an existing value covers, differs between rows written before and after it;
|
|
64
|
+
# removing or renaming a column, or changing what a value means, is MAJOR and bumps it.
|
|
65
|
+
#
|
|
66
|
+
# 4 — entries name their key epoch (specs/003). One nullable column, `chain_epoch`, but
|
|
67
|
+
# it is inside the digest, so `chain_digest` on a contract-4 row covers something a
|
|
68
|
+
# contract-3 digest does not, and a NULL epoch means "before key epochs" on a contract-3
|
|
69
|
+
# row where a chained contract-4 row always has one.
|
|
70
|
+
#
|
|
71
|
+
# 3 — entries are chained (specs/002). The three chain columns are nullable, so the
|
|
72
|
+
# shape change is additive, but what a NULL digest means changed: on a contract-3 row
|
|
73
|
+
# it is a row written unchained, which verification reports, where on an older row it
|
|
74
|
+
# only means the row predates chaining.
|
|
59
75
|
#
|
|
60
76
|
# 2 — `metadata` carries the transport detail the caller passed. Through version 1 it
|
|
61
77
|
# was written NULL on every row regardless of what was passed, so anything mapping
|
|
62
78
|
# version 1 rows could reasonably have read the column as "always empty". The shape
|
|
63
79
|
# did not change; what a value means did.
|
|
64
|
-
CONTRACT_VERSION =
|
|
80
|
+
CONTRACT_VERSION = 4
|
|
65
81
|
|
|
66
82
|
TABLE_NAME = "reeve_audit_entries"
|
|
67
83
|
|
|
@@ -94,5 +110,20 @@ require_relative "audit/entry"
|
|
|
94
110
|
require_relative "audit/recorder"
|
|
95
111
|
require_relative "audit/isolated_recorder"
|
|
96
112
|
require_relative "audit/query"
|
|
113
|
+
require_relative "audit/chain/keyring"
|
|
114
|
+
require_relative "audit/chain/normalizer"
|
|
115
|
+
require_relative "audit/chain/canonical"
|
|
116
|
+
require_relative "audit/chain_record"
|
|
117
|
+
require_relative "audit/chain_epoch"
|
|
118
|
+
require_relative "audit/chain/verification"
|
|
119
|
+
require_relative "audit/chain/report"
|
|
120
|
+
require_relative "audit/chain/head"
|
|
121
|
+
require_relative "audit/chain/epochs"
|
|
122
|
+
require_relative "audit/chain/rotation"
|
|
123
|
+
require_relative "audit/chain/epoch_rules"
|
|
124
|
+
require_relative "audit/chain/schema"
|
|
125
|
+
require_relative "audit/chain/walk"
|
|
126
|
+
require_relative "audit/chain/verifier"
|
|
127
|
+
require_relative "audit/chain"
|
|
97
128
|
|
|
98
129
|
Reeve::Audit.install!
|
data/lib/reeve/configuration.rb
CHANGED
|
@@ -24,9 +24,13 @@ module Reeve
|
|
|
24
24
|
SETTINGS = %i[
|
|
25
25
|
principal_resolver unguarded_tools audit_failure_mode redact_arguments
|
|
26
26
|
max_recorded_ids policy_adapter default_action audit_recorder logger
|
|
27
|
-
compliance_principals compliance_tools inventory
|
|
27
|
+
compliance_principals compliance_tools inventory ledger_key retired_ledger_keys
|
|
28
28
|
].freeze
|
|
29
29
|
|
|
30
|
+
# Shown by #to_h and #inspect as fingerprints. A configuration object gets printed —
|
|
31
|
+
# in a console, a bug report, a failing spec — and a secret must not travel with it.
|
|
32
|
+
SECRET_SETTINGS = %i[ledger_key retired_ledger_keys].freeze
|
|
33
|
+
|
|
30
34
|
attr_reader(*SETTINGS)
|
|
31
35
|
|
|
32
36
|
def initialize
|
|
@@ -42,6 +46,47 @@ module Reeve
|
|
|
42
46
|
@compliance_principals = nil
|
|
43
47
|
@compliance_tools = nil
|
|
44
48
|
@inventory = nil
|
|
49
|
+
@ledger_key = nil
|
|
50
|
+
@retired_ledger_keys = []
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# The key that signs the audit ledger's chain (specs/002). Held in configuration, never
|
|
54
|
+
# in the database: that separation is the whole of the tamper-evidence guarantee.
|
|
55
|
+
# Validated here so a short key fails on the initializer line that set it.
|
|
56
|
+
#
|
|
57
|
+
# config.ledger_key = Rails.application.key_generator.generate_key("reeve audit ledger", 32)
|
|
58
|
+
def ledger_key=(secret)
|
|
59
|
+
unless secret.nil?
|
|
60
|
+
LedgerKey.validate!(secret, :ledger_key)
|
|
61
|
+
if @retired_ledger_keys.include?(secret)
|
|
62
|
+
raise ArgumentError, "ledger_key is also listed in retired_ledger_keys; a key " \
|
|
63
|
+
"either signs or is retired, not both"
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
@ledger_key = secret
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Keys that signed older entries, kept so those entries still verify. Never used to
|
|
71
|
+
# sign. Removing one leaves the entries it signed unverifiable — reported as such,
|
|
72
|
+
# never as tampered.
|
|
73
|
+
def retired_ledger_keys=(secrets)
|
|
74
|
+
unless secrets.is_a?(Array)
|
|
75
|
+
raise ArgumentError, "retired_ledger_keys must be an Array of keys, got a #{secrets.class}"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
secrets.each { |secret| LedgerKey.validate!(secret, :retired_ledger_keys) }
|
|
79
|
+
if !@ledger_key.nil? && secrets.include?(@ledger_key)
|
|
80
|
+
raise ArgumentError, "retired_ledger_keys includes the current ledger_key; a key " \
|
|
81
|
+
"either signs or is retired, not both"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
@retired_ledger_keys = secrets.dup.freeze
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# The fingerprint stored on every row this configuration signs, or nil with no key.
|
|
88
|
+
def ledger_key_id
|
|
89
|
+
@ledger_key && LedgerKey.fingerprint(@ledger_key)
|
|
45
90
|
end
|
|
46
91
|
|
|
47
92
|
# The host's whole tool inventory (Reeve::Inventory). Optional. Setting it is what lets
|
|
@@ -163,11 +208,30 @@ module Reeve
|
|
|
163
208
|
end
|
|
164
209
|
|
|
165
210
|
def to_h
|
|
166
|
-
SETTINGS.to_h { |setting| [setting,
|
|
211
|
+
SETTINGS.to_h { |setting| [setting, shown(setting)] }
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def inspect
|
|
215
|
+
"#<#{self.class.name} #{to_h.map do |setting, value|
|
|
216
|
+
"#{setting}=#{value.inspect}"
|
|
217
|
+
end.join(', ')}>"
|
|
167
218
|
end
|
|
168
219
|
|
|
169
220
|
private
|
|
170
221
|
|
|
222
|
+
def shown(setting)
|
|
223
|
+
value = public_send(setting)
|
|
224
|
+
return value unless SECRET_SETTINGS.include?(setting)
|
|
225
|
+
|
|
226
|
+
if value.is_a?(Array)
|
|
227
|
+
value.map do |secret|
|
|
228
|
+
LedgerKey.fingerprint(secret)
|
|
229
|
+
end
|
|
230
|
+
else
|
|
231
|
+
value && LedgerKey.fingerprint(value)
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
|
|
171
235
|
def require_one_of!(setting, value, allowed)
|
|
172
236
|
symbol = value.is_a?(Symbol) ? value : nil
|
|
173
237
|
return symbol if allowed.include?(symbol)
|
data/lib/reeve/errors.rb
CHANGED
|
@@ -70,6 +70,21 @@ module Reeve
|
|
|
70
70
|
end
|
|
71
71
|
end
|
|
72
72
|
|
|
73
|
+
# Raised when a chained ledger write or a verification has no key to use (specs/002
|
|
74
|
+
# FR-010). On the invocation path the envelope wraps it in AuditWriteError, exactly as it
|
|
75
|
+
# wraps any other ledger failure, so a host rescues one class either way.
|
|
76
|
+
class LedgerKeyError < Error
|
|
77
|
+
def initialize(message = nil)
|
|
78
|
+
super(message || <<~MESSAGE.chomp)
|
|
79
|
+
no ledger key is configured, so the audit ledger cannot be chained. Set one in
|
|
80
|
+
config/initializers/reeve.rb — derived from secret_key_base, it works in every
|
|
81
|
+
environment with no new secret:
|
|
82
|
+
|
|
83
|
+
config.ledger_key = Rails.application.key_generator.generate_key("reeve audit ledger", 32)
|
|
84
|
+
MESSAGE
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
73
88
|
# Raised when the ledger write fails and audit_failure_mode is :fail (FR-012).
|
|
74
89
|
#
|
|
75
90
|
# It carries a rule like any other denial, because the failure is not recorded anywhere
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reeve
|
|
4
|
+
# What makes a ledger key acceptable, and the name a key goes by (specs/002, research R7).
|
|
5
|
+
#
|
|
6
|
+
# Core rather than under reeve/audit, because configuration validates keys at assignment
|
|
7
|
+
# and configuration loads without ActiveRecord. Validation needs nothing beyond Ruby;
|
|
8
|
+
# `openssl` is required only when a fingerprint is computed, so `require "reeve"` stays on
|
|
9
|
+
# its short stdlib allowlist (spec/load_safety_spec.rb) and a host that never configures
|
|
10
|
+
# a key never loads it.
|
|
11
|
+
module LedgerKey
|
|
12
|
+
# HMAC-SHA256 takes a key of any length, but one shorter than the hash's own output
|
|
13
|
+
# weakens it. Bytes, not characters: that is what the HMAC sees.
|
|
14
|
+
MINIMUM_BYTES = 32
|
|
15
|
+
|
|
16
|
+
FINGERPRINT_LABEL = "reeve-ledger-key-id"
|
|
17
|
+
FINGERPRINT_LENGTH = 16
|
|
18
|
+
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
# The identifier stored on each chained row in place of the key. Derived from the key
|
|
22
|
+
# rather than chosen by the host, so an identifier can never be pinned to the wrong
|
|
23
|
+
# secret, and a renamed key cannot orphan the history it signed. An HMAC output, so it
|
|
24
|
+
# says nothing about the key it names.
|
|
25
|
+
def fingerprint(secret)
|
|
26
|
+
require "openssl"
|
|
27
|
+
OpenSSL::HMAC.hexdigest("SHA256", secret, FINGERPRINT_LABEL)[0, FINGERPRINT_LENGTH]
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Raises ArgumentError naming the setting and the rule. Never the value: a key that is
|
|
31
|
+
# too short is still a secret, and error messages end up in logs.
|
|
32
|
+
def validate!(secret, setting)
|
|
33
|
+
unless secret.is_a?(String)
|
|
34
|
+
raise ArgumentError, "#{setting} must be a String of at least #{MINIMUM_BYTES} " \
|
|
35
|
+
"bytes, got a #{secret.class}"
|
|
36
|
+
end
|
|
37
|
+
if secret.strip.empty?
|
|
38
|
+
raise ArgumentError, "#{setting} must not be blank; it must be at least " \
|
|
39
|
+
"#{MINIMUM_BYTES} bytes"
|
|
40
|
+
end
|
|
41
|
+
return secret if secret.bytesize >= MINIMUM_BYTES
|
|
42
|
+
|
|
43
|
+
raise ArgumentError, "#{setting} must be at least #{MINIMUM_BYTES} bytes, got " \
|
|
44
|
+
"#{secret.bytesize}"
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reeve
|
|
4
|
+
# Registers `bin/rails reeve:verify_ledger` and `bin/rails reeve:rotate_ledger_key`, and
|
|
5
|
+
# does nothing else.
|
|
6
|
+
#
|
|
7
|
+
# Loaded by lib/reeve.rb only when Rails::Railtie is already defined, so `require "reeve"`
|
|
8
|
+
# in a plain Ruby process still loads nothing beyond reeve's own files and its short stdlib
|
|
9
|
+
# allowlist (spec/load_safety_spec.rb).
|
|
10
|
+
class Railtie < ::Rails::Railtie
|
|
11
|
+
rake_tasks do
|
|
12
|
+
require "reeve/tasks"
|
|
13
|
+
Reeve::Tasks.define
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
data/lib/reeve/tasks.rb
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Reeve
|
|
6
|
+
# `bin/rails reeve:verify_ledger` (specs/002 FR-017, contracts/ledger-chain.md "Rake").
|
|
7
|
+
#
|
|
8
|
+
# A Ruby file rather than a .rake one on purpose: reeve.gemspec packages lib/**/*.rb, and a
|
|
9
|
+
# .rake file would pass every spec in the working tree and be missing from the gem users
|
|
10
|
+
# install. Registered by Reeve::Railtie; callable without Rake for anything else that
|
|
11
|
+
# wants the same answer — a deploy gate, a scheduled job, a console.
|
|
12
|
+
#
|
|
13
|
+
# status = Reeve::Tasks.verify_ledger(env: { "LIMIT" => "0" })
|
|
14
|
+
module Tasks
|
|
15
|
+
EXIT = { intact: 0, broken: 1, unverifiable: 2, no_chain: 2 }.freeze
|
|
16
|
+
FORMATS = %w[text json].freeze
|
|
17
|
+
ROTATE_EXIT = { rotated: 0, current: 0, refused: 1 }.freeze
|
|
18
|
+
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
# Verifies the ledger, writes the report to `out`, and returns the exit status:
|
|
22
|
+
# 0 intact, 1 broken, 2 could not verify (unverifiable, or no chain).
|
|
23
|
+
#
|
|
24
|
+
# LIMIT caps the breaks listed in the text report (default 100; the totals are never
|
|
25
|
+
# capped). FORMAT=json writes the whole result, every break, and ignores LIMIT.
|
|
26
|
+
def verify_ledger(env: ENV, out: $stdout)
|
|
27
|
+
format = env.fetch("FORMAT", "text")
|
|
28
|
+
unless FORMATS.include?(format)
|
|
29
|
+
raise ArgumentError, "FORMAT must be one of #{FORMATS.join(', ')}, got #{format.inspect}"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
limit = limit_from(env)
|
|
33
|
+
require "reeve/audit"
|
|
34
|
+
result = Reeve::Audit::Chain.verify
|
|
35
|
+
out.puts(format == "json" ? JSON.generate(result.as_json) : result.to_s(limit: limit))
|
|
36
|
+
EXIT.fetch(result.status)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Makes the configured ledger key the latest epoch's key (specs/003, contracts/
|
|
40
|
+
# key-rotation.md), writes what happened to `out`, and returns the exit status: 0 rotated
|
|
41
|
+
# or already current, 1 refused.
|
|
42
|
+
#
|
|
43
|
+
# ACCEPT_UNVERIFIABLE=1 opens the epoch even though the outgoing epoch's key is not
|
|
44
|
+
# configured here — for a key that is lost, and only then.
|
|
45
|
+
def rotate_ledger_key(env: ENV, out: $stdout)
|
|
46
|
+
accept = accept_unverifiable_from(env)
|
|
47
|
+
require "reeve/audit"
|
|
48
|
+
rotation = Reeve::Audit::Chain.rotate_key!(accept_unverifiable: accept)
|
|
49
|
+
out.puts(rotation.to_s)
|
|
50
|
+
ROTATE_EXIT.fetch(rotation.status)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def accept_unverifiable_from(env)
|
|
54
|
+
raw = env.fetch("ACCEPT_UNVERIFIABLE", "")
|
|
55
|
+
return false if raw.empty?
|
|
56
|
+
return true if raw == "1"
|
|
57
|
+
|
|
58
|
+
raise ArgumentError, "ACCEPT_UNVERIFIABLE must be 1 or unset, got #{raw.inspect}"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def limit_from(env)
|
|
62
|
+
raw = env.fetch("LIMIT", Reeve::Audit::Chain::Verification::DEFAULT_LIMIT.to_s)
|
|
63
|
+
Integer(raw, 10).tap { |limit| raise ArgumentError if limit.negative? }
|
|
64
|
+
rescue ArgumentError, TypeError
|
|
65
|
+
raise ArgumentError, "LIMIT must be a whole number of breaks to list, got #{raw.inspect}"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# The Rake task, defined on the current Rake application. Depends on :environment, so
|
|
69
|
+
# the host's initializer — and with it the ledger key — is loaded first.
|
|
70
|
+
#
|
|
71
|
+
# The DSL is borrowed by a throwaway object, so Reeve::Tasks itself never gains `task`,
|
|
72
|
+
# `sh` and the rest of Rake's methods.
|
|
73
|
+
def define
|
|
74
|
+
Object.new.extend(Rake::DSL).instance_exec do
|
|
75
|
+
namespace :reeve do
|
|
76
|
+
desc "Verify the audit ledger's chain " \
|
|
77
|
+
"(exit 0 intact, 1 broken, 2 could not verify; LIMIT=, FORMAT=json)"
|
|
78
|
+
task verify_ledger: :environment do
|
|
79
|
+
exit Reeve::Tasks.verify_ledger
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
desc "Make config.ledger_key the ledger's current key epoch (exit 0 rotated or " \
|
|
83
|
+
"current, 1 refused; ACCEPT_UNVERIFIABLE=1 if the old key is lost)"
|
|
84
|
+
task rotate_ledger_key: :environment do
|
|
85
|
+
exit Reeve::Tasks.rotate_ledger_key
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -46,9 +46,10 @@ module Reeve
|
|
|
46
46
|
invocation_id occurred_at agent_id agent_name principal_type principal_id
|
|
47
47
|
tool_name arguments outcome rule detail record_type record_ids record_count
|
|
48
48
|
truncated derived guard duration_ms metadata contract_version
|
|
49
|
+
chain_sequence chain_key_id chain_digest chain_epoch
|
|
49
50
|
].freeze
|
|
50
51
|
|
|
51
|
-
EXPECTED_VERSION =
|
|
52
|
+
EXPECTED_VERSION = 4
|
|
52
53
|
|
|
53
54
|
def initialize(tool: nil, expected: EXPECTED_VERSION, ledger: nil)
|
|
54
55
|
super(tool: tool, ledger: ledger)
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reeve
|
|
4
|
+
module Testing
|
|
5
|
+
module Checks
|
|
6
|
+
# Whether the ledger's chain holds (specs/002 FR-018).
|
|
7
|
+
#
|
|
8
|
+
# It writes entries of its own first — the first compliance tool, once per fixture
|
|
9
|
+
# principal — because a suite with transactional fixtures starts every example with
|
|
10
|
+
# an empty ledger, and a check that only looked would find nothing and say nothing.
|
|
11
|
+
# Then it verifies the whole ledger the run can see, not only its own rows: a break an
|
|
12
|
+
# earlier run left behind is reported, not hidden.
|
|
13
|
+
#
|
|
14
|
+
# * **intact** — passed; the message is the verification's own summary line, so it
|
|
15
|
+
# states the range covered and what was not.
|
|
16
|
+
# * **broken, unverifiable, or no chain after writing** — failed. The run signed its own
|
|
17
|
+
# entries, so a key it lacks or a recorder that did not chain is a configuration
|
|
18
|
+
# error, not an absence.
|
|
19
|
+
# * **no key configured** — failed, naming the setting.
|
|
20
|
+
# * **nothing to write or read** (no compliance tools, ledger not loaded) — skipped,
|
|
21
|
+
# saying the chain was not verified. Never passed.
|
|
22
|
+
#
|
|
23
|
+
# Reeve::Checks::LedgerChain.new.call
|
|
24
|
+
class LedgerChain < Base
|
|
25
|
+
NOT_VERIFIED = "the ledger chain was not verified"
|
|
26
|
+
|
|
27
|
+
def initialize(tools: nil, principals: nil, arguments: {}, invoke: nil, ledger: nil)
|
|
28
|
+
@tools = tools
|
|
29
|
+
@principals = principals
|
|
30
|
+
super(tool: nil, arguments: arguments, invoke: invoke, ledger: ledger)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def call
|
|
34
|
+
return skipped("there is no compliance tool to write an entry with") if subject.nil?
|
|
35
|
+
return skipped(unreadable_reason) unless readable?
|
|
36
|
+
return no_key if Reeve.config.ledger_key.nil?
|
|
37
|
+
|
|
38
|
+
unwritten = write_entries
|
|
39
|
+
return unwritten_entry(unwritten) if unwritten
|
|
40
|
+
|
|
41
|
+
judge(ledger.verify_chain)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def subject
|
|
47
|
+
@subject ||= Array(@tools || Testing.compliance_tools).first
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def principals
|
|
51
|
+
configured = Testing.compliance_principals? ? Testing.compliance_principals : nil
|
|
52
|
+
list = Array(@principals || configured)
|
|
53
|
+
list.empty? ? [nil] : list
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Each principal on its own, so one refusal does not stop the next. A denial, or the
|
|
57
|
+
# tool's own error, still leaves a ledger entry — the envelope records before it
|
|
58
|
+
# re-raises — which is all this needs. A failed ledger write leaves none, and is
|
|
59
|
+
# returned as the reason the chain could not be checked.
|
|
60
|
+
#
|
|
61
|
+
# Returns nil when entries were written, or the reason none were — including the case
|
|
62
|
+
# where every invocation failed before reaching reeve, which a chain left by an
|
|
63
|
+
# earlier run must not be allowed to pass for.
|
|
64
|
+
def write_entries
|
|
65
|
+
before = ledger.count
|
|
66
|
+
principals.each do |principal|
|
|
67
|
+
invoke.call(tool: subject, arguments: arguments, principal: principal)
|
|
68
|
+
rescue Reeve::AuditWriteError => e
|
|
69
|
+
return "#{e.class}: #{e.message}"
|
|
70
|
+
rescue StandardError
|
|
71
|
+
next
|
|
72
|
+
end
|
|
73
|
+
ledger.count == before ? "its invocations wrote no entry" : nil
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def readable?
|
|
77
|
+
ledger.available? && ledger.respond_to?(:verify_chain)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def unwritten_entry(reason)
|
|
81
|
+
failed("#{NOT_VERIFIED}: it wrote no entry of its own — #{reason}",
|
|
82
|
+
coverage: :unwritten)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def unreadable_reason
|
|
86
|
+
ledger.unavailable_reason || "this ledger cannot verify a chain"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def judge(verification)
|
|
90
|
+
return skipped("the audit ledger is not loaded") if verification.nil?
|
|
91
|
+
|
|
92
|
+
summary = verification.to_s
|
|
93
|
+
return passed(summary.lines.first.chomp, coverage: :intact) if verification.intact?
|
|
94
|
+
|
|
95
|
+
failed(summary, coverage: verification.status)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def no_key
|
|
99
|
+
failed("expected the ledger to be chained, but no ledger key is configured: set " \
|
|
100
|
+
"config.ledger_key (the generated initializer derives one from secret_key_base)",
|
|
101
|
+
coverage: :no_key)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Every skip says, first, that the chain was not verified — then why.
|
|
105
|
+
def skipped(reason)
|
|
106
|
+
Result.skipped(check: check_name, message: "#{NOT_VERIFIED}: #{reason}",
|
|
107
|
+
details: { coverage: :unverified })
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def base_details
|
|
111
|
+
{}
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
data/lib/reeve/testing/checks.rb
CHANGED
|
@@ -9,6 +9,7 @@ require_relative "checks/redaction_holds"
|
|
|
9
9
|
require_relative "checks/principal_required"
|
|
10
10
|
require_relative "checks/contract_version"
|
|
11
11
|
require_relative "checks/endpoint_coverage"
|
|
12
|
+
require_relative "checks/ledger_chain"
|
|
12
13
|
|
|
13
14
|
module Reeve
|
|
14
15
|
module Testing
|
|
@@ -19,7 +20,7 @@ module Reeve
|
|
|
19
20
|
# the same violation reads identically from either — and from neither:
|
|
20
21
|
#
|
|
21
22
|
# report = Reeve::Checks.run_all(principals: [alice, bob])
|
|
22
|
-
# abort report.to_s
|
|
23
|
+
# abort report.to_s if report.failed? || report.nothing_checked?
|
|
23
24
|
#
|
|
24
25
|
# Nothing here loads a test framework (FR-026).
|
|
25
26
|
module Checks
|
|
@@ -31,12 +32,13 @@ module Reeve
|
|
|
31
32
|
RedactionHolds,
|
|
32
33
|
PrincipalRequired,
|
|
33
34
|
ContractVersion,
|
|
34
|
-
EndpointCoverage
|
|
35
|
+
EndpointCoverage,
|
|
36
|
+
LedgerChain
|
|
35
37
|
].freeze
|
|
36
38
|
|
|
37
39
|
# The checks that are asked once — about the ledger, or about the endpoint as a
|
|
38
40
|
# whole — rather than once per tool.
|
|
39
|
-
GLOBAL = [ContractVersion, EndpointCoverage].freeze
|
|
41
|
+
GLOBAL = [ContractVersion, EndpointCoverage, LedgerChain].freeze
|
|
40
42
|
|
|
41
43
|
# The compliance suite's engine (FR-018): every check, against every registered
|
|
42
44
|
# guarded tool, in one Report.
|
|
@@ -58,10 +60,12 @@ module Reeve
|
|
|
58
60
|
def self.run(check, principals:, tools: nil, arguments: {}, invoke: nil, ledger: nil)
|
|
59
61
|
if GLOBAL.include?(check)
|
|
60
62
|
return Report.new([build(check, tool: nil, principals: principals, tools: tools,
|
|
61
|
-
ledger: ledger).call])
|
|
63
|
+
invoke: invoke, ledger: ledger).call])
|
|
62
64
|
end
|
|
63
65
|
|
|
64
66
|
subjects = tools || Testing.compliance_tools
|
|
67
|
+
return Report.new([nothing_to_check(check, tools)]) if subjects.empty?
|
|
68
|
+
|
|
65
69
|
Report.new(
|
|
66
70
|
subjects.map do |tool|
|
|
67
71
|
build(check, tool: tool, principals: principals, arguments: arguments,
|
|
@@ -70,6 +74,25 @@ module Reeve
|
|
|
70
74
|
)
|
|
71
75
|
end
|
|
72
76
|
|
|
77
|
+
# A per-tool check over no tools establishes nothing, and an empty Report reads as
|
|
78
|
+
# passed — the green that means "did not look". So an empty population is one skip,
|
|
79
|
+
# saying which of the three ways the run came to have nothing in it.
|
|
80
|
+
def self.nothing_to_check(check, tools)
|
|
81
|
+
reason =
|
|
82
|
+
if !tools.nil? then "the run was given tools: []"
|
|
83
|
+
elsif Testing.compliance_tools_narrowed? then "compliance_tools is []"
|
|
84
|
+
else "the registry is empty and no endpoint inventory routes a tool"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
Result.skipped(
|
|
88
|
+
check: check.check_name,
|
|
89
|
+
message: "no compliance tools to check, so #{check.check_name} established " \
|
|
90
|
+
"nothing — #{reason}",
|
|
91
|
+
details: { coverage: :none }
|
|
92
|
+
)
|
|
93
|
+
end
|
|
94
|
+
private_class_method :nothing_to_check
|
|
95
|
+
|
|
73
96
|
# Every per-tool check for one tool, instantiated but not run.
|
|
74
97
|
def self.for_tool(tool, principals:, arguments: {}, invoke: nil, ledger: nil)
|
|
75
98
|
(ALL - GLOBAL).map do |check|
|
|
@@ -91,6 +114,8 @@ module Reeve
|
|
|
91
114
|
when "GuardDeclared" then GuardDeclared.new(tool: tool, ledger: ledger)
|
|
92
115
|
when "ContractVersion" then ContractVersion.new(ledger: ledger)
|
|
93
116
|
when "EndpointCoverage" then EndpointCoverage.new(tools: tools, ledger: ledger)
|
|
117
|
+
when "LedgerChain" then LedgerChain.new(tools: tools, principals: principals,
|
|
118
|
+
invoke: invoke, ledger: ledger)
|
|
94
119
|
when "PrincipalRequired" then PrincipalRequired.new(**common)
|
|
95
120
|
when "CrossPrincipalLeak" then CrossPrincipalLeak.new(principals: Array(principals),
|
|
96
121
|
**common)
|
|
@@ -15,8 +15,9 @@ module Reeve
|
|
|
15
15
|
#
|
|
16
16
|
# One method per check, matching the RSpec shared example group one for one, so a red
|
|
17
17
|
# build names the guarantee that broke rather than reporting "compliance" as one
|
|
18
|
-
# undifferentiated failure. A check that could establish nothing either way
|
|
19
|
-
# skipped with its
|
|
18
|
+
# undifferentiated failure. A check that could establish nothing either way — no
|
|
19
|
+
# inventory declared, or no tools to run a per-tool check over — is skipped with its
|
|
20
|
+
# reason, never passed.
|
|
20
21
|
module ComplianceAssertions
|
|
21
22
|
include Assertions
|
|
22
23
|
|
|
@@ -12,8 +12,8 @@
|
|
|
12
12
|
#
|
|
13
13
|
# One example per check rather than one for the lot, so a red build names the guarantee
|
|
14
14
|
# that broke — "CrossPrincipalLeak" is an incident, "compliance" is a shrug. A check that
|
|
15
|
-
# could establish nothing either way (EndpointCoverage with no inventory
|
|
16
|
-
# its reason, rather than passed.
|
|
15
|
+
# could establish nothing either way (EndpointCoverage with no inventory, or a per-tool
|
|
16
|
+
# check with no tools to run over) is skipped, with its reason, rather than passed.
|
|
17
17
|
RSpec.shared_examples "a reeve-compliant server" do
|
|
18
18
|
Reeve::Checks::ALL.each do |check|
|
|
19
19
|
it "satisfies #{check.check_name} for every registered guarded tool" do
|