reeve 0.4.0 → 0.6.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 +176 -1
- data/README.md +202 -12
- data/lib/generators/reeve/install/templates/create_audit_entries.rb.tt +41 -3
- data/lib/generators/reeve/install/templates/initializer.rb.tt +20 -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 +40 -2
- data/lib/reeve/audit/chain/canonical.rb +129 -0
- data/lib/reeve/audit/chain/head.rb +94 -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 +134 -0
- data/lib/reeve/audit/chain/verification.rb +110 -0
- data/lib/reeve/audit/chain/verifier.rb +160 -0
- data/lib/reeve/audit/chain/walk.rb +123 -0
- data/lib/reeve/audit/chain.rb +168 -0
- data/lib/reeve/audit/chain_record.rb +61 -0
- data/lib/reeve/audit/isolated_recorder.rb +5 -0
- data/lib/reeve/audit/recorder.rb +32 -19
- data/lib/reeve/audit.rb +24 -4
- data/lib/reeve/authorization.rb +1 -11
- data/lib/reeve/configuration.rb +90 -2
- data/lib/reeve/decision.rb +6 -1
- data/lib/reeve/errors.rb +28 -0
- data/lib/reeve/inventory/report.rb +146 -0
- data/lib/reeve/inventory.rb +212 -0
- data/lib/reeve/invocation.rb +3 -1
- data/lib/reeve/ledger_key.rb +47 -0
- data/lib/reeve/railtie.rb +15 -0
- data/lib/reeve/tasks.rb +62 -0
- data/lib/reeve/testing/assertions.rb +2 -2
- data/lib/reeve/testing/checks/contract_version.rb +2 -1
- data/lib/reeve/testing/checks/endpoint_coverage.rb +133 -0
- data/lib/reeve/testing/checks/ledger_chain.rb +116 -0
- data/lib/reeve/testing/checks.rb +43 -7
- data/lib/reeve/testing/compliance_assertions.rb +4 -1
- data/lib/reeve/testing/compliance_suite.rb +4 -1
- data/lib/reeve/testing/ledger.rb +11 -1
- data/lib/reeve/testing/matchers/pass_reeve_check.rb +1 -1
- data/lib/reeve/testing/report.rb +30 -6
- data/lib/reeve/testing/result.rb +28 -8
- data/lib/reeve/testing.rb +20 -4
- data/lib/reeve/version.rb +1 -1
- data/lib/reeve.rb +3 -0
- metadata +20 -2
|
@@ -33,15 +33,29 @@ module Reeve
|
|
|
33
33
|
# contracts/audit-entry.md, which exists so that stays hypothetical: an append-only
|
|
34
34
|
# ledger cannot be backfilled, so a column that is not additive has no honest value
|
|
35
35
|
# to give the rows already written.
|
|
36
|
+
# `creates` names any table a step builds. A step is applied only when every column it
|
|
37
|
+
# adds and every table it creates exist: a ledger with one half and not the other is
|
|
38
|
+
# half-migrated, and calling it current would leave the host with no way forward.
|
|
36
39
|
STEPS = [
|
|
37
40
|
{
|
|
38
41
|
contract: 2,
|
|
39
42
|
adds: %w[contract_version],
|
|
43
|
+
creates: [],
|
|
40
44
|
template: "add_contract_version_to_reeve_audit_entries.rb.tt",
|
|
41
45
|
destination: "add_contract_version_to_reeve_audit_entries.rb"
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
contract: 3,
|
|
49
|
+
adds: %w[chain_sequence chain_key_id chain_digest],
|
|
50
|
+
creates: %w[reeve_audit_chains],
|
|
51
|
+
template: "add_ledger_chain_to_reeve_audit_entries.rb.tt",
|
|
52
|
+
destination: "add_ledger_chain_to_reeve_audit_entries.rb"
|
|
42
53
|
}
|
|
43
54
|
].freeze
|
|
44
55
|
|
|
56
|
+
KEY_LINE = "config.ledger_key = " \
|
|
57
|
+
'Rails.application.key_generator.generate_key("reeve audit ledger", 32)'
|
|
58
|
+
|
|
45
59
|
source_root File.expand_path("templates", __dir__)
|
|
46
60
|
|
|
47
61
|
desc "Brings an existing reeve audit ledger up to the current audit-entry contract."
|
|
@@ -93,7 +107,7 @@ module Reeve
|
|
|
93
107
|
end
|
|
94
108
|
|
|
95
109
|
def pending_message
|
|
96
|
-
<<~NEXT
|
|
110
|
+
message = <<~NEXT
|
|
97
111
|
|
|
98
112
|
#{@emitted.size} migration#{'s' if @emitted.size != 1} written, taking the ledger
|
|
99
113
|
to audit-entry contract #{@emitted.last[:contract]}. Run:
|
|
@@ -103,13 +117,37 @@ module Reeve
|
|
|
103
117
|
Existing rows are stamped with the contract they were actually written under,
|
|
104
118
|
not with the current one.
|
|
105
119
|
NEXT
|
|
120
|
+
message += chain_message if @emitted.any? { |step| step[:contract] == 3 }
|
|
121
|
+
message
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Contract 3 changes what the application must configure, not only the table: without
|
|
125
|
+
# a key, every guarded call fails closed once the migration runs. The host is reading
|
|
126
|
+
# this output at exactly the moment they need to know that.
|
|
127
|
+
def chain_message
|
|
128
|
+
<<~CHAIN
|
|
129
|
+
|
|
130
|
+
The ledger is now chained (contract 3). Add this line to config/initializers/reeve.rb
|
|
131
|
+
before you deploy, or every guarded call fails closed:
|
|
132
|
+
|
|
133
|
+
#{KEY_LINE}
|
|
134
|
+
|
|
135
|
+
It derives the key from secret_key_base, so rotating secret_key_base rotates the
|
|
136
|
+
ledger key: keep the old derived key in config.retired_ledger_keys, or entries it
|
|
137
|
+
signed become unverifiable. A dedicated key avoids that coupling.
|
|
138
|
+
|
|
139
|
+
Very large ledger on PostgreSQL? The migration builds a unique index that blocks
|
|
140
|
+
writes while it scans; the migration's header shows how to build it with
|
|
141
|
+
algorithm: :concurrently instead.
|
|
142
|
+
CHAIN
|
|
106
143
|
end
|
|
107
144
|
|
|
108
145
|
# Asked of the table rather than of a version the host recorded: the table is the
|
|
109
146
|
# thing being changed, and it is the only party that cannot be out of date about
|
|
110
147
|
# itself.
|
|
111
148
|
def applied?(step)
|
|
112
|
-
(step[:adds] - existing_columns).empty?
|
|
149
|
+
(step[:adds] - existing_columns).empty? &&
|
|
150
|
+
step[:creates].all? { |table| connection.table_exists?(table) }
|
|
113
151
|
end
|
|
114
152
|
|
|
115
153
|
def existing_columns
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bigdecimal"
|
|
4
|
+
require "json"
|
|
5
|
+
require "openssl"
|
|
6
|
+
|
|
7
|
+
module Reeve
|
|
8
|
+
module Audit
|
|
9
|
+
class Chain
|
|
10
|
+
# The exact text a digest is computed over (specs/002 FR-002, FR-003; research R1, R2
|
|
11
|
+
# step 2; data-model "Canonical entry").
|
|
12
|
+
#
|
|
13
|
+
# Computed once when a row is written and again, from what the database returns,
|
|
14
|
+
# when it is verified — so everything here exists to make those two readings produce
|
|
15
|
+
# identical text:
|
|
16
|
+
#
|
|
17
|
+
# * object keys sorted at every depth, so a database that reorders JSON keys
|
|
18
|
+
# (PostgreSQL jsonb, MySQL JSON) changes nothing;
|
|
19
|
+
# * one spelling per number, so `1.0e+20` written and `100000000000000000000` read
|
|
20
|
+
# back are the same number;
|
|
21
|
+
# * times as integer microseconds since the epoch, so neither the time zone nor the
|
|
22
|
+
# driver's Time class matters;
|
|
23
|
+
# * strings, booleans and nil typed, so "1" and 1 never digest alike.
|
|
24
|
+
#
|
|
25
|
+
# The field list is written out rather than read from the table — a check that
|
|
26
|
+
# derives its expectation from the thing it checks checks nothing — and is keyed by
|
|
27
|
+
# contract version, so a future column joins the digest for rows written under that
|
|
28
|
+
# contract only. The tags keep an entry digest and a genesis digest from ever being
|
|
29
|
+
# interchangeable, and let a future canonical form be told apart from this one.
|
|
30
|
+
module Canonical
|
|
31
|
+
ENTRY_TAG = "reeve-ledger-entry-v1"
|
|
32
|
+
GENESIS_TAG = "reeve-ledger-genesis-v1"
|
|
33
|
+
|
|
34
|
+
ENTRY_FIELDS = {
|
|
35
|
+
3 => %w[
|
|
36
|
+
chain_sequence chain_key_id contract_version
|
|
37
|
+
invocation_id occurred_at agent_id agent_name principal_type principal_id
|
|
38
|
+
tool_name arguments outcome rule detail record_type record_ids record_count
|
|
39
|
+
truncated derived guard duration_ms metadata
|
|
40
|
+
].freeze
|
|
41
|
+
}.freeze
|
|
42
|
+
|
|
43
|
+
GENESIS_FIELDS = %w[genesis_at pre_chain_count pre_chain_unchained_count
|
|
44
|
+
genesis_key_id].freeze
|
|
45
|
+
|
|
46
|
+
TIME_FIELDS = %w[occurred_at genesis_at].freeze
|
|
47
|
+
|
|
48
|
+
module_function
|
|
49
|
+
|
|
50
|
+
def entry_digest(secret, row, previous_digest:)
|
|
51
|
+
hmac(secret, ENTRY_TAG, entry_message_body(row, previous_digest))
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def genesis_digest(secret, genesis)
|
|
55
|
+
pairs = GENESIS_FIELDS.map { |field| [field, value(genesis, field)] }
|
|
56
|
+
hmac(secret, GENESIS_TAG, dump(pairs))
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def entry_message_body(row, previous_digest)
|
|
60
|
+
version = value(row, "contract_version")
|
|
61
|
+
fields = ENTRY_FIELDS.fetch(version) do
|
|
62
|
+
raise ArgumentError,
|
|
63
|
+
"there is no canonical field list for audit-entry contract " \
|
|
64
|
+
"#{version.inspect}; only contract " \
|
|
65
|
+
"#{ENTRY_FIELDS.keys.join(', ')} rows are chained"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
pairs = fields.map { |field| [field, value(row, field)] }
|
|
69
|
+
dump([["previous_digest", previous_digest], *pairs])
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def microseconds(time)
|
|
73
|
+
(time.to_r * 1_000_000).floor
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def dump(value)
|
|
77
|
+
case value
|
|
78
|
+
when Array then "[#{value.map { |item| dump(item) }.join(',')}]"
|
|
79
|
+
when Hash then object(value)
|
|
80
|
+
else scalar(value)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def scalar(value)
|
|
85
|
+
case value
|
|
86
|
+
when nil, true, false then value.nil? ? "null" : value.to_s
|
|
87
|
+
when Integer, Float, BigDecimal then number(value)
|
|
88
|
+
when String, Symbol then JSON.generate(value.to_s)
|
|
89
|
+
else
|
|
90
|
+
raise ArgumentError,
|
|
91
|
+
"#{value.class} has no canonical form; normalize it before it is digested"
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def object(hash)
|
|
96
|
+
pairs = hash.map { |key, item| [key.to_s, item] }.sort_by(&:first)
|
|
97
|
+
"{#{pairs.map { |key, item| "#{JSON.generate(key)}:#{dump(item)}" }.join(',')}}"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# The shortest exact decimal: no exponent, no trailing zeros, no "-0".
|
|
101
|
+
def number(value)
|
|
102
|
+
if value.is_a?(Float) && !value.finite?
|
|
103
|
+
raise ArgumentError,
|
|
104
|
+
"a non-finite number has no canonical form; normalize it before it is digested"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
decimal = value.is_a?(Float) ? BigDecimal(value.to_s) : BigDecimal(value)
|
|
108
|
+
return "0" if decimal.zero?
|
|
109
|
+
return decimal.to_i.to_s if decimal.frac.zero?
|
|
110
|
+
|
|
111
|
+
decimal.to_s("F")
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def value(row, field)
|
|
115
|
+
raw = if row.is_a?(Hash)
|
|
116
|
+
row.key?(field) ? row[field] : row[field.to_sym]
|
|
117
|
+
else
|
|
118
|
+
row[field]
|
|
119
|
+
end
|
|
120
|
+
TIME_FIELDS.include?(field) && !raw.nil? ? microseconds(raw) : raw
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def hmac(secret, tag, body)
|
|
124
|
+
OpenSSL::HMAC.hexdigest("SHA256", secret, "#{tag}\n#{body}")
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reeve
|
|
4
|
+
module Audit
|
|
5
|
+
class Chain
|
|
6
|
+
# The chain record's head, advanced under its lock (research R3).
|
|
7
|
+
#
|
|
8
|
+
# One instance per append, bound to the connection that append runs on; every
|
|
9
|
+
# statement here goes through that connection, so the lock, the row and the head move
|
|
10
|
+
# share one transaction and commit or roll back together.
|
|
11
|
+
#
|
|
12
|
+
# UPDATE head_sequence by one -- the lock, taken first
|
|
13
|
+
# SELECT the new head_sequence and the old head_digest
|
|
14
|
+
# ... the caller inserts the row ...
|
|
15
|
+
# UPDATE head_digest to the row's digest
|
|
16
|
+
#
|
|
17
|
+
# Taking the lock with a write is what makes this behave the same on all three
|
|
18
|
+
# engines: SQLite cannot upgrade a read lock while another writer waits, and ignores
|
|
19
|
+
# SELECT ... FOR UPDATE.
|
|
20
|
+
class Head
|
|
21
|
+
def initialize(connection:, entry_class:)
|
|
22
|
+
@connection = connection
|
|
23
|
+
@entry_class = entry_class
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Creates the chain record with its genesis — link 0, head 0 — unless it exists.
|
|
27
|
+
#
|
|
28
|
+
# Deliberately not part of the write that follows. Run first inside it, an UPDATE that
|
|
29
|
+
# matched no row took a gap lock on MySQL's unique index, and two writers starting the
|
|
30
|
+
# chain at once each tried to INSERT into the gap the other held: a deadlock, found
|
|
31
|
+
# by CI, invisible on PostgreSQL, which has no gap locks. On its own, two concurrent
|
|
32
|
+
# INSERTs of one unique name serialize: one wins and the other gets a duplicate key,
|
|
33
|
+
# which means the chain has started and is all it needs to know.
|
|
34
|
+
#
|
|
35
|
+
# So a started chain can exist with nothing in it yet (head 0) — if the first chained
|
|
36
|
+
# write then fails, say. The verifier reports that as no chain, not as a pass.
|
|
37
|
+
def start!(signer)
|
|
38
|
+
return if connection.select_value("SELECT 1 FROM #{TABLE} WHERE #{named}")
|
|
39
|
+
|
|
40
|
+
genesis = genesis_for(signer)
|
|
41
|
+
digest = Canonical.genesis_digest(signer.signing_secret, genesis)
|
|
42
|
+
values = genesis.merge(name: ChainRecord::LEDGER, genesis_digest: digest,
|
|
43
|
+
head_sequence: 0, head_digest: digest)
|
|
44
|
+
quoted = values.values.map { |value| connection.quote(value) }
|
|
45
|
+
|
|
46
|
+
entry_class.transaction(requires_new: true) do
|
|
47
|
+
connection.insert("INSERT INTO #{TABLE} (#{values.keys.join(', ')}) " \
|
|
48
|
+
"VALUES (#{quoted.join(', ')})")
|
|
49
|
+
end
|
|
50
|
+
rescue ActiveRecord::RecordNotUnique
|
|
51
|
+
nil
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Locks the head and returns the next sequence and the digest it links to. The chain
|
|
55
|
+
# must have been started.
|
|
56
|
+
def advance!
|
|
57
|
+
vanished = increment.zero?
|
|
58
|
+
raise ActiveRecord::StatementInvalid, "the ledger chain record vanished" if vanished
|
|
59
|
+
|
|
60
|
+
head = connection.select_one("SELECT head_sequence, head_digest FROM #{TABLE} " \
|
|
61
|
+
"WHERE #{named}")
|
|
62
|
+
[Integer(head["head_sequence"]), head["head_digest"]]
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def point_at!(digest)
|
|
66
|
+
connection.update("UPDATE #{TABLE} SET head_digest = #{connection.quote(digest)} " \
|
|
67
|
+
"WHERE #{named}")
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
attr_reader :connection, :entry_class
|
|
73
|
+
|
|
74
|
+
def increment
|
|
75
|
+
connection.update("UPDATE #{TABLE} SET head_sequence = head_sequence + 1 WHERE #{named}")
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def genesis_for(signer)
|
|
79
|
+
{
|
|
80
|
+
genesis_at: Time.now.utc.floor(6),
|
|
81
|
+
pre_chain_count: entry_class.where("contract_version < 3").count,
|
|
82
|
+
pre_chain_unchained_count:
|
|
83
|
+
entry_class.where(UNCHAINED).count,
|
|
84
|
+
genesis_key_id: signer.signing_id
|
|
85
|
+
}
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def named
|
|
89
|
+
"name = #{connection.quote(ChainRecord::LEDGER)}"
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reeve
|
|
4
|
+
module Audit
|
|
5
|
+
class Chain
|
|
6
|
+
# The keys one chained write or one verification may use (specs/002 FR-011, R7).
|
|
7
|
+
#
|
|
8
|
+
# One key signs; any number verify. Rows name their key by fingerprint, so lookup is
|
|
9
|
+
# by fingerprint too, and a key the ring does not hold is simply absent — the verifier
|
|
10
|
+
# turns that into "unverifiable", never into "tampered".
|
|
11
|
+
#
|
|
12
|
+
# Keyring.from(Reeve.config) # what the recorder signs with
|
|
13
|
+
# Keyring.new(signing: nil, retired: [auditor_key]) # verify-only, e.g. an auditor
|
|
14
|
+
class Keyring
|
|
15
|
+
def self.from(config)
|
|
16
|
+
new(signing: config.ledger_key, retired: config.retired_ledger_keys)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def initialize(signing:, retired: [])
|
|
20
|
+
@signing_secret = signing
|
|
21
|
+
@by_id = {}
|
|
22
|
+
[signing, *retired].compact.each do |secret|
|
|
23
|
+
@by_id[LedgerKey.fingerprint(secret)] ||= secret
|
|
24
|
+
end
|
|
25
|
+
@by_id.freeze
|
|
26
|
+
freeze
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
attr_reader :signing_secret
|
|
30
|
+
|
|
31
|
+
def signing_id
|
|
32
|
+
signing_secret && LedgerKey.fingerprint(signing_secret)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def signing?
|
|
36
|
+
!signing_secret.nil?
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def for(key_id)
|
|
40
|
+
@by_id[key_id]
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def empty?
|
|
44
|
+
@by_id.empty?
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def inspect
|
|
48
|
+
"#<#{self.class.name} signing=#{signing_id.inspect} keys=#{@by_id.keys.inspect}>"
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reeve
|
|
4
|
+
module Audit
|
|
5
|
+
class Chain
|
|
6
|
+
# Puts a row into the form the database will hand back, before it is written
|
|
7
|
+
# (specs/002 FR-003, research R2 step 1).
|
|
8
|
+
#
|
|
9
|
+
# The digest is computed at write time and recomputed at verification from what the
|
|
10
|
+
# database returns. The two only agree if nothing is left for the database to change,
|
|
11
|
+
# so anything it would change is changed here first:
|
|
12
|
+
#
|
|
13
|
+
# * JSON values go through the column's own ActiveRecord type, encode and decode — the
|
|
14
|
+
# exact path a write takes. Symbol keys become strings, a Time inside the arguments
|
|
15
|
+
# becomes the string ActiveRecord stores, NaN becomes nil.
|
|
16
|
+
# * `occurred_at` is floored to the precision the column keeps, so the database has
|
|
17
|
+
# no fraction to round (MySQL rounds; it does not truncate), and moved to UTC.
|
|
18
|
+
#
|
|
19
|
+
# What remains — JSON key order and how a number is spelled — is the database's to
|
|
20
|
+
# change, and Canonical makes it irrelevant.
|
|
21
|
+
#
|
|
22
|
+
# It cannot recover a value the database alters for good: an integer beyond double
|
|
23
|
+
# precision in a MySQL JSON column is stored rounded. That shows up as a false break,
|
|
24
|
+
# never a false pass, and is documented.
|
|
25
|
+
class Normalizer
|
|
26
|
+
JSON_FIELDS = %i[arguments record_ids metadata].freeze
|
|
27
|
+
TIME_FIELD = :occurred_at
|
|
28
|
+
|
|
29
|
+
# Undeclared precision: MySQL's DATETIME is whole seconds, the others keep
|
|
30
|
+
# microseconds.
|
|
31
|
+
WHOLE_SECOND_ADAPTERS = /mysql|trilogy/i
|
|
32
|
+
DEFAULT_PRECISION = 6
|
|
33
|
+
|
|
34
|
+
def self.effective_precision(column, adapter_name)
|
|
35
|
+
return column.precision unless column.precision.nil?
|
|
36
|
+
|
|
37
|
+
adapter_name.to_s.match?(WHOLE_SECOND_ADAPTERS) ? 0 : DEFAULT_PRECISION
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def initialize(entry_class)
|
|
41
|
+
@entry_class = entry_class
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# A new hash; the row passed in is not modified.
|
|
45
|
+
def call(row)
|
|
46
|
+
# Symbol keys throughout, whatever the caller built: a host recorder (FR-007) may use
|
|
47
|
+
# strings, and every lookup below — and the replay check after it — is by symbol.
|
|
48
|
+
normalized = row.to_h.transform_keys(&:to_sym)
|
|
49
|
+
JSON_FIELDS.each do |field|
|
|
50
|
+
next unless normalized.key?(field)
|
|
51
|
+
|
|
52
|
+
normalized[field] = round_trip(field, normalized[field])
|
|
53
|
+
end
|
|
54
|
+
normalized[TIME_FIELD] = floor(normalized[TIME_FIELD]) if normalized[TIME_FIELD]
|
|
55
|
+
normalized
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
attr_reader :entry_class
|
|
61
|
+
|
|
62
|
+
def round_trip(field, value)
|
|
63
|
+
return nil if value.nil?
|
|
64
|
+
|
|
65
|
+
type = json_types[field]
|
|
66
|
+
type.deserialize(type.serialize(value))
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def floor(time)
|
|
70
|
+
time.to_time.getutc.floor(precision)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def json_types
|
|
74
|
+
@json_types ||= JSON_FIELDS.to_h do |field|
|
|
75
|
+
[field, entry_class.type_for_attribute(field.to_s)]
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def precision
|
|
80
|
+
@precision ||= self.class.effective_precision(
|
|
81
|
+
entry_class.columns_hash.fetch(TIME_FIELD.to_s),
|
|
82
|
+
entry_class.connection_db_config.adapter
|
|
83
|
+
)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reeve
|
|
4
|
+
module Audit
|
|
5
|
+
class Chain
|
|
6
|
+
# A Verification in words (contracts/ledger-chain.md "Output").
|
|
7
|
+
#
|
|
8
|
+
# Every summary line carries its own coverage. A reader who copies only the first line
|
|
9
|
+
# into a ticket still copies how much of the ledger the word in it covers — "INTACT"
|
|
10
|
+
# never travels without "sequences 1–n" and the count of what was not covered.
|
|
11
|
+
#
|
|
12
|
+
# Unknown-key breaks are summarized, not listed: there is one per row a missing key
|
|
13
|
+
# signed, and the summary already names the key and the count.
|
|
14
|
+
class Report
|
|
15
|
+
LABEL_WIDTH = 14
|
|
16
|
+
KIND_WIDTH = 17
|
|
17
|
+
PREFIX = "reeve ledger:"
|
|
18
|
+
|
|
19
|
+
def self.number(value)
|
|
20
|
+
digits = value.abs.to_s.reverse.scan(/\d{1,3}/).join(",").reverse
|
|
21
|
+
value.negative? ? "-#{digits}" : digits
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def initialize(verification)
|
|
25
|
+
@v = verification
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def to_s(limit:)
|
|
29
|
+
listed = @v.breaks.reject { |found| found.kind == :unknown_key }
|
|
30
|
+
return summary if listed.empty?
|
|
31
|
+
|
|
32
|
+
lines = listed.first(limit).map { |found| line_for(found) }
|
|
33
|
+
[summary, totals, *lines, more(listed, limit)].compact.join("\n")
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def summary
|
|
39
|
+
case @v.status
|
|
40
|
+
when :intact then intact
|
|
41
|
+
when :broken then broken
|
|
42
|
+
when :no_chain then no_chain
|
|
43
|
+
else @v.reason == :no_key ? no_key : unverifiable
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def intact
|
|
48
|
+
"#{PREFIX} INTACT — sequences #{range(@v.covered)} verified " \
|
|
49
|
+
"(head at start: #{number(@v.head_at_start)}); #{uncovered}"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def broken
|
|
53
|
+
count = @v.breaks.size
|
|
54
|
+
where = @v.covered ? "in sequences #{range(@v.covered)}" : "in the ledger"
|
|
55
|
+
"#{PREFIX} BROKEN — #{number(count)} break#{'s' unless count == 1} #{where}; #{uncovered}"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def no_chain
|
|
59
|
+
"#{PREFIX} NO CHAIN — #{number(@v.uncovered_now + @v.unchained_now)} entries, " \
|
|
60
|
+
"none chained; nothing was verified"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def no_key
|
|
64
|
+
"#{PREFIX} UNVERIFIABLE — no ledger key configured; nothing was verified"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def unverifiable
|
|
68
|
+
unknown = @v.breaks.select { |found| found.kind == :unknown_key }
|
|
69
|
+
rows = unknown.count { |found| entry_position?(found.sequence) }
|
|
70
|
+
checked = @v.covered ? @v.covered.size - rows : 0
|
|
71
|
+
"#{PREFIX} UNVERIFIABLE — #{number(rows)} entries signed by #{missing_keys(unknown)} " \
|
|
72
|
+
"not configured; the other #{number(checked)} in #{range(@v.covered)} show no " \
|
|
73
|
+
"breaks, which is not a pass; #{number(@v.uncovered_now)} earlier entries not covered"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# The genesis is position 0 and is not an entry.
|
|
77
|
+
def entry_position?(sequence)
|
|
78
|
+
sequence.is_a?(Integer) && sequence.positive?
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def missing_keys(unknown)
|
|
82
|
+
keys = unknown.map(&:key_id).compact.uniq
|
|
83
|
+
keys.one? ? "key #{keys.first}, which is" : "keys #{keys.join(', ')}, which are"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def uncovered
|
|
87
|
+
parts = ["#{number(@v.uncovered_now)} earlier entries not covered"]
|
|
88
|
+
at_genesis = @v.uncovered_at_genesis
|
|
89
|
+
parts << " (#{number(at_genesis)} when the chain started)" if at_genesis
|
|
90
|
+
unless @v.unchained_at_genesis.to_i.zero?
|
|
91
|
+
parts << "; #{number(@v.unchained_at_genesis)} unchained entries from before the " \
|
|
92
|
+
"chain started, not covered"
|
|
93
|
+
end
|
|
94
|
+
parts.join
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def totals
|
|
98
|
+
" totals: #{@v.totals.map { |kind, count| "#{kind} #{count}" }.join(', ')}"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def more(listed, limit)
|
|
102
|
+
remaining = listed.size - limit
|
|
103
|
+
return nil unless limit.positive? && remaining.positive?
|
|
104
|
+
|
|
105
|
+
" … and #{number(remaining)} more (the totals above are complete)"
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def line_for(found)
|
|
109
|
+
kind = found.kind.to_s.ljust(KIND_WIDTH)
|
|
110
|
+
" #{label(found).ljust(LABEL_WIDTH)}#{kind}#{found.detail}".rstrip
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def label(found)
|
|
114
|
+
sequence = found.sequence
|
|
115
|
+
return "invocation #{found.invocation_id}" if found.invocation_id
|
|
116
|
+
return "—" if sequence.nil?
|
|
117
|
+
return "##{range(sequence)}" if sequence.is_a?(Range)
|
|
118
|
+
|
|
119
|
+
sequence.zero? ? "genesis" : "##{number(sequence)}"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def range(value)
|
|
123
|
+
return "—" if value.nil?
|
|
124
|
+
|
|
125
|
+
"#{number(value.first)}–#{number(value.last)}"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def number(value)
|
|
129
|
+
self.class.number(value.to_i)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reeve
|
|
4
|
+
module Audit
|
|
5
|
+
class Chain
|
|
6
|
+
# What a verification established, and — as importantly — what it did not
|
|
7
|
+
# (specs/002 FR-012–FR-014, data-model "Verification result").
|
|
8
|
+
#
|
|
9
|
+
# The status says whether the chain held; the coverage says over what. They are
|
|
10
|
+
# separate fields so that no reader can take one for the other: an intact chain that
|
|
11
|
+
# covers the last 300 rows of a ledger of 5,300 is intact *for those 300*.
|
|
12
|
+
class Verification
|
|
13
|
+
STATUSES = %i[intact broken unverifiable no_chain].freeze
|
|
14
|
+
|
|
15
|
+
# `sequence` is an Integer or a Range (a gap), or nil for a break that has no
|
|
16
|
+
# position; an unchained row has none and is named by `invocation_id` instead.
|
|
17
|
+
# `key_id` is set for unknown_key: the fingerprint of the key the verifier lacked.
|
|
18
|
+
Break = Struct.new(:kind, :sequence, :detail, :invocation_id, :key_id, keyword_init: true)
|
|
19
|
+
|
|
20
|
+
DEFAULT_LIMIT = 100
|
|
21
|
+
|
|
22
|
+
KINDS = %i[
|
|
23
|
+
digest_mismatch missing duplicate unchained head_mismatch beyond_head
|
|
24
|
+
genesis_altered chain_record_missing unknown_key
|
|
25
|
+
].freeze
|
|
26
|
+
|
|
27
|
+
# The status rules (data-model), for a verification that had a key and found a
|
|
28
|
+
# chain; the no-key and no-chain answers are given before a walk ever starts. Any
|
|
29
|
+
# break but an unknown key is a broken chain. Rows only an absent key could check
|
|
30
|
+
# leave it unverifiable — never intact, and never called tampered.
|
|
31
|
+
def self.status_for(breaks)
|
|
32
|
+
kinds = breaks.map(&:kind)
|
|
33
|
+
return :broken if (kinds - [:unknown_key]).any?
|
|
34
|
+
return :unverifiable if kinds.include?(:unknown_key)
|
|
35
|
+
|
|
36
|
+
:intact
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
attr_reader :status, :covered, :breaks, :head_at_start, :head_at_end,
|
|
40
|
+
:uncovered_now, :uncovered_at_genesis, :unchained_now, :unchained_at_genesis,
|
|
41
|
+
:reason
|
|
42
|
+
|
|
43
|
+
def initialize(status:, covered: nil, breaks: [], head_at_start: nil, head_at_end: nil,
|
|
44
|
+
uncovered_now: 0, uncovered_at_genesis: nil, unchained_now: 0,
|
|
45
|
+
unchained_at_genesis: nil, reason: nil)
|
|
46
|
+
raise ArgumentError, "unknown status #{status.inspect}" unless STATUSES.include?(status)
|
|
47
|
+
|
|
48
|
+
@status = status
|
|
49
|
+
@covered = covered
|
|
50
|
+
@breaks = breaks.freeze
|
|
51
|
+
@head_at_start = head_at_start
|
|
52
|
+
@head_at_end = head_at_end
|
|
53
|
+
@uncovered_now = uncovered_now
|
|
54
|
+
@uncovered_at_genesis = uncovered_at_genesis
|
|
55
|
+
@unchained_now = unchained_now
|
|
56
|
+
@unchained_at_genesis = unchained_at_genesis
|
|
57
|
+
@reason = reason
|
|
58
|
+
freeze
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def intact?
|
|
62
|
+
status == :intact
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Never capped, whatever the display does with the list.
|
|
66
|
+
def totals
|
|
67
|
+
breaks.each_with_object(Hash.new(0)) { |b, counts| counts[b.kind] += 1 }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def unverifiable_count
|
|
71
|
+
totals[:unknown_key]
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# The report a person reads (contracts/ledger-chain.md "Output"). The first line
|
|
75
|
+
# states the status and what it covers together; no line says "intact" or "pass"
|
|
76
|
+
# without the covered range and the uncovered count beside it (SC-006). `limit`
|
|
77
|
+
# caps the breaks listed, never the totals.
|
|
78
|
+
def to_s(limit: DEFAULT_LIMIT)
|
|
79
|
+
Report.new(self).to_s(limit: limit)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def as_json(*)
|
|
83
|
+
{
|
|
84
|
+
"status" => status.to_s, "covered" => range_json(covered),
|
|
85
|
+
"head_at_start" => head_at_start, "head_at_end" => head_at_end,
|
|
86
|
+
"uncovered_now" => uncovered_now, "uncovered_at_genesis" => uncovered_at_genesis,
|
|
87
|
+
"unchained_now" => unchained_now, "unchained_at_genesis" => unchained_at_genesis,
|
|
88
|
+
"unverifiable_count" => unverifiable_count,
|
|
89
|
+
"totals" => totals.transform_keys(&:to_s), "reason" => reason&.to_s,
|
|
90
|
+
"breaks" => breaks.map { |b| break_json(b) }
|
|
91
|
+
}
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
def break_json(found)
|
|
97
|
+
{
|
|
98
|
+
"kind" => found.kind.to_s, "sequence" => range_json(found.sequence),
|
|
99
|
+
"detail" => found.detail, "invocation_id" => found.invocation_id,
|
|
100
|
+
"key_id" => found.key_id
|
|
101
|
+
}
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def range_json(value)
|
|
105
|
+
value.is_a?(Range) ? { "first" => value.first, "last" => value.last } : value
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|