reanimator 0.1.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 +7 -0
- data/CHANGELOG.md +207 -0
- data/LICENSE.txt +21 -0
- data/README.md +483 -0
- data/SECURITY.md +32 -0
- data/exe/reanimator +6 -0
- data/lib/reanimator/acceptance_criterion.rb +75 -0
- data/lib/reanimator/agent_connection.rb +62 -0
- data/lib/reanimator/agent_profile.rb +51 -0
- data/lib/reanimator/agents/adapter.rb +49 -0
- data/lib/reanimator/agents/capability.rb +29 -0
- data/lib/reanimator/agents/constraints.rb +57 -0
- data/lib/reanimator/agents/hosted.rb +38 -0
- data/lib/reanimator/agents/local.rb +46 -0
- data/lib/reanimator/agents/patch.rb +60 -0
- data/lib/reanimator/agents/repair_task.rb +51 -0
- data/lib/reanimator/agents/result.rb +42 -0
- data/lib/reanimator/agents/strategies/arithmetic_fix.rb +51 -0
- data/lib/reanimator/agents/strategies/base.rb +26 -0
- data/lib/reanimator/agents.rb +38 -0
- data/lib/reanimator/autonomy_level.rb +41 -0
- data/lib/reanimator/baseline.rb +106 -0
- data/lib/reanimator/baseline_runner.rb +81 -0
- data/lib/reanimator/bounded_command.rb +87 -0
- data/lib/reanimator/classifier.rb +89 -0
- data/lib/reanimator/cli.rb +273 -0
- data/lib/reanimator/cohort.rb +61 -0
- data/lib/reanimator/commissioning/fixture.rb +69 -0
- data/lib/reanimator/commissioning/report.rb +45 -0
- data/lib/reanimator/commissioning/test.rb +145 -0
- data/lib/reanimator/confidence.rb +66 -0
- data/lib/reanimator/configuration.rb +35 -0
- data/lib/reanimator/contract_file.rb +116 -0
- data/lib/reanimator/contract_snapshot.rb +188 -0
- data/lib/reanimator/criteria_stack.rb +91 -0
- data/lib/reanimator/criterion_result.rb +70 -0
- data/lib/reanimator/detectors/base.rb +49 -0
- data/lib/reanimator/detectors/bundler.rb +68 -0
- data/lib/reanimator/detectors/commands.rb +112 -0
- data/lib/reanimator/detectors/fork.rb +25 -0
- data/lib/reanimator/detectors/github_actions.rb +68 -0
- data/lib/reanimator/detectors/operational_proof.rb +48 -0
- data/lib/reanimator/detectors/rails_app.rb +51 -0
- data/lib/reanimator/detectors/relationships.rb +46 -0
- data/lib/reanimator/detectors/ruby_gem.rb +40 -0
- data/lib/reanimator/detectors/security_controls.rb +36 -0
- data/lib/reanimator/discovered_fact.rb +99 -0
- data/lib/reanimator/discovery.rb +48 -0
- data/lib/reanimator/discovery_result.rb +89 -0
- data/lib/reanimator/drift_detector.rb +227 -0
- data/lib/reanimator/drift_report.rb +86 -0
- data/lib/reanimator/effective_contract.rb +108 -0
- data/lib/reanimator/effective_policy_resolver.rb +116 -0
- data/lib/reanimator/errors.rb +21 -0
- data/lib/reanimator/evaluators/base.rb +55 -0
- data/lib/reanimator/evaluators/command.rb +34 -0
- data/lib/reanimator/evaluators/documentation_check.rb +25 -0
- data/lib/reanimator/evaluators/http_smoke.rb +19 -0
- data/lib/reanimator/evaluators/repository_integrity.rb +22 -0
- data/lib/reanimator/evaluators/security_evidence.rb +24 -0
- data/lib/reanimator/evaluators/support_matrix.rb +25 -0
- data/lib/reanimator/evaluators.rb +41 -0
- data/lib/reanimator/family/gate.rb +40 -0
- data/lib/reanimator/family/gate_result.rb +46 -0
- data/lib/reanimator/family/gate_runner.rb +91 -0
- data/lib/reanimator/family.rb +89 -0
- data/lib/reanimator/family_inference.rb +113 -0
- data/lib/reanimator/git.rb +72 -0
- data/lib/reanimator/isolation.rb +36 -0
- data/lib/reanimator/language_inference.rb +43 -0
- data/lib/reanimator/lifecycle_intent.rb +93 -0
- data/lib/reanimator/obligation.rb +35 -0
- data/lib/reanimator/permission_model.rb +61 -0
- data/lib/reanimator/portfolio.rb +68 -0
- data/lib/reanimator/portfolio_drift.rb +120 -0
- data/lib/reanimator/profile_inference.rb +37 -0
- data/lib/reanimator/profiles.rb +110 -0
- data/lib/reanimator/proof_of_life.rb +45 -0
- data/lib/reanimator/proposed_change.rb +55 -0
- data/lib/reanimator/provenance.rb +39 -0
- data/lib/reanimator/relationship.rb +62 -0
- data/lib/reanimator/repair_run.rb +146 -0
- data/lib/reanimator/reports/agents.rb +28 -0
- data/lib/reanimator/reports/baseline.rb +73 -0
- data/lib/reanimator/reports/commissioning.rb +48 -0
- data/lib/reanimator/reports/contract.rb +70 -0
- data/lib/reanimator/reports/discovery.rb +38 -0
- data/lib/reanimator/reports/drift.rb +63 -0
- data/lib/reanimator/reports/families.rb +41 -0
- data/lib/reanimator/reports/family_gates.rb +41 -0
- data/lib/reanimator/reports/github_check.rb +45 -0
- data/lib/reanimator/reports/inventory.rb +37 -0
- data/lib/reanimator/reports/permissions.rb +32 -0
- data/lib/reanimator/reports/portfolio_baseline.rb +68 -0
- data/lib/reanimator/reports/portfolio_drift.rb +107 -0
- data/lib/reanimator/reports/run.rb +52 -0
- data/lib/reanimator/reports/snapshot.rb +58 -0
- data/lib/reanimator/reports.rb +36 -0
- data/lib/reanimator/repository.rb +96 -0
- data/lib/reanimator/repository_metadata.rb +78 -0
- data/lib/reanimator/repository_state.rb +48 -0
- data/lib/reanimator/routing/decision.rb +35 -0
- data/lib/reanimator/routing/router.rb +122 -0
- data/lib/reanimator/run.rb +48 -0
- data/lib/reanimator/safety_floor.rb +81 -0
- data/lib/reanimator/security_control.rb +87 -0
- data/lib/reanimator/security_posture.rb +80 -0
- data/lib/reanimator/snapshot_store.rb +78 -0
- data/lib/reanimator/task_class.rb +63 -0
- data/lib/reanimator/version.rb +5 -0
- data/lib/reanimator/vitality_profile.rb +44 -0
- data/lib/reanimator/waiver.rb +74 -0
- data/lib/reanimator.rb +214 -0
- metadata +262 -0
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Reanimator
|
|
6
|
+
# A comparable, serializable picture of what a repository was required to
|
|
7
|
+
# prove at a point in time. Configuration drift (specification section 06) is
|
|
8
|
+
# a comparison between two of these: the contract as it was last recorded,
|
|
9
|
+
# and the contract the current evidence resolves to.
|
|
10
|
+
#
|
|
11
|
+
# It deliberately captures more than the contract alone. "A removed security
|
|
12
|
+
# gate" and "a narrowed runtime matrix" are the section 06 examples of a
|
|
13
|
+
# policy change, and both live in discovered evidence rather than in the
|
|
14
|
+
# declared file, so the snapshot carries them too.
|
|
15
|
+
class ContractSnapshot
|
|
16
|
+
# `taken_at` is normalized to an ISO-8601 string on the way in: a snapshot
|
|
17
|
+
# exists to be written down and read back, so the moment it records reads
|
|
18
|
+
# the same whether it was just taken or loaded from a file.
|
|
19
|
+
attr_reader :repository, :origin, :intent, :profile, :security_posture, :autonomy,
|
|
20
|
+
:protected_paths, :criteria, :waivers, :support_matrix, :security_controls, :taken_at
|
|
21
|
+
|
|
22
|
+
def initialize(repository:, intent:, profile:, security_posture:, autonomy: {},
|
|
23
|
+
protected_paths: [], criteria: [], waivers: [], support_matrix: [],
|
|
24
|
+
security_controls: [], origin: nil, taken_at: nil)
|
|
25
|
+
@repository = immutable(repository)
|
|
26
|
+
@origin = immutable(origin)
|
|
27
|
+
@intent = intent
|
|
28
|
+
@profile = profile
|
|
29
|
+
@security_posture = security_posture
|
|
30
|
+
@autonomy = immutable(autonomy)
|
|
31
|
+
@protected_paths = immutable(protected_paths)
|
|
32
|
+
@criteria = immutable(criteria)
|
|
33
|
+
@waivers = immutable(waivers)
|
|
34
|
+
@support_matrix = immutable(support_matrix)
|
|
35
|
+
@security_controls = immutable(security_controls)
|
|
36
|
+
@taken_at = taken_at.is_a?(Time) ? taken_at.iso8601 : taken_at
|
|
37
|
+
freeze
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# A snapshot records what was true at a moment, so it must not be editable
|
|
41
|
+
# afterwards - and `freeze` alone does not achieve that, because it leaves
|
|
42
|
+
# the recorded arrays and hashes writable. Copies are frozen rather than
|
|
43
|
+
# the caller's own collections, so recording a contract never freezes the
|
|
44
|
+
# live contract.
|
|
45
|
+
def immutable(value)
|
|
46
|
+
case value
|
|
47
|
+
when Hash then value.transform_values { |entry| immutable(entry) }.freeze
|
|
48
|
+
when Array then value.map { |entry| immutable(entry) }.freeze
|
|
49
|
+
when String then value.frozen? ? value : value.dup.freeze
|
|
50
|
+
else value
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
private :immutable
|
|
54
|
+
|
|
55
|
+
# @param contract [EffectiveContract]
|
|
56
|
+
# @return [ContractSnapshot]
|
|
57
|
+
def self.of(contract, taken_at: Time.now)
|
|
58
|
+
discovery = contract.discovery
|
|
59
|
+
|
|
60
|
+
new(
|
|
61
|
+
repository: contract.repository.full_name,
|
|
62
|
+
origin: Git.origin_url(contract.repository.path),
|
|
63
|
+
intent: contract.intent.to_sym,
|
|
64
|
+
profile: contract.profile.id,
|
|
65
|
+
security_posture: contract.security_posture.to_sym,
|
|
66
|
+
autonomy: contract.autonomy,
|
|
67
|
+
protected_paths: contract.protected_paths,
|
|
68
|
+
criteria: contract.criteria.map { |criterion| record_criterion(criterion) },
|
|
69
|
+
waivers: contract.waivers.map { |waiver| record_waiver(waiver) },
|
|
70
|
+
support_matrix: discovered_matrix(discovery),
|
|
71
|
+
security_controls: discovered_controls(discovery),
|
|
72
|
+
taken_at: taken_at
|
|
73
|
+
)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def self.record_criterion(criterion)
|
|
77
|
+
{id: criterion.id, evaluator: criterion.evaluator, run: criterion.run, required: criterion.required?}
|
|
78
|
+
end
|
|
79
|
+
private_class_method :record_criterion
|
|
80
|
+
|
|
81
|
+
def self.record_waiver(waiver)
|
|
82
|
+
{target: waiver.target, expires: waiver.expires&.iso8601}
|
|
83
|
+
end
|
|
84
|
+
private_class_method :record_waiver
|
|
85
|
+
|
|
86
|
+
# What CI actually exercises, falling back to what the project declares.
|
|
87
|
+
def self.discovered_matrix(discovery)
|
|
88
|
+
return [] unless discovery
|
|
89
|
+
|
|
90
|
+
Array(discovery.value(:ci_ruby_matrix) || discovery.value(:supported_ruby)).map(&:to_s)
|
|
91
|
+
end
|
|
92
|
+
private_class_method :discovered_matrix
|
|
93
|
+
|
|
94
|
+
def self.discovered_controls(discovery)
|
|
95
|
+
return [] unless discovery
|
|
96
|
+
|
|
97
|
+
discovery.in_category(:security_controls).map(&:key).sort
|
|
98
|
+
end
|
|
99
|
+
private_class_method :discovered_controls
|
|
100
|
+
|
|
101
|
+
# Rebuilds a snapshot from its serialized form, tolerating the string keys
|
|
102
|
+
# a JSON round-trip produces.
|
|
103
|
+
def self.from_h(hash)
|
|
104
|
+
data = symbolize(hash)
|
|
105
|
+
unless data.is_a?(Hash) && data.key?(:repository)
|
|
106
|
+
raise ContractError,
|
|
107
|
+
"That is not a contract snapshot; expected the JSON object written by `reanimator drift --save`."
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
new(
|
|
111
|
+
repository: data[:repository],
|
|
112
|
+
origin: data[:origin],
|
|
113
|
+
intent: data[:intent]&.to_sym,
|
|
114
|
+
profile: data[:profile]&.to_sym,
|
|
115
|
+
security_posture: data[:security_posture]&.to_sym,
|
|
116
|
+
autonomy: (data[:autonomy] || {}).transform_values(&:to_sym),
|
|
117
|
+
protected_paths: Array(data[:protected_paths]).map(&:to_s),
|
|
118
|
+
criteria: Array(data[:criteria]).map { |criterion| restore_criterion(criterion) },
|
|
119
|
+
waivers: Array(data[:waivers]).map { |waiver| restore_waiver(waiver) },
|
|
120
|
+
support_matrix: Array(data[:support_matrix]).map(&:to_s),
|
|
121
|
+
security_controls: Array(data[:security_controls]).map(&:to_sym),
|
|
122
|
+
taken_at: data[:taken_at]
|
|
123
|
+
)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def self.restore_criterion(criterion)
|
|
127
|
+
{
|
|
128
|
+
id: criterion[:id]&.to_sym, evaluator: criterion[:evaluator]&.to_sym,
|
|
129
|
+
run: criterion[:run], required: criterion[:required]
|
|
130
|
+
}
|
|
131
|
+
end
|
|
132
|
+
private_class_method :restore_criterion
|
|
133
|
+
|
|
134
|
+
def self.restore_waiver(waiver)
|
|
135
|
+
{target: waiver[:target]&.to_sym, expires: waiver[:expires]}
|
|
136
|
+
end
|
|
137
|
+
private_class_method :restore_waiver
|
|
138
|
+
|
|
139
|
+
def self.load(path)
|
|
140
|
+
from_h(JSON.parse(File.read(path, encoding: "UTF-8")))
|
|
141
|
+
rescue JSON::ParserError => e
|
|
142
|
+
raise ContractError, "#{path} is not a valid contract snapshot: #{e.message}"
|
|
143
|
+
rescue Errno::ENOENT
|
|
144
|
+
raise ContractError, "No contract snapshot at #{path}."
|
|
145
|
+
rescue SystemCallError => e
|
|
146
|
+
raise ContractError, "Could not read the contract snapshot at #{path}: #{e.message}"
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def self.symbolize(value)
|
|
150
|
+
case value
|
|
151
|
+
when Hash then value.each_with_object({}) { |(k, v), h| h[k.to_sym] = symbolize(v) }
|
|
152
|
+
when Array then value.map { |v| symbolize(v) }
|
|
153
|
+
else value
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
private_class_method :symbolize
|
|
157
|
+
|
|
158
|
+
# The criterion recorded under an id, or nil.
|
|
159
|
+
def criterion(id)
|
|
160
|
+
criteria.find { |c| c[:id] == id }
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def criterion_ids = criteria.map { |c| c[:id] }
|
|
164
|
+
|
|
165
|
+
# Whether a recorded criterion was blocking. Asked rather than dug out of
|
|
166
|
+
# the criterion hash, so callers never have to know its shape.
|
|
167
|
+
def requires?(id) = criterion(id)&.fetch(:required, false) == true
|
|
168
|
+
|
|
169
|
+
def waiver_targets = waivers.map { |w| w[:target] }
|
|
170
|
+
|
|
171
|
+
# When accepted risk for a target runs out, or nil for open-ended.
|
|
172
|
+
def waiver_expiry(target)
|
|
173
|
+
waivers.find { |waiver| waiver[:target] == target }&.[](:expires)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def to_h
|
|
177
|
+
{
|
|
178
|
+
repository: repository, origin: origin,
|
|
179
|
+
taken_at: taken_at,
|
|
180
|
+
intent: intent, profile: profile, security_posture: security_posture,
|
|
181
|
+
autonomy: autonomy, protected_paths: protected_paths, criteria: criteria,
|
|
182
|
+
waivers: waivers, support_matrix: support_matrix, security_controls: security_controls
|
|
183
|
+
}
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def to_json(*args) = JSON.pretty_generate(to_h, *args)
|
|
187
|
+
end
|
|
188
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reanimator
|
|
4
|
+
# Assembles the acceptance criteria for one repository out of the layers
|
|
5
|
+
# allowed to contribute them (specification section 07). Three layers speak:
|
|
6
|
+
# the vitality profile's defaults, the security posture's obligations, and
|
|
7
|
+
# the owner's declared checks. They are *merged*, not concatenated, so a
|
|
8
|
+
# layer naming an existing criterion id replaces it rather than arguing with
|
|
9
|
+
# it twice in the same report - and the owner's declaration wins.
|
|
10
|
+
#
|
|
11
|
+
# This is deliberately not part of {EffectivePolicyResolver}: resolving the
|
|
12
|
+
# precedence stack and assembling the list of things to prove change for
|
|
13
|
+
# different reasons, and adding a fourth layer should not mean reopening the
|
|
14
|
+
# resolver.
|
|
15
|
+
class CriteriaStack
|
|
16
|
+
def initialize(profile:, posture:, discovery:, declared:)
|
|
17
|
+
@profile = profile
|
|
18
|
+
@posture = posture
|
|
19
|
+
@discovery = discovery
|
|
20
|
+
@declared = declared
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @return [Array<AcceptanceCriterion>]
|
|
24
|
+
def resolve
|
|
25
|
+
merge(profile_criteria, security_criteria, declared_criteria)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
attr_reader :profile, :posture, :discovery, :declared
|
|
31
|
+
|
|
32
|
+
# Keeps each criterion in the position it first appeared, so reports stay
|
|
33
|
+
# stable, while the last layer to claim an id supplies the criterion.
|
|
34
|
+
def merge(*layers)
|
|
35
|
+
layers.flatten.each_with_object({}) { |criterion, merged| merged[criterion.id] = criterion }.values
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Profile criteria, with discovered commands substituted in for the
|
|
39
|
+
# matching defaults (section 00: reuse detected checks; surface missing
|
|
40
|
+
# proof).
|
|
41
|
+
def profile_criteria
|
|
42
|
+
commands = discovery.discovered_commands
|
|
43
|
+
profile.resolved_criteria.map do |criterion|
|
|
44
|
+
run = commands[criterion.id]
|
|
45
|
+
run ? with_run(criterion, run) : criterion
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def with_run(criterion, run)
|
|
50
|
+
AcceptanceCriterion.new(
|
|
51
|
+
id: criterion.id, claim: criterion.claim, evaluator: criterion.evaluator,
|
|
52
|
+
run: run, target: criterion.target, required: criterion.required,
|
|
53
|
+
scope: criterion.scope, freshness: criterion.freshness, owner: criterion.owner,
|
|
54
|
+
risk_class: criterion.risk_class,
|
|
55
|
+
provenance: discovery.command_fact(criterion.id)&.provenance
|
|
56
|
+
)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# The security evidence the posture makes binding (section 12). These are
|
|
60
|
+
# policy-owned rather than profile-owned: how much security proof a
|
|
61
|
+
# repository must show is a posture decision, not a question of what shape
|
|
62
|
+
# the project happens to be.
|
|
63
|
+
def security_criteria
|
|
64
|
+
posture.obligations.map do |control_id, required|
|
|
65
|
+
control = SecurityControl.fetch(control_id)
|
|
66
|
+
AcceptanceCriterion.new(
|
|
67
|
+
id: control.criterion_id, claim: control.claim,
|
|
68
|
+
evaluator: :security_evidence, target: control.to_s,
|
|
69
|
+
required: required, owner: "policy"
|
|
70
|
+
)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Owner-declared acceptance checks become criteria owned by the repository.
|
|
75
|
+
# A criterion that edits CI/verifier surfaces is elevated by policy.
|
|
76
|
+
def declared_criteria
|
|
77
|
+
checks = declared&.acceptance&.dig(:checks) || []
|
|
78
|
+
checks.map do |check|
|
|
79
|
+
AcceptanceCriterion.new(
|
|
80
|
+
id: check[:id] || "declared-#{check.object_id}",
|
|
81
|
+
claim: check[:claim] || "Owner-declared check #{check[:id]}.",
|
|
82
|
+
evaluator: check[:evaluator] || :command,
|
|
83
|
+
run: check[:run], target: check[:target],
|
|
84
|
+
required: check.fetch(:required, true),
|
|
85
|
+
owner: "repository",
|
|
86
|
+
risk_class: check[:risk_class] || :normal
|
|
87
|
+
)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reanimator
|
|
4
|
+
# The outcome of evaluating one acceptance criterion during a baseline. Its
|
|
5
|
+
# status is drawn from the vocabulary section 14 requires a baseline to
|
|
6
|
+
# separate: verified claims, failed claims, missing evidence, skipped
|
|
7
|
+
# boundaries, and accepted risks - plus "planned" for the read-only,
|
|
8
|
+
# non-executing default.
|
|
9
|
+
class CriterionResult
|
|
10
|
+
STATUSES = %i[verified failed missing_evidence skipped accepted_risk planned error].freeze
|
|
11
|
+
|
|
12
|
+
attr_reader :criterion, :status, :detail, :evidence, :duration
|
|
13
|
+
|
|
14
|
+
def initialize(criterion:, status:, detail: nil, evidence: nil, duration: nil)
|
|
15
|
+
@criterion = criterion
|
|
16
|
+
@status = status.to_sym
|
|
17
|
+
unless STATUSES.include?(@status)
|
|
18
|
+
raise UnknownError, "Unknown criterion result status #{status.inspect}."
|
|
19
|
+
end
|
|
20
|
+
@detail = detail
|
|
21
|
+
@evidence = evidence
|
|
22
|
+
@duration = duration
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def verified? = status == :verified
|
|
26
|
+
|
|
27
|
+
def failed? = status == :failed
|
|
28
|
+
|
|
29
|
+
# A required criterion whose failure has been explicitly accepted via a
|
|
30
|
+
# waiver: not verified, but not blocking either.
|
|
31
|
+
def accepted_risk? = status == :accepted_risk
|
|
32
|
+
|
|
33
|
+
# Whether this result contributes proof (a verified required criterion).
|
|
34
|
+
def proven? = verified?
|
|
35
|
+
|
|
36
|
+
# Whether the criterion is still unproven: no evidence either way yet.
|
|
37
|
+
# An evaluator that errored belongs here too - "we could not tell" is not
|
|
38
|
+
# proof, and leaving it out would let a broken check read as green.
|
|
39
|
+
def unproven? = %i[missing_evidence planned skipped error].include?(status)
|
|
40
|
+
|
|
41
|
+
def required? = criterion.required?
|
|
42
|
+
|
|
43
|
+
# Whether this result is eligible to be waived: a required obligation that
|
|
44
|
+
# is not proven and has not already been accepted.
|
|
45
|
+
def waivable? = required? && !verified? && !accepted_risk?
|
|
46
|
+
|
|
47
|
+
# A copy of this result recorded as accepted risk under the given waiver.
|
|
48
|
+
# The criterion is still not verified - its failure is explicitly accepted.
|
|
49
|
+
#
|
|
50
|
+
# @param waiver [Waiver]
|
|
51
|
+
# @return [CriterionResult]
|
|
52
|
+
def accepted_via(waiver)
|
|
53
|
+
CriterionResult.new(
|
|
54
|
+
criterion: criterion, status: :accepted_risk,
|
|
55
|
+
detail: waiver.acceptance_of(self), evidence: evidence, duration: duration
|
|
56
|
+
)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def to_h
|
|
60
|
+
{
|
|
61
|
+
id: criterion.id,
|
|
62
|
+
claim: criterion.claim,
|
|
63
|
+
required: criterion.required?,
|
|
64
|
+
status: status,
|
|
65
|
+
detail: detail,
|
|
66
|
+
duration: duration
|
|
67
|
+
}.compact
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reanimator
|
|
4
|
+
module Detectors
|
|
5
|
+
# Base class for every detector. A detector reads a repository through the
|
|
6
|
+
# read-only helpers on {Repository} and returns an array of
|
|
7
|
+
# {DiscoveredFact}s, each carrying its own provenance and confidence. It
|
|
8
|
+
# never decides policy - it only proposes facts for a human to confirm.
|
|
9
|
+
class Base
|
|
10
|
+
# @return [Repository]
|
|
11
|
+
attr_reader :repository
|
|
12
|
+
|
|
13
|
+
def initialize(repository)
|
|
14
|
+
@repository = repository
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# @return [Array<DiscoveredFact>]
|
|
18
|
+
def call
|
|
19
|
+
detect
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# @return [Array<DiscoveredFact>]
|
|
23
|
+
def detect
|
|
24
|
+
raise NotImplementedError, "#{self.class} must implement #detect"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
# Builds a discovered fact with a provenance record in one call - the
|
|
30
|
+
# shape every detector uses.
|
|
31
|
+
def fact(key, category:, claim:, source:, method:, value: true, confidence: :medium, detail: nil)
|
|
32
|
+
DiscoveredFact.new(
|
|
33
|
+
key: key,
|
|
34
|
+
category: category,
|
|
35
|
+
claim: claim,
|
|
36
|
+
value: value,
|
|
37
|
+
confidence: confidence,
|
|
38
|
+
provenance: Provenance.new(source: source, method: method, detail: detail)
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Convenience: the first repository-relative path matching any of the
|
|
43
|
+
# given globs, or nil.
|
|
44
|
+
def first_path(*patterns)
|
|
45
|
+
patterns.flat_map { |pattern| repository.glob(pattern) }.first
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reanimator
|
|
4
|
+
module Detectors
|
|
5
|
+
# Detects the Ruby/Bundler ecosystem and the supported-runtime matrix
|
|
6
|
+
# (specification section 06: Ecosystem and Support matrix). Evidence is the
|
|
7
|
+
# presence of a Gemfile, a lockfile, and any runtime pin - .ruby-version,
|
|
8
|
+
# a gemspec `required_ruby_version`, or a Gemfile `ruby` directive.
|
|
9
|
+
class Bundler < Base
|
|
10
|
+
RUBY_VERSION_PIN = /required_ruby_version\s*=?\s*['"]?([^'"]+)['"]?/
|
|
11
|
+
GEMFILE_RUBY = /^\s*ruby\s+['"]([^'"]+)['"]/
|
|
12
|
+
|
|
13
|
+
# A single declared runtime version paired with the file it came from.
|
|
14
|
+
Pin = Struct.new(:version, :source)
|
|
15
|
+
|
|
16
|
+
def detect
|
|
17
|
+
return [] unless repository.exist?("Gemfile") || repository.any?("*.gemspec")
|
|
18
|
+
|
|
19
|
+
facts = [
|
|
20
|
+
fact(:bundler, category: :ecosystem,
|
|
21
|
+
claim: "Managed with Bundler.",
|
|
22
|
+
confidence: :high, source: gemfile_or_gemspec, method: :file_present)
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
if repository.exist?("Gemfile.lock")
|
|
26
|
+
facts << fact(:locked_dependencies, category: :ecosystem,
|
|
27
|
+
claim: "Dependencies are locked (Gemfile.lock present).",
|
|
28
|
+
confidence: :high, source: "Gemfile.lock", method: :file_present)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
pins = runtime_pins
|
|
32
|
+
unless pins.empty?
|
|
33
|
+
versions = pins.map(&:version)
|
|
34
|
+
facts << fact(:supported_ruby, category: :support_matrix,
|
|
35
|
+
claim: "Declared Ruby support: #{versions.join(", ")}.",
|
|
36
|
+
value: versions, confidence: :medium,
|
|
37
|
+
source: pins.first.source, method: :version_pin, detail: versions.join(", "))
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
facts
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def gemfile_or_gemspec
|
|
46
|
+
repository.exist?("Gemfile") ? "Gemfile" : first_path("*.gemspec")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# @return [Array<Pin>] de-duplicated runtime pins, strongest source first
|
|
50
|
+
def runtime_pins
|
|
51
|
+
pins = []
|
|
52
|
+
|
|
53
|
+
if (rv = repository.read(".ruby-version"))
|
|
54
|
+
pins << Pin.new(rv.strip, ".ruby-version")
|
|
55
|
+
end
|
|
56
|
+
if (gemspec = first_path("*.gemspec")) && (body = repository.read(gemspec)) &&
|
|
57
|
+
(m = body.match(RUBY_VERSION_PIN))
|
|
58
|
+
pins << Pin.new(m[1].strip, gemspec)
|
|
59
|
+
end
|
|
60
|
+
if (gemfile = repository.read("Gemfile")) && (m = gemfile.match(GEMFILE_RUBY))
|
|
61
|
+
pins << Pin.new(m[1].strip, "Gemfile")
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
pins.uniq(&:version)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reanimator
|
|
4
|
+
module Detectors
|
|
5
|
+
# Discovers the commands a repository already uses to prove itself
|
|
6
|
+
# (specification section 06: Commands). The point is that users should
|
|
7
|
+
# "confirm facts rather than transcribe their own CI" - so this detector
|
|
8
|
+
# reads Rakefiles, lint configuration, gemspecs, and package.json scripts
|
|
9
|
+
# and reports the install/test/lint/build commands it finds. Each command
|
|
10
|
+
# fact later overrides the matching profile default (section 00: reuse
|
|
11
|
+
# detected checks; surface missing proof).
|
|
12
|
+
class Commands < Base
|
|
13
|
+
def detect
|
|
14
|
+
[
|
|
15
|
+
dependencies_command,
|
|
16
|
+
test_command,
|
|
17
|
+
lint_command,
|
|
18
|
+
build_command,
|
|
19
|
+
assets_command
|
|
20
|
+
].compact
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def dependencies_command
|
|
26
|
+
return unless repository.exist?("Gemfile")
|
|
27
|
+
|
|
28
|
+
command(:dependencies_command, "bundle check",
|
|
29
|
+
claim: "Dependency resolution is verified with `bundle check`.",
|
|
30
|
+
source: "Gemfile")
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def test_command
|
|
34
|
+
if rspec?
|
|
35
|
+
command(:test_command, "bundle exec rspec",
|
|
36
|
+
claim: "Tests run with RSpec.", source: rspec_source, confidence: :high)
|
|
37
|
+
elsif repository.exist?("test") || repository.any?("test/**/*_test.rb")
|
|
38
|
+
command(:test_command, "bundle exec rake test",
|
|
39
|
+
claim: "Tests run with the default rake test task.", source: "test/")
|
|
40
|
+
elsif (script = package_script("test"))
|
|
41
|
+
command(:test_command, script,
|
|
42
|
+
claim: "Tests run via the package.json test script.", source: "package.json")
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def lint_command
|
|
47
|
+
if repository.exist?(".standard.yml") || gemfile_mentions?("standard")
|
|
48
|
+
command(:lint_command, "bundle exec standardrb",
|
|
49
|
+
claim: "Lint runs with Standard.", source: standard_source, confidence: :high)
|
|
50
|
+
elsif repository.exist?(".rubocop.yml")
|
|
51
|
+
command(:lint_command, "bundle exec rubocop",
|
|
52
|
+
claim: "Lint runs with RuboCop.", source: ".rubocop.yml", confidence: :high)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def build_command
|
|
57
|
+
gemspec = first_path("*.gemspec")
|
|
58
|
+
return unless gemspec
|
|
59
|
+
|
|
60
|
+
command(:build_command, "gem build #{gemspec}",
|
|
61
|
+
claim: "Gem builds with `gem build`.", source: gemspec, confidence: :high)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def assets_command
|
|
65
|
+
script = package_script("build")
|
|
66
|
+
return unless script
|
|
67
|
+
|
|
68
|
+
command(:assets_command, script,
|
|
69
|
+
claim: "Assets build via the package.json build script.", source: "package.json")
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# --- helpers -------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
def command(key, value, claim:, source:, confidence: :medium)
|
|
75
|
+
fact(key, category: :commands, claim: claim, value: value,
|
|
76
|
+
confidence: confidence, source: source, method: :command_inference, detail: value)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def rspec?
|
|
80
|
+
repository.exist?(".rspec") || repository.exist?("spec") || repository.any?("spec/**/*_spec.rb")
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def rspec_source
|
|
84
|
+
repository.exist?(".rspec") ? ".rspec" : "spec/"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def standard_source
|
|
88
|
+
repository.exist?(".standard.yml") ? ".standard.yml" : "Gemfile"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def gemfile_mentions?(gem)
|
|
92
|
+
body = repository.read("Gemfile").to_s + repository.read("Gemfile.lock").to_s
|
|
93
|
+
body.include?(gem)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def package_script(name)
|
|
97
|
+
body = repository.read("package.json")
|
|
98
|
+
return unless body
|
|
99
|
+
|
|
100
|
+
require "json"
|
|
101
|
+
scripts = JSON.parse(body).fetch("scripts", {})
|
|
102
|
+
script = scripts[name]
|
|
103
|
+
return unless script
|
|
104
|
+
|
|
105
|
+
# Prefer the yarn/npm indirection so the discovered command is portable.
|
|
106
|
+
repository.exist?("yarn.lock") ? "yarn #{name}" : "npm run #{name}"
|
|
107
|
+
rescue JSON::ParserError
|
|
108
|
+
nil
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reanimator
|
|
4
|
+
module Detectors
|
|
5
|
+
# Detects that a repository is a fork and, when possible, its upstream
|
|
6
|
+
# (specification section 04 cohorts and section 10 relationships). A fork
|
|
7
|
+
# is classified differently from an active original - it may be upstream
|
|
8
|
+
# observation, divergence monitoring, or a candidate to retire - so knowing
|
|
9
|
+
# the fork relationship early shapes the entire onboarding default.
|
|
10
|
+
class Fork < Base
|
|
11
|
+
def detect
|
|
12
|
+
upstream = Git.capture(repository.path, "config", "--get", "remote.upstream.url")
|
|
13
|
+
return [] unless repository.fork? || upstream
|
|
14
|
+
|
|
15
|
+
upstream_name = RepositoryMetadata.full_name_from_url(upstream)
|
|
16
|
+
claim = upstream_name ? "A fork of #{upstream_name}." : "A fork with a configured upstream remote."
|
|
17
|
+
|
|
18
|
+
[fact(:fork_of, category: :relationships,
|
|
19
|
+
claim: claim, value: upstream_name, confidence: :high,
|
|
20
|
+
source: upstream ? "remote.upstream" : "git metadata", method: :git_metadata,
|
|
21
|
+
detail: upstream_name)]
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reanimator
|
|
4
|
+
module Detectors
|
|
5
|
+
# Reads .github/workflows/*.yml for the evidence CI already provides
|
|
6
|
+
# (specification section 06: Commands, Support matrix, Security controls,
|
|
7
|
+
# Operational proof). Reanimator's job is to surface what a maintainer
|
|
8
|
+
# has already proven, so a project with a Ruby matrix and a
|
|
9
|
+
# dependency-review action should never be asked to re-declare either.
|
|
10
|
+
class GitHubActions < Base
|
|
11
|
+
# Action fingerprints -> the security control they demonstrate.
|
|
12
|
+
SECURITY_ACTIONS = {
|
|
13
|
+
"actions/dependency-review-action" => "Dependency review",
|
|
14
|
+
"github/codeql-action" => "CodeQL SAST",
|
|
15
|
+
"gitleaks" => "Secret scanning (gitleaks)",
|
|
16
|
+
"trufflehog" => "Secret scanning (trufflehog)",
|
|
17
|
+
"brakeman" => "Brakeman SAST",
|
|
18
|
+
"bundler-audit" => "Bundler dependency audit",
|
|
19
|
+
"bundle audit" => "Bundler dependency audit"
|
|
20
|
+
}.freeze
|
|
21
|
+
|
|
22
|
+
RUBY_MATRIX = /ruby(?:-version)?\s*:\s*\[([^\]]+)\]/
|
|
23
|
+
|
|
24
|
+
def detect
|
|
25
|
+
workflows = repository.glob(".github/workflows/*.yml") + repository.glob(".github/workflows/*.yaml")
|
|
26
|
+
return [] if workflows.empty?
|
|
27
|
+
|
|
28
|
+
facts = [
|
|
29
|
+
fact(:ci_github_actions, category: :operational_proof,
|
|
30
|
+
claim: "Continuous integration runs on GitHub Actions.",
|
|
31
|
+
value: workflows, confidence: :high, source: workflows.first, method: :file_present)
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
facts.concat(ruby_matrix_facts(workflows))
|
|
35
|
+
facts.concat(security_facts(workflows))
|
|
36
|
+
facts
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def ruby_matrix_facts(workflows)
|
|
42
|
+
workflows.filter_map do |path|
|
|
43
|
+
body = repository.read(path)
|
|
44
|
+
next unless body && (m = body.match(RUBY_MATRIX))
|
|
45
|
+
|
|
46
|
+
versions = m[1].split(",").map { |v| v.strip.delete("\"'") }.reject(&:empty?)
|
|
47
|
+
next if versions.empty?
|
|
48
|
+
|
|
49
|
+
fact(:ci_ruby_matrix, category: :support_matrix,
|
|
50
|
+
claim: "CI exercises Ruby: #{versions.join(", ")}.",
|
|
51
|
+
value: versions, confidence: :high, source: path, method: :yaml_matrix, detail: versions.join(", "))
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def security_facts(workflows)
|
|
56
|
+
text = workflows.map { |path| repository.read(path).to_s }.join("\n")
|
|
57
|
+
SECURITY_ACTIONS.filter_map do |marker, description|
|
|
58
|
+
next unless text.include?(marker)
|
|
59
|
+
|
|
60
|
+
key = description.downcase.gsub(/[^a-z]+/, "_").gsub(/\A_|_\z/, "")
|
|
61
|
+
fact(:"security_#{key}", category: :security_controls,
|
|
62
|
+
claim: "Security control present: #{description}.",
|
|
63
|
+
confidence: :high, source: ".github/workflows", method: :action_fingerprint, detail: marker)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|