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
data/lib/reeve/tasks.rb
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
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
|
+
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
# Verifies the ledger, writes the report to `out`, and returns the exit status:
|
|
21
|
+
# 0 intact, 1 broken, 2 could not verify (unverifiable, or no chain).
|
|
22
|
+
#
|
|
23
|
+
# LIMIT caps the breaks listed in the text report (default 100; the totals are never
|
|
24
|
+
# capped). FORMAT=json writes the whole result, every break, and ignores LIMIT.
|
|
25
|
+
def verify_ledger(env: ENV, out: $stdout)
|
|
26
|
+
format = env.fetch("FORMAT", "text")
|
|
27
|
+
unless FORMATS.include?(format)
|
|
28
|
+
raise ArgumentError, "FORMAT must be one of #{FORMATS.join(', ')}, got #{format.inspect}"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
limit = limit_from(env)
|
|
32
|
+
require "reeve/audit"
|
|
33
|
+
result = Reeve::Audit::Chain.verify
|
|
34
|
+
out.puts(format == "json" ? JSON.generate(result.as_json) : result.to_s(limit: limit))
|
|
35
|
+
EXIT.fetch(result.status)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def limit_from(env)
|
|
39
|
+
raw = env.fetch("LIMIT", Reeve::Audit::Chain::Verification::DEFAULT_LIMIT.to_s)
|
|
40
|
+
Integer(raw, 10).tap { |limit| raise ArgumentError if limit.negative? }
|
|
41
|
+
rescue ArgumentError, TypeError
|
|
42
|
+
raise ArgumentError, "LIMIT must be a whole number of breaks to list, got #{raw.inspect}"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# The Rake task, defined on the current Rake application. Depends on :environment, so
|
|
46
|
+
# the host's initializer — and with it the ledger key — is loaded first.
|
|
47
|
+
#
|
|
48
|
+
# The DSL is borrowed by a throwaway object, so Reeve::Tasks itself never gains `task`,
|
|
49
|
+
# `sh` and the rest of Rake's methods.
|
|
50
|
+
def define
|
|
51
|
+
Object.new.extend(Rake::DSL).instance_exec do
|
|
52
|
+
namespace :reeve do
|
|
53
|
+
desc "Verify the audit ledger's chain " \
|
|
54
|
+
"(exit 0 intact, 1 broken, 2 could not verify; LIMIT=, FORMAT=json)"
|
|
55
|
+
task verify_ledger: :environment do
|
|
56
|
+
exit Reeve::Tasks.verify_ledger
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -56,14 +56,14 @@ module Reeve
|
|
|
56
56
|
)
|
|
57
57
|
end
|
|
58
58
|
|
|
59
|
-
# SC-009's escape hatch: any
|
|
59
|
+
# SC-009's escape hatch: any check, straight from Minitest.
|
|
60
60
|
def assert_reeve_check(check)
|
|
61
61
|
result = check.call
|
|
62
62
|
assert result.passed?, result.message
|
|
63
63
|
result
|
|
64
64
|
end
|
|
65
65
|
|
|
66
|
-
# A whole Report at once, for a host that would rather have one test than
|
|
66
|
+
# A whole Report at once, for a host that would rather have one test than one per check.
|
|
67
67
|
def assert_reeve_compliance(principals: nil, **options)
|
|
68
68
|
report = Checks.run_all(
|
|
69
69
|
principals: principals || Testing.compliance_principals, **options
|
|
@@ -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
|
|
49
50
|
].freeze
|
|
50
51
|
|
|
51
|
-
EXPECTED_VERSION =
|
|
52
|
+
EXPECTED_VERSION = 3
|
|
52
53
|
|
|
53
54
|
def initialize(tool: nil, expected: EXPECTED_VERSION, ledger: nil)
|
|
54
55
|
super(tool: tool, ledger: ledger)
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reeve
|
|
4
|
+
module Testing
|
|
5
|
+
module Checks
|
|
6
|
+
# Whether the run covered the endpoint, or only the tools reeve was told about.
|
|
7
|
+
#
|
|
8
|
+
# Every other check walks a population reeve assembled itself: the classes that
|
|
9
|
+
# included `Reeve::Guard`. A server that dispatches some tools without ever handing
|
|
10
|
+
# them to reeve leaves them out of that population, and the run goes green on the
|
|
11
|
+
# subset it could see. This check compares the run against the host's own list of
|
|
12
|
+
# registered names (`config.inventory`), so a green run can only mean the endpoint.
|
|
13
|
+
#
|
|
14
|
+
# Four answers, and only one of them is a pass:
|
|
15
|
+
#
|
|
16
|
+
# * **no inventory declared** — skipped. Reeve cannot know what the server registers
|
|
17
|
+
# unless told, and a pass here would be the green that means "did not look".
|
|
18
|
+
# * **a subset** — skipped, when the host narrowed the run with `compliance_tools` or
|
|
19
|
+
# `tools:`. That is the incremental-adoption path; the run says what it certified
|
|
20
|
+
# and that it is not the endpoint, without turning the build red mid-retrofit.
|
|
21
|
+
# * **incomplete** — failed: a registered tool bypasses reeve or reaches it unguarded,
|
|
22
|
+
# and the run claimed to cover everything.
|
|
23
|
+
# * **complete** — passed.
|
|
24
|
+
#
|
|
25
|
+
# Reeve::Checks::EndpointCoverage.new.call
|
|
26
|
+
# Reeve::Checks::EndpointCoverage.new(inventory: McpInventory).call
|
|
27
|
+
class EndpointCoverage < Base
|
|
28
|
+
def initialize(inventory: :configured, tools: nil, ledger: nil)
|
|
29
|
+
super(ledger: ledger)
|
|
30
|
+
@inventory = inventory == :configured ? Reeve.config.inventory : inventory
|
|
31
|
+
@tools = tools
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def call
|
|
35
|
+
return unverified if inventory.nil?
|
|
36
|
+
|
|
37
|
+
report = inventory.report
|
|
38
|
+
if narrowed?
|
|
39
|
+
subset(report)
|
|
40
|
+
elsif report.complete?
|
|
41
|
+
complete(report)
|
|
42
|
+
else
|
|
43
|
+
incomplete(report)
|
|
44
|
+
end
|
|
45
|
+
rescue StandardError => e
|
|
46
|
+
failed("the endpoint inventory could not be read, so its coverage is unknown: " \
|
|
47
|
+
"#{e.class}: #{e.message}", coverage: :unknown)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
attr_reader :inventory
|
|
53
|
+
|
|
54
|
+
def narrowed?
|
|
55
|
+
!@tools.nil? || Testing.compliance_tools_narrowed?
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def certified
|
|
59
|
+
@certified ||= @tools || Testing.compliance_tools
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def unverified
|
|
63
|
+
skipped(
|
|
64
|
+
"no endpoint inventory is declared, so this run certifies the " \
|
|
65
|
+
"#{pluralize(certified.size, 'tool')} reeve knows about and cannot say whether " \
|
|
66
|
+
"the server dispatches others outside it — set config.inventory to a " \
|
|
67
|
+
"Reeve::Inventory to verify the endpoint",
|
|
68
|
+
coverage: :unverified
|
|
69
|
+
)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def complete(report)
|
|
73
|
+
passed(
|
|
74
|
+
"every one of the #{pluralize(report.registered.size, 'tool')} the server " \
|
|
75
|
+
"registers is protected and guarded (#{report.guarded.size}) or exempted " \
|
|
76
|
+
"(#{report.exempted.size})",
|
|
77
|
+
coverage: :complete
|
|
78
|
+
)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def incomplete(report)
|
|
82
|
+
failed(
|
|
83
|
+
"expected every tool the server registers to be protected by reeve or " \
|
|
84
|
+
"exempted, but #{gaps(report)}\n\n#{report}",
|
|
85
|
+
coverage: :incomplete
|
|
86
|
+
)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Complete coverage with a narrowed run is still a subset: the inventory is sound,
|
|
90
|
+
# but this run did not certify all of it.
|
|
91
|
+
def subset(report)
|
|
92
|
+
routed = report.routed_tools
|
|
93
|
+
missing = routed - certified
|
|
94
|
+
lines = [
|
|
95
|
+
"this run certifies a subset, not the endpoint: " \
|
|
96
|
+
"#{(routed - missing).size} of the #{pluralize(routed.size, 'tool')} routed " \
|
|
97
|
+
"through reeve (compliance_tools or tools: narrowed it)"
|
|
98
|
+
]
|
|
99
|
+
lines << "not certified: #{missing.map { |tool| tool.name || tool.inspect }.join(', ')}" \
|
|
100
|
+
unless missing.empty?
|
|
101
|
+
lines << "" << report.to_s unless report.complete?
|
|
102
|
+
|
|
103
|
+
skipped(lines.join("\n"), coverage: :subset)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def gaps(report)
|
|
107
|
+
[
|
|
108
|
+
gap(report.bypassing, "bypasses it", "bypass it"),
|
|
109
|
+
gap(report.unguarded, "is routed through it unguarded",
|
|
110
|
+
"are routed through it unguarded"),
|
|
111
|
+
gap(report.unregistered_entries, "declared in the inventory is not registered",
|
|
112
|
+
"declared in the inventory are not registered")
|
|
113
|
+
].compact.join(", ")
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def gap(entries, one, many)
|
|
117
|
+
return nil if entries.empty?
|
|
118
|
+
|
|
119
|
+
"#{entries.size} #{entries.size == 1 ? one : many}"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Only this check can come back with nothing established either way.
|
|
123
|
+
def skipped(message, details = {})
|
|
124
|
+
Result.skipped(check: check_name, message: message, details: details)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def base_details
|
|
128
|
+
{}
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
@@ -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
|
@@ -8,17 +8,19 @@ require_relative "checks/rule_present"
|
|
|
8
8
|
require_relative "checks/redaction_holds"
|
|
9
9
|
require_relative "checks/principal_required"
|
|
10
10
|
require_relative "checks/contract_version"
|
|
11
|
+
require_relative "checks/endpoint_coverage"
|
|
12
|
+
require_relative "checks/ledger_chain"
|
|
11
13
|
|
|
12
14
|
module Reeve
|
|
13
15
|
module Testing
|
|
14
|
-
# The
|
|
16
|
+
# The guarantees, as objects.
|
|
15
17
|
#
|
|
16
18
|
# This is the whole of the testing kit's logic. The RSpec matchers and the Minitest
|
|
17
19
|
# assertions are adapters over it and contain no assertions of their own, which is why
|
|
18
20
|
# the same violation reads identically from either — and from neither:
|
|
19
21
|
#
|
|
20
22
|
# report = Reeve::Checks.run_all(principals: [alice, bob])
|
|
21
|
-
# abort report.to_s
|
|
23
|
+
# abort report.to_s if report.failed? || report.nothing_checked?
|
|
22
24
|
#
|
|
23
25
|
# Nothing here loads a test framework (FR-026).
|
|
24
26
|
module Checks
|
|
@@ -29,11 +31,14 @@ module Reeve
|
|
|
29
31
|
RulePresent,
|
|
30
32
|
RedactionHolds,
|
|
31
33
|
PrincipalRequired,
|
|
32
|
-
ContractVersion
|
|
34
|
+
ContractVersion,
|
|
35
|
+
EndpointCoverage,
|
|
36
|
+
LedgerChain
|
|
33
37
|
].freeze
|
|
34
38
|
|
|
35
|
-
# The checks that are asked once about the ledger
|
|
36
|
-
|
|
39
|
+
# The checks that are asked once — about the ledger, or about the endpoint as a
|
|
40
|
+
# whole — rather than once per tool.
|
|
41
|
+
GLOBAL = [ContractVersion, EndpointCoverage, LedgerChain].freeze
|
|
37
42
|
|
|
38
43
|
# The compliance suite's engine (FR-018): every check, against every registered
|
|
39
44
|
# guarded tool, in one Report.
|
|
@@ -53,9 +58,14 @@ module Reeve
|
|
|
53
58
|
# test method out of, so that a failing suite names the guarantee that broke rather
|
|
54
59
|
# than reporting "compliance" as one undifferentiated red.
|
|
55
60
|
def self.run(check, principals:, tools: nil, arguments: {}, invoke: nil, ledger: nil)
|
|
56
|
-
|
|
61
|
+
if GLOBAL.include?(check)
|
|
62
|
+
return Report.new([build(check, tool: nil, principals: principals, tools: tools,
|
|
63
|
+
invoke: invoke, ledger: ledger).call])
|
|
64
|
+
end
|
|
57
65
|
|
|
58
66
|
subjects = tools || Testing.compliance_tools
|
|
67
|
+
return Report.new([nothing_to_check(check, tools)]) if subjects.empty?
|
|
68
|
+
|
|
59
69
|
Report.new(
|
|
60
70
|
subjects.map do |tool|
|
|
61
71
|
build(check, tool: tool, principals: principals, arguments: arguments,
|
|
@@ -64,6 +74,25 @@ module Reeve
|
|
|
64
74
|
)
|
|
65
75
|
end
|
|
66
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
|
+
|
|
67
96
|
# Every per-tool check for one tool, instantiated but not run.
|
|
68
97
|
def self.for_tool(tool, principals:, arguments: {}, invoke: nil, ledger: nil)
|
|
69
98
|
(ALL - GLOBAL).map do |check|
|
|
@@ -74,12 +103,19 @@ module Reeve
|
|
|
74
103
|
|
|
75
104
|
# The one place that knows what each check's constructor wants. Front-ends and hosts
|
|
76
105
|
# ask for a check by class and get a configured one back.
|
|
77
|
-
|
|
106
|
+
#
|
|
107
|
+
# +tools+ is the population the run was given, which only EndpointCoverage reads: an
|
|
108
|
+
# explicit list is a narrowed run, and it must say so.
|
|
109
|
+
def self.build(check, tool:, principals:, arguments: {}, invoke: nil, ledger: nil,
|
|
110
|
+
tools: nil)
|
|
78
111
|
common = { tool: tool, arguments: arguments, invoke: invoke, ledger: ledger }
|
|
79
112
|
|
|
80
113
|
case check.check_name
|
|
81
114
|
when "GuardDeclared" then GuardDeclared.new(tool: tool, ledger: ledger)
|
|
82
115
|
when "ContractVersion" then ContractVersion.new(ledger: ledger)
|
|
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)
|
|
83
119
|
when "PrincipalRequired" then PrincipalRequired.new(**common)
|
|
84
120
|
when "CrossPrincipalLeak" then CrossPrincipalLeak.new(principals: Array(principals),
|
|
85
121
|
**common)
|
|
@@ -15,7 +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.
|
|
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.
|
|
19
21
|
module ComplianceAssertions
|
|
20
22
|
include Assertions
|
|
21
23
|
|
|
@@ -25,6 +27,7 @@ module Reeve
|
|
|
25
27
|
|
|
26
28
|
define_method(method_name) do
|
|
27
29
|
report = Checks.run(check, principals: Testing.compliance_principals)
|
|
30
|
+
skip(report.to_s) if report.skipped?
|
|
28
31
|
assert report.passed?, report.to_s
|
|
29
32
|
report
|
|
30
33
|
end
|
|
@@ -11,11 +11,14 @@
|
|
|
11
11
|
# Reeve::Testing.compliance_principals = -> { [users(:alice), users(:bob)] }
|
|
12
12
|
#
|
|
13
13
|
# One example per check rather than one for the lot, so a red build names the guarantee
|
|
14
|
-
# that broke — "CrossPrincipalLeak" is an incident, "compliance" is a shrug.
|
|
14
|
+
# that broke — "CrossPrincipalLeak" is an incident, "compliance" is a shrug. A check that
|
|
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.
|
|
15
17
|
RSpec.shared_examples "a reeve-compliant server" do
|
|
16
18
|
Reeve::Checks::ALL.each do |check|
|
|
17
19
|
it "satisfies #{check.check_name} for every registered guarded tool" do
|
|
18
20
|
report = Reeve::Checks.run(check, principals: Reeve::Testing.compliance_principals)
|
|
21
|
+
skip(report.to_s) if report.skipped?
|
|
19
22
|
|
|
20
23
|
expect(report).to be_passed, report.to_s
|
|
21
24
|
end
|
data/lib/reeve/testing/ledger.rb
CHANGED
|
@@ -10,7 +10,8 @@ module Reeve
|
|
|
10
10
|
# the ledger is absent.
|
|
11
11
|
#
|
|
12
12
|
# A host with its own non-ActiveRecord recorder can pass any object with these six
|
|
13
|
-
# methods as `ledger:` to any ledger-reading check
|
|
13
|
+
# methods as `ledger:` to any ledger-reading check; LedgerChain also asks for
|
|
14
|
+
# #verify_chain, and is skipped by a ledger that has none.
|
|
14
15
|
class Ledger
|
|
15
16
|
def self.default
|
|
16
17
|
new
|
|
@@ -67,6 +68,15 @@ module Reeve
|
|
|
67
68
|
entry_class.column_names.map(&:to_s)
|
|
68
69
|
end
|
|
69
70
|
|
|
71
|
+
# The chain's verification of the whole ledger (specs/002), or nil when the ledger is
|
|
72
|
+
# not loaded. The kit's only way to the chain: it names Reeve::Audit::Chain here, at
|
|
73
|
+
# call time, so `require "reeve/testing"` still loads with no ActiveRecord at all.
|
|
74
|
+
def verify_chain
|
|
75
|
+
return nil unless available? && Reeve::Audit.const_defined?(:Chain, false)
|
|
76
|
+
|
|
77
|
+
Reeve::Audit::Chain.verify(entry_class: entry_class)
|
|
78
|
+
end
|
|
79
|
+
|
|
70
80
|
private
|
|
71
81
|
|
|
72
82
|
def entry_class
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
module Reeve
|
|
4
4
|
module Testing
|
|
5
5
|
module Matchers
|
|
6
|
-
# The escape hatch that keeps SC-009 honest: every
|
|
6
|
+
# The escape hatch that keeps SC-009 honest: every check is
|
|
7
7
|
# assertable from RSpec, not only the two with names of their own.
|
|
8
8
|
#
|
|
9
9
|
# expect(Reeve::Checks::RedactionHolds.new(tool: T, principal: alice))
|
data/lib/reeve/testing/report.rb
CHANGED
|
@@ -15,6 +15,8 @@ module Reeve
|
|
|
15
15
|
freeze
|
|
16
16
|
end
|
|
17
17
|
|
|
18
|
+
# Nothing failed. A skip is not a failure — and it is printed, so it is not
|
|
19
|
+
# mistaken for a pass either.
|
|
18
20
|
def passed?
|
|
19
21
|
failures.empty?
|
|
20
22
|
end
|
|
@@ -23,20 +25,39 @@ module Reeve
|
|
|
23
25
|
!passed?
|
|
24
26
|
end
|
|
25
27
|
|
|
28
|
+
# Every result was a skip: the run established nothing either way. The front-ends
|
|
29
|
+
# report this as a skipped test rather than a green one.
|
|
30
|
+
def skipped?
|
|
31
|
+
!results.empty? && results.all?(&:skipped?)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# A check had nothing to examine — a per-tool check run over no tools at all. passed?
|
|
35
|
+
# is still true then, since nothing failed; a test framework shows the skip, but a
|
|
36
|
+
# gate with no framework (a CI script, a deploy step) has only this to stop on. A skip
|
|
37
|
+
# for a missing inventory is not this: there the tools existed and were checked.
|
|
38
|
+
def nothing_checked?
|
|
39
|
+
results.any? { |result| result.skipped? && result.details[:coverage] == :none }
|
|
40
|
+
end
|
|
41
|
+
|
|
26
42
|
def failures
|
|
27
|
-
results.
|
|
43
|
+
results.select(&:failed?)
|
|
28
44
|
end
|
|
29
45
|
|
|
30
46
|
def passes
|
|
31
47
|
results.select(&:passed?)
|
|
32
48
|
end
|
|
33
49
|
|
|
50
|
+
def skips
|
|
51
|
+
results.select(&:skipped?)
|
|
52
|
+
end
|
|
53
|
+
|
|
34
54
|
def size
|
|
35
55
|
results.size
|
|
36
56
|
end
|
|
37
57
|
|
|
38
58
|
def to_s
|
|
39
|
-
[summary, *failures.map { |result| detail(result) }
|
|
59
|
+
[summary, *failures.map { |result| detail("FAIL", result) },
|
|
60
|
+
*skips.map { |result| detail("SKIP", result) }].join("\n")
|
|
40
61
|
end
|
|
41
62
|
|
|
42
63
|
def inspect
|
|
@@ -46,12 +67,15 @@ module Reeve
|
|
|
46
67
|
private
|
|
47
68
|
|
|
48
69
|
def summary
|
|
49
|
-
"reeve compliance: #{size} #{size == 1 ? 'check' : 'checks'}, " \
|
|
50
|
-
|
|
70
|
+
line = "reeve compliance: #{size} #{size == 1 ? 'check' : 'checks'}, " \
|
|
71
|
+
"#{passes.size} passed, #{failures.size} failed"
|
|
72
|
+
skips.empty? ? line : "#{line}, #{skips.size} skipped"
|
|
51
73
|
end
|
|
52
74
|
|
|
53
|
-
|
|
54
|
-
|
|
75
|
+
# Every line of a multi-line message indented, so it reads as one block under its
|
|
76
|
+
# heading rather than as stray lines of the report.
|
|
77
|
+
def detail(heading, result)
|
|
78
|
+
"\n#{heading} #{result.check}\n #{result.message.gsub("\n", "\n ")}"
|
|
55
79
|
end
|
|
56
80
|
end
|
|
57
81
|
end
|
data/lib/reeve/testing/result.rb
CHANGED
|
@@ -9,31 +9,51 @@ module Reeve
|
|
|
9
9
|
# Minitest assertion, a rake task — reads +message+ and prints it; none of them
|
|
10
10
|
# composes their own (FR-019). That is what makes the same violation read identically
|
|
11
11
|
# from all three.
|
|
12
|
+
#
|
|
13
|
+
# A result is passed, failed, or skipped. Skipped is for a guarantee the run could not
|
|
14
|
+
# establish either way — endpoint coverage with no inventory declared. Reporting that
|
|
15
|
+
# as a pass would be the green that means "we did not look"; reporting it as a failure
|
|
16
|
+
# would turn every build red over a question the host never asked. A skip is printed,
|
|
17
|
+
# and shows up yellow in both test frameworks, so it is neither.
|
|
12
18
|
class Result
|
|
13
|
-
|
|
19
|
+
STATUSES = %i[passed failed skipped].freeze
|
|
20
|
+
|
|
21
|
+
attr_reader :check, :message, :details, :status
|
|
14
22
|
|
|
15
23
|
def self.passed(check:, message:, details: {})
|
|
16
|
-
new(check: check,
|
|
24
|
+
new(check: check, status: :passed, message: message, details: details)
|
|
17
25
|
end
|
|
18
26
|
|
|
19
27
|
def self.failed(check:, message:, details: {})
|
|
20
|
-
new(check: check,
|
|
28
|
+
new(check: check, status: :failed, message: message, details: details)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.skipped(check:, message:, details: {})
|
|
32
|
+
new(check: check, status: :skipped, message: message, details: details)
|
|
21
33
|
end
|
|
22
34
|
|
|
23
|
-
def initialize(check:,
|
|
35
|
+
def initialize(check:, status:, message:, details: {})
|
|
36
|
+
unless STATUSES.include?(status)
|
|
37
|
+
raise ArgumentError, "status must be one of #{STATUSES.inspect}, got #{status.inspect}"
|
|
38
|
+
end
|
|
39
|
+
|
|
24
40
|
@check = check.to_s
|
|
25
|
-
@
|
|
41
|
+
@status = status
|
|
26
42
|
@message = message.to_s
|
|
27
43
|
@details = details.freeze
|
|
28
44
|
freeze
|
|
29
45
|
end
|
|
30
46
|
|
|
31
47
|
def passed?
|
|
32
|
-
|
|
48
|
+
status == :passed
|
|
33
49
|
end
|
|
34
50
|
|
|
35
51
|
def failed?
|
|
36
|
-
|
|
52
|
+
status == :failed
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def skipped?
|
|
56
|
+
status == :skipped
|
|
37
57
|
end
|
|
38
58
|
|
|
39
59
|
def to_s
|
|
@@ -41,7 +61,7 @@ module Reeve
|
|
|
41
61
|
end
|
|
42
62
|
|
|
43
63
|
def inspect
|
|
44
|
-
"#<Reeve::Testing::Result #{check} #{
|
|
64
|
+
"#<Reeve::Testing::Result #{check} #{status} " \
|
|
45
65
|
"#{message.inspect}>"
|
|
46
66
|
end
|
|
47
67
|
end
|