rail_verdict 1.0.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/LICENSE +21 -0
- data/NOTICE +14 -0
- data/README.md +192 -0
- data/exe/railverdict +6 -0
- data/exe/railverdict-minitest-reporter.rb +90 -0
- data/lib/rail_verdict/analyzers/_shared.rb +54 -0
- data/lib/rail_verdict/analyzers/bundler_audit.rb +186 -0
- data/lib/rail_verdict/analyzers/minitest.rb +312 -0
- data/lib/rail_verdict/analyzers/rspec.rb +274 -0
- data/lib/rail_verdict/analyzers/rubocop.rb +289 -0
- data/lib/rail_verdict/analyzers/simplecov.rb +178 -0
- data/lib/rail_verdict/baseline.rb +151 -0
- data/lib/rail_verdict/check.rb +415 -0
- data/lib/rail_verdict/cli.rb +471 -0
- data/lib/rail_verdict/comparison.rb +216 -0
- data/lib/rail_verdict/configuration.rb +165 -0
- data/lib/rail_verdict/contracts/analyzer_result.rb +138 -0
- data/lib/rail_verdict/contracts/finding.rb +154 -0
- data/lib/rail_verdict/contracts/gate_result.rb +171 -0
- data/lib/rail_verdict/coverage/changed_line_evaluator.rb +70 -0
- data/lib/rail_verdict/doctor.rb +132 -0
- data/lib/rail_verdict/errors.rb +17 -0
- data/lib/rail_verdict/findings_command.rb +24 -0
- data/lib/rail_verdict/fingerprint.rb +72 -0
- data/lib/rail_verdict/git/command.rb +48 -0
- data/lib/rail_verdict/git/context.rb +361 -0
- data/lib/rail_verdict/git/diff_parser.rb +200 -0
- data/lib/rail_verdict/git/errors.rb +16 -0
- data/lib/rail_verdict/init.rb +38 -0
- data/lib/rail_verdict/intelligence/ai_analysis.rb +82 -0
- data/lib/rail_verdict/intelligence/ai_failure.rb +44 -0
- data/lib/rail_verdict/intelligence/budget.rb +36 -0
- data/lib/rail_verdict/intelligence/cache.rb +74 -0
- data/lib/rail_verdict/intelligence/context_builder.rb +92 -0
- data/lib/rail_verdict/intelligence/manifest.rb +70 -0
- data/lib/rail_verdict/intelligence/orchestrator.rb +77 -0
- data/lib/rail_verdict/intelligence/prompt.rb +28 -0
- data/lib/rail_verdict/intelligence/provider.rb +14 -0
- data/lib/rail_verdict/intelligence/providers/fake_provider.rb +31 -0
- data/lib/rail_verdict/intelligence/providers/openai_compat.rb +119 -0
- data/lib/rail_verdict/intelligence/redactor.rb +51 -0
- data/lib/rail_verdict/intelligence/secret_detector.rb +45 -0
- data/lib/rail_verdict/intelligence/source_reader.rb +49 -0
- data/lib/rail_verdict/intelligence.rb +11 -0
- data/lib/rail_verdict/mcp/cache.rb +233 -0
- data/lib/rail_verdict/mcp/repository_root.rb +31 -0
- data/lib/rail_verdict/mcp/serializers.rb +98 -0
- data/lib/rail_verdict/mcp/server.rb +110 -0
- data/lib/rail_verdict/mcp/tools/build_repair_packet.rb +85 -0
- data/lib/rail_verdict/mcp/tools/explain.rb +100 -0
- data/lib/rail_verdict/mcp/tools/get_finding.rb +139 -0
- data/lib/rail_verdict/mcp/tools/investigate.rb +107 -0
- data/lib/rail_verdict/mcp/tools/list_findings.rb +119 -0
- data/lib/rail_verdict/mcp/tools/verify.rb +137 -0
- data/lib/rail_verdict/mcp/tools/verify_repair.rb +105 -0
- data/lib/rail_verdict/mcp/validators.rb +81 -0
- data/lib/rail_verdict/mcp.rb +7 -0
- data/lib/rail_verdict/process_runner.rb +292 -0
- data/lib/rail_verdict/rails_context/classifier.rb +57 -0
- data/lib/rail_verdict/rails_context/confidence.rb +13 -0
- data/lib/rail_verdict/rails_context/constant_inferencer.rb +56 -0
- data/lib/rail_verdict/rails_context/context.rb +115 -0
- data/lib/rail_verdict/rails_context/detector.rb +141 -0
- data/lib/rail_verdict/rails_context/limits.rb +17 -0
- data/lib/rail_verdict/rails_context/resolvers/association_extractor.rb +48 -0
- data/lib/rail_verdict/rails_context/resolvers/policy_resolver.rb +45 -0
- data/lib/rail_verdict/rails_context/resolvers/route_resolver.rb +40 -0
- data/lib/rail_verdict/rails_context/resolvers/route_scanner.rb +55 -0
- data/lib/rail_verdict/rails_context/resolvers/schema_resolver.rb +90 -0
- data/lib/rail_verdict/rails_context/resolvers/test_candidates.rb +75 -0
- data/lib/rail_verdict/rails_context/resolvers/view_resolver.rb +45 -0
- data/lib/rail_verdict/rails_context/schema_parser.rb +50 -0
- data/lib/rail_verdict/rails_context.rb +21 -0
- data/lib/rail_verdict/repair/boundary.rb +36 -0
- data/lib/rail_verdict/repair/command.rb +89 -0
- data/lib/rail_verdict/repair/constraints.rb +31 -0
- data/lib/rail_verdict/repair/context_assembler.rb +301 -0
- data/lib/rail_verdict/repair/diff_context.rb +51 -0
- data/lib/rail_verdict/repair/packet.rb +158 -0
- data/lib/rail_verdict/repair/prompt_renderer.rb +27 -0
- data/lib/rail_verdict/repair/verification_plan.rb +45 -0
- data/lib/rail_verdict/repair/verifier.rb +84 -0
- data/lib/rail_verdict/reporters/console.rb +107 -0
- data/lib/rail_verdict/reporters/github_annotations.rb +104 -0
- data/lib/rail_verdict/reporters/json_reporter.rb +19 -0
- data/lib/rail_verdict/reporters/sarif.rb +138 -0
- data/lib/rail_verdict/run_context.rb +99 -0
- data/lib/rail_verdict/schema_validator.rb +85 -0
- data/lib/rail_verdict/strict_yaml.rb +134 -0
- data/lib/rail_verdict/verification/policy.rb +359 -0
- data/lib/rail_verdict/version.rb +5 -0
- data/lib/rail_verdict/waiver.rb +67 -0
- data/lib/rail_verdict/waiver_store.rb +59 -0
- data/lib/rail_verdict.rb +64 -0
- data/schemas/ai-analysis-v1.schema.json +54 -0
- data/schemas/baseline-v1.schema.json +108 -0
- data/schemas/configuration-v1.1.schema.json +150 -0
- data/schemas/configuration-v1.2.schema.json +182 -0
- data/schemas/configuration-v1.3.schema.json +198 -0
- data/schemas/configuration-v1.4.schema.json +146 -0
- data/schemas/configuration-v1.schema.json +72 -0
- data/schemas/coverage-v1.schema.json +51 -0
- data/schemas/finding-v1.schema.json +131 -0
- data/schemas/minitest-reporter-v1.schema.json +113 -0
- data/schemas/repair-packet-v1.schema.json +364 -0
- data/schemas/result-v1.schema.json +378 -0
- data/schemas/waiver-v1.schema.json +45 -0
- data/schemas/waivers-v1.schema.json +67 -0
- metadata +192 -0
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module RailVerdict
|
|
6
|
+
class Configuration
|
|
7
|
+
DEFAULT_FILENAME = ".railverdict.yml"
|
|
8
|
+
CONFIGURATION_VERSION = 1.4
|
|
9
|
+
SUPPORTED_VERSIONS = [1, 1.1, 1.2, 1.3, 1.4].freeze
|
|
10
|
+
UTF8_BOM = "\xEF\xBB\xBF".b
|
|
11
|
+
|
|
12
|
+
attr_reader :version, :mode, :analyzers, :source_path, :digest
|
|
13
|
+
|
|
14
|
+
def self.load(path)
|
|
15
|
+
source_path = path.to_s
|
|
16
|
+
unless File.file?(source_path)
|
|
17
|
+
raise ConfigurationError.new(
|
|
18
|
+
"configuration file not found: #{source_path}",
|
|
19
|
+
source_path: source_path
|
|
20
|
+
)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
bytes = File.binread(source_path)
|
|
24
|
+
text = decode_utf8(bytes, source_path)
|
|
25
|
+
data = load_strict_yaml(text, source_path)
|
|
26
|
+
validate_schema(data, source_path)
|
|
27
|
+
new(data, source_path, bytes)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.decode_utf8(bytes, source_path)
|
|
31
|
+
if bytes.start_with?(UTF8_BOM)
|
|
32
|
+
raise ConfigurationError.new(
|
|
33
|
+
"#{source_path}: configuration must be UTF-8 without a byte order mark",
|
|
34
|
+
source_path: source_path
|
|
35
|
+
)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
text = bytes.dup.force_encoding(Encoding::UTF_8)
|
|
39
|
+
unless text.valid_encoding?
|
|
40
|
+
raise ConfigurationError.new(
|
|
41
|
+
"#{source_path}: configuration must be valid UTF-8",
|
|
42
|
+
source_path: source_path
|
|
43
|
+
)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
text
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def self.load_strict_yaml(text, source_path)
|
|
50
|
+
StrictYaml.parse(text, source_path)
|
|
51
|
+
rescue StrictYaml::Error => error
|
|
52
|
+
raise ConfigurationError.new(
|
|
53
|
+
"#{error.message}",
|
|
54
|
+
source_path: source_path,
|
|
55
|
+
property_path: error.property_path
|
|
56
|
+
)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def self.validate_schema(data, source_path)
|
|
60
|
+
unless data.is_a?(Hash)
|
|
61
|
+
raise ConfigurationError.new(
|
|
62
|
+
"#{source_path}: $: configuration must be a mapping",
|
|
63
|
+
source_path: source_path,
|
|
64
|
+
property_path: "$"
|
|
65
|
+
)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
errors = SchemaValidator.validate_configuration(data)
|
|
69
|
+
return if errors.empty?
|
|
70
|
+
|
|
71
|
+
raise ConfigurationError.new(
|
|
72
|
+
"invalid configuration #{source_path}: #{errors.join('; ')}",
|
|
73
|
+
source_path: source_path,
|
|
74
|
+
property_path: errors.first.to_s.split(":").first
|
|
75
|
+
)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def initialize(data, source_path, bytes)
|
|
79
|
+
@version = data.fetch("version")
|
|
80
|
+
@mode = data.fetch("mode").freeze
|
|
81
|
+
@analyzers = deep_freeze(data.fetch("analyzers"))
|
|
82
|
+
@raw_data = deep_freeze(data.dup)
|
|
83
|
+
@source_path = source_path.dup.freeze
|
|
84
|
+
@digest = Digest::SHA256.hexdigest(bytes).freeze
|
|
85
|
+
freeze
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def analyzer_selection(name)
|
|
89
|
+
@analyzers.fetch(name)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def analyzer_enabled?(name)
|
|
93
|
+
selection = @analyzers[name]
|
|
94
|
+
return false if selection.nil?
|
|
95
|
+
|
|
96
|
+
selection.fetch("enabled")
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def analyzer_required?(name)
|
|
100
|
+
@analyzers.fetch(name).fetch("required")
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def analyzer_present?(name)
|
|
104
|
+
@analyzers.key?(name)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def baseline_path
|
|
108
|
+
@raw_data&.dig("baseline", "path")
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def waivers_path
|
|
112
|
+
@raw_data&.dig("waivers", "path")
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def git_base
|
|
116
|
+
@raw_data&.dig("git", "base")
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def ai_config
|
|
120
|
+
@raw_data&.dig("ai")
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def ai_enabled?
|
|
124
|
+
ai_config&.fetch("enabled", false) == true
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def ai_mode
|
|
128
|
+
ai_config&.fetch("mode", "off")
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def ai_remote_enabled?
|
|
132
|
+
ai_enabled? && ai_config&.dig("remote", "enabled") == true
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def ai_remote_trust
|
|
136
|
+
ai_config&.dig("remote", "trust") || "redacted"
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def ai_budgets
|
|
140
|
+
ai_config&.fetch("budgets", {}) || {}
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def ai_cache_enabled?
|
|
144
|
+
ai_config&.dig("cache", "enabled") == true
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
private
|
|
148
|
+
|
|
149
|
+
def deep_freeze(value)
|
|
150
|
+
case value
|
|
151
|
+
when Hash
|
|
152
|
+
value.each { |key, child| deep_freeze(child); key.freeze }
|
|
153
|
+
value.freeze
|
|
154
|
+
when Array
|
|
155
|
+
value.each { |child| deep_freeze(child) }
|
|
156
|
+
value.freeze
|
|
157
|
+
when String
|
|
158
|
+
value.freeze
|
|
159
|
+
else
|
|
160
|
+
value
|
|
161
|
+
end
|
|
162
|
+
value
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailVerdict
|
|
4
|
+
class AnalyzerResult
|
|
5
|
+
EXECUTION_STATUSES = %w[
|
|
6
|
+
succeeded unavailable unsupported timed_out signaled
|
|
7
|
+
failed parse_failed truncated malformed
|
|
8
|
+
].freeze
|
|
9
|
+
FAILURE_CODES = (EXECUTION_STATUSES - ["succeeded"]).freeze
|
|
10
|
+
EVIDENCE_STATUSES = %w[complete incomplete].freeze
|
|
11
|
+
|
|
12
|
+
attr_reader :analyzer, :invocation, :tool_version, :execution_status,
|
|
13
|
+
:evidence_status, :finding_ids, :failure, :evidence_summary
|
|
14
|
+
|
|
15
|
+
def initialize(analyzer:, invocation:, execution_status:, finding_ids:, tool_version: nil, failure: nil, evidence_summary: nil)
|
|
16
|
+
@analyzer = require_nonempty_string(analyzer, "analyzer", 256).freeze
|
|
17
|
+
@invocation = validate_invocation(invocation)
|
|
18
|
+
@execution_status = validate_execution_status(execution_status)
|
|
19
|
+
@evidence_status = execution_status == "succeeded" ? "complete" : "incomplete"
|
|
20
|
+
@tool_version = tool_version && require_nonempty_string(tool_version, "tool_version", 128).freeze
|
|
21
|
+
@finding_ids = validate_finding_ids(finding_ids)
|
|
22
|
+
@failure = validate_failure(failure)
|
|
23
|
+
@evidence_summary = validate_evidence_summary(evidence_summary)
|
|
24
|
+
enforce_coupling
|
|
25
|
+
freeze
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def complete?
|
|
29
|
+
evidence_status == "complete"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def to_schema_h
|
|
33
|
+
result = {}
|
|
34
|
+
result["analyzer"] = analyzer
|
|
35
|
+
result["tool_version"] = tool_version if tool_version
|
|
36
|
+
result["invocation"] = invocation
|
|
37
|
+
result["execution_status"] = execution_status
|
|
38
|
+
result["evidence_status"] = evidence_status
|
|
39
|
+
result["finding_ids"] = finding_ids
|
|
40
|
+
result["failure"] = failure if failure
|
|
41
|
+
result["evidence_summary"] = evidence_summary if evidence_summary
|
|
42
|
+
result
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def enforce_coupling
|
|
48
|
+
if execution_status == "succeeded"
|
|
49
|
+
raise ArgumentError, "succeeded results cannot carry failure metadata" unless failure.nil?
|
|
50
|
+
else
|
|
51
|
+
raise ArgumentError, "non-succeeded results require failure metadata" if failure.nil?
|
|
52
|
+
unless failure["code"] == execution_status
|
|
53
|
+
raise ArgumentError, "failure code must match execution status"
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def validate_invocation(invocation)
|
|
59
|
+
raise ArgumentError, "invocation must be a Hash" unless invocation.is_a?(Hash)
|
|
60
|
+
|
|
61
|
+
executable = invocation["executable"]
|
|
62
|
+
argv = invocation["argv"]
|
|
63
|
+
unless executable.is_a?(String) && !executable.empty? && executable.bytesize <= 1024
|
|
64
|
+
raise ArgumentError, "invocation.executable must be a non-empty string"
|
|
65
|
+
end
|
|
66
|
+
unless argv.is_a?(Array) && argv.all? { |element| element.is_a?(String) && element.bytesize <= 4096 }
|
|
67
|
+
raise ArgumentError, "invocation.argv must be an array of strings"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
{ "executable" => executable.dup.freeze, "argv" => argv.map(&:dup).map(&:freeze).freeze }.freeze
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def validate_execution_status(value)
|
|
74
|
+
unless EXECUTION_STATUSES.include?(value)
|
|
75
|
+
raise ArgumentError, "execution_status must be one of #{EXECUTION_STATUSES.join(', ')}"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
value
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def validate_finding_ids(finding_ids)
|
|
82
|
+
unless finding_ids.is_a?(Array) &&
|
|
83
|
+
finding_ids.all? { |id| id.is_a?(String) && !id.empty? && id.bytesize <= 256 }
|
|
84
|
+
raise ArgumentError, "finding_ids must be an array of non-empty strings"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
finding_ids.map { |id| id.dup.freeze }.freeze
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def validate_failure(failure)
|
|
91
|
+
return nil if failure.nil?
|
|
92
|
+
|
|
93
|
+
raise ArgumentError, "failure must be a Hash" unless failure.is_a?(Hash)
|
|
94
|
+
|
|
95
|
+
code = failure["code"]
|
|
96
|
+
message = failure["message"]
|
|
97
|
+
unless FAILURE_CODES.include?(code)
|
|
98
|
+
raise ArgumentError, "failure.code must be one of #{FAILURE_CODES.join(', ')}"
|
|
99
|
+
end
|
|
100
|
+
unless message.is_a?(String) && !message.empty? && message.bytesize <= 4096
|
|
101
|
+
raise ArgumentError, "failure.message must be a non-empty string"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
{ "code" => code.freeze, "message" => message.dup.freeze }.freeze
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def validate_evidence_summary(value)
|
|
108
|
+
return nil if value.nil?
|
|
109
|
+
|
|
110
|
+
raise ArgumentError, "evidence_summary must be a Hash" unless value.is_a?(Hash)
|
|
111
|
+
raise ArgumentError, "evidence_summary is too large" if value.keys.length > 30
|
|
112
|
+
|
|
113
|
+
value.each do |key, entry|
|
|
114
|
+
raise ArgumentError, "evidence_summary keys must be non-empty strings" unless key.is_a?(String) && !key.empty? && key.bytesize <= 128
|
|
115
|
+
if entry.is_a?(Hash) || entry.is_a?(Array)
|
|
116
|
+
raise ArgumentError, "evidence_summary values must be primitives" unless key.start_with?("changed_") || key == "files"
|
|
117
|
+
else
|
|
118
|
+
unless entry.is_a?(Integer) || entry.is_a?(Float) || entry.is_a?(String) || entry.nil? || entry == true || entry == false
|
|
119
|
+
raise ArgumentError, "evidence_summary values must be primitives"
|
|
120
|
+
end
|
|
121
|
+
if entry.is_a?(String) && entry.bytesize > 4096
|
|
122
|
+
raise ArgumentError, "evidence_summary string values too large"
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
value.dup.freeze
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def require_nonempty_string(value, name, max_length)
|
|
131
|
+
unless value.is_a?(String) && !value.empty? && value.bytesize <= max_length
|
|
132
|
+
raise ArgumentError, "#{name} must be a non-empty string of at most #{max_length} bytes"
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
value
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module RailVerdict
|
|
7
|
+
class Finding
|
|
8
|
+
SCHEMA_VERSION = "1.0"
|
|
9
|
+
FINGERPRINT_PAYLOAD_SCHEMA = "https://railverdict.dev/fingerprint-payload/v1"
|
|
10
|
+
LEGACY_FINGERPRINT_PAYLOAD_SCHEMA = "https://railverdict.dev/fingerprint-payload/v0.1"
|
|
11
|
+
FINGERPRINT_PATTERN = /\Asha256:[0-9a-f]{64}\z/
|
|
12
|
+
LOCATION_PATH_PATTERN = %r{
|
|
13
|
+
\A
|
|
14
|
+
(?!/)
|
|
15
|
+
(?!.*\\)
|
|
16
|
+
(?!.*//)
|
|
17
|
+
(?!(?:\.{1,2})(?:/|\z))
|
|
18
|
+
(?!.* / (?:\.{1,2}) (?:/|\z))
|
|
19
|
+
[^\u0000-\u001f/]+
|
|
20
|
+
(?:/[^\u0000-\u001f/]+)*
|
|
21
|
+
\z
|
|
22
|
+
}x
|
|
23
|
+
|
|
24
|
+
ORIGINS = %w[deterministic runtime ai custom].freeze
|
|
25
|
+
SEVERITIES = %w[info low medium high critical].freeze
|
|
26
|
+
CONFIDENCES = %w[low medium high].freeze
|
|
27
|
+
STATES = %w[observed introduced existing resolved changed moved suppressed waived].freeze
|
|
28
|
+
|
|
29
|
+
attr_reader :id, :fingerprint, :origin, :analyzer, :rule_id, :category, :severity,
|
|
30
|
+
:confidence, :state, :evidence_ref, :location, :message
|
|
31
|
+
|
|
32
|
+
def self.fingerprint_for(analyzer:, rule_id:, path:, message:)
|
|
33
|
+
Fingerprint.hexdigest(analyzer: analyzer, rule_id: rule_id, path: path, message: message)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.id_for(fingerprint)
|
|
37
|
+
hex = fingerprint.delete_prefix("sha256:")
|
|
38
|
+
"rv:#{hex.slice(0, 20)}"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def initialize(fingerprint:, origin:, analyzer:, rule_id:, category:, severity:, confidence:,
|
|
42
|
+
state:, evidence_ref:, location:, message:)
|
|
43
|
+
@fingerprint = require_match(fingerprint, FINGERPRINT_PATTERN, "fingerprint")
|
|
44
|
+
@origin = require_member(origin, ORIGINS, "origin")
|
|
45
|
+
@analyzer = require_nonempty(analyzer, "analyzer", 256)
|
|
46
|
+
@rule_id = require_nonempty(rule_id, "rule_id", 256)
|
|
47
|
+
@category = require_nonempty(category, "category", 256)
|
|
48
|
+
@severity = require_member(severity, SEVERITIES, "severity")
|
|
49
|
+
@confidence = require_member(confidence, CONFIDENCES, "confidence")
|
|
50
|
+
@state = require_member(state, STATES, "state")
|
|
51
|
+
@evidence_ref = require_nonempty(evidence_ref, "evidence_ref", 512)
|
|
52
|
+
@location = validate_location(location)
|
|
53
|
+
@message = require_nonempty(message, "message", 4096)
|
|
54
|
+
@id = Finding.id_for(@fingerprint).freeze
|
|
55
|
+
@location.freeze
|
|
56
|
+
@analyzer.freeze
|
|
57
|
+
@rule_id.freeze
|
|
58
|
+
@category.freeze
|
|
59
|
+
@evidence_ref.freeze
|
|
60
|
+
@message.freeze
|
|
61
|
+
freeze
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def schema_version
|
|
65
|
+
SCHEMA_VERSION
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def sort_key
|
|
69
|
+
[
|
|
70
|
+
location.fetch("path").to_s,
|
|
71
|
+
location["start_line"] || -1,
|
|
72
|
+
location["end_line"] || -1,
|
|
73
|
+
analyzer,
|
|
74
|
+
rule_id,
|
|
75
|
+
fingerprint,
|
|
76
|
+
id
|
|
77
|
+
]
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def to_schema_h
|
|
81
|
+
{
|
|
82
|
+
"schema_version" => SCHEMA_VERSION,
|
|
83
|
+
"id" => id,
|
|
84
|
+
"fingerprint" => fingerprint,
|
|
85
|
+
"origin" => origin,
|
|
86
|
+
"analyzer" => analyzer,
|
|
87
|
+
"rule_id" => rule_id,
|
|
88
|
+
"category" => category,
|
|
89
|
+
"severity" => severity,
|
|
90
|
+
"confidence" => confidence,
|
|
91
|
+
"state" => state,
|
|
92
|
+
"evidence_ref" => evidence_ref,
|
|
93
|
+
"location" => location,
|
|
94
|
+
"message" => message
|
|
95
|
+
}
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
private
|
|
99
|
+
|
|
100
|
+
def require_match(value, pattern, name)
|
|
101
|
+
raise ArgumentError, "#{name} is invalid" unless value.is_a?(String) && value.match?(pattern)
|
|
102
|
+
|
|
103
|
+
value.frozen? ? value : value.dup.freeze
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def require_member(value, allowed, name)
|
|
107
|
+
raise ArgumentError, "#{name} must be one of #{allowed.join(', ')}" unless allowed.include?(value)
|
|
108
|
+
|
|
109
|
+
value
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def require_nonempty(value, name, max_length)
|
|
113
|
+
unless value.is_a?(String) && value.valid_encoding? && !value.empty? && value.bytesize <= max_length
|
|
114
|
+
raise ArgumentError, "#{name} must be a non-empty string of at most #{max_length} bytes"
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
value
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def validate_location(location)
|
|
121
|
+
raise ArgumentError, "location must be a Hash" unless location.is_a?(Hash)
|
|
122
|
+
|
|
123
|
+
unknown = location.keys - %w[path start_line end_line]
|
|
124
|
+
raise ArgumentError, "location contains unknown fields: #{unknown.join(', ')}" unless unknown.empty?
|
|
125
|
+
|
|
126
|
+
path = location.fetch("path") do
|
|
127
|
+
raise ArgumentError, "location requires a path"
|
|
128
|
+
end
|
|
129
|
+
unless path.is_a?(String) && path.valid_encoding? && path.match?(LOCATION_PATH_PATTERN)
|
|
130
|
+
raise ArgumentError, "location.path must be a clean repository-relative path"
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
result = { "path" => path.dup.freeze }
|
|
134
|
+
start_line = location["start_line"]
|
|
135
|
+
end_line = location["end_line"]
|
|
136
|
+
result["start_line"] = validate_line(start_line, "start_line") unless start_line.nil?
|
|
137
|
+
result["end_line"] = validate_line(end_line, "end_line") unless end_line.nil?
|
|
138
|
+
|
|
139
|
+
if !start_line.nil? && !end_line.nil? && end_line < start_line
|
|
140
|
+
raise ArgumentError, "location end_line cannot precede start_line"
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
result
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def validate_line(value, name)
|
|
147
|
+
unless value.is_a?(Integer) && value >= 1
|
|
148
|
+
raise ArgumentError, "location #{name} must be an integer >= 1"
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
value
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailVerdict
|
|
4
|
+
class GateResult
|
|
5
|
+
SCHEMA_VERSION = "1.0"
|
|
6
|
+
COMPLETION_STATUSES = %w[complete incomplete interrupted].freeze
|
|
7
|
+
GATES = %w[PASS WARN FAIL INCOMPLETE].freeze
|
|
8
|
+
POLICY_STATUSES = %w[pass warn fail not_evaluated].freeze
|
|
9
|
+
FAILURE_CODES = %w[
|
|
10
|
+
unavailable unsupported timed_out signaled failed parse_failed truncated
|
|
11
|
+
malformed configuration interrupted incomplete_evidence
|
|
12
|
+
].freeze
|
|
13
|
+
|
|
14
|
+
attr_reader :completion_status, :gate, :policy_status, :findings,
|
|
15
|
+
:analyzer_results, :operational_failures, :decision_reasons
|
|
16
|
+
|
|
17
|
+
def initialize(completion_status:, gate:, policy_status:, findings:, analyzer_results:,
|
|
18
|
+
operational_failures:, decision_reasons:, baseline: nil, comparison: nil, git: nil, rails_context: nil)
|
|
19
|
+
@completion_status = require_member(completion_status, COMPLETION_STATUSES, "completion_status")
|
|
20
|
+
@gate = require_member(gate, GATES, "gate")
|
|
21
|
+
@policy_status = require_member(policy_status, POLICY_STATUSES, "policy_status")
|
|
22
|
+
@findings = validate_findings(findings)
|
|
23
|
+
@analyzer_results = validate_analyzer_results(analyzer_results)
|
|
24
|
+
@operational_failures = validate_operational_failures(operational_failures)
|
|
25
|
+
@decision_reasons = validate_decision_reasons(decision_reasons)
|
|
26
|
+
@baseline = baseline ? baseline.dup.freeze : nil
|
|
27
|
+
@comparison = comparison ? comparison.dup.freeze : nil
|
|
28
|
+
@git = git ? deep_freeze_git(git) : nil
|
|
29
|
+
@rails_context = rails_context ? deep_freeze_git(rails_context) : nil
|
|
30
|
+
enforce_state_coupling
|
|
31
|
+
freeze
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def baseline
|
|
35
|
+
@baseline
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def comparison
|
|
39
|
+
@comparison
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def git
|
|
43
|
+
@git
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def rails_context
|
|
47
|
+
@rails_context
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def complete?
|
|
51
|
+
completion_status == "complete"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def incomplete?
|
|
55
|
+
completion_status != "complete"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def to_schema_h
|
|
59
|
+
result = {
|
|
60
|
+
"schema_version" => SCHEMA_VERSION,
|
|
61
|
+
"completion_status" => completion_status,
|
|
62
|
+
"gate" => gate,
|
|
63
|
+
"policy_status" => policy_status,
|
|
64
|
+
"findings" => findings,
|
|
65
|
+
"analyzer_results" => analyzer_results.map(&:to_schema_h),
|
|
66
|
+
"operational_failures" => operational_failures,
|
|
67
|
+
"decision_reasons" => decision_reasons
|
|
68
|
+
}
|
|
69
|
+
result["baseline"] = @baseline if @baseline
|
|
70
|
+
result["comparison"] = @comparison if @comparison
|
|
71
|
+
result["git"] = @git if @git
|
|
72
|
+
result["rails_context"] = @rails_context if @rails_context
|
|
73
|
+
result
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def deep_freeze_git(value)
|
|
77
|
+
case value
|
|
78
|
+
when Hash
|
|
79
|
+
value.each do |key, child|
|
|
80
|
+
key.freeze if key.is_a?(String)
|
|
81
|
+
deep_freeze_git(child)
|
|
82
|
+
end
|
|
83
|
+
value.freeze
|
|
84
|
+
when Array
|
|
85
|
+
value.each { |child| deep_freeze_git(child) }
|
|
86
|
+
value.freeze
|
|
87
|
+
when String
|
|
88
|
+
value.freeze
|
|
89
|
+
else
|
|
90
|
+
value
|
|
91
|
+
end
|
|
92
|
+
value
|
|
93
|
+
end
|
|
94
|
+
private :deep_freeze_git
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
|
|
98
|
+
def require_member(value, allowed, name)
|
|
99
|
+
raise ArgumentError, "#{name} must be one of #{allowed.join(', ')}" unless allowed.include?(value)
|
|
100
|
+
|
|
101
|
+
value
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def enforce_state_coupling
|
|
105
|
+
if completion_status == "complete"
|
|
106
|
+
unless %w[PASS WARN FAIL].include?(gate) && %w[pass warn fail].include?(policy_status)
|
|
107
|
+
raise ArgumentError, "complete result must contain a completed gate and policy status"
|
|
108
|
+
end
|
|
109
|
+
elsif gate != "INCOMPLETE" || policy_status != "not_evaluated" || operational_failures.empty?
|
|
110
|
+
raise ArgumentError, "incomplete result must be INCOMPLETE, not_evaluated, and explain a failure"
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def validate_findings(findings)
|
|
115
|
+
unless findings.is_a?(Array)
|
|
116
|
+
raise ArgumentError, "findings must be an array"
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
findings.map do |finding|
|
|
120
|
+
raise ArgumentError, "finding summary must be a Hash" unless finding.is_a?(Hash)
|
|
121
|
+
|
|
122
|
+
expected = %w[id fingerprint severity state blocking]
|
|
123
|
+
raise ArgumentError, "finding summary contains unknown fields" unless finding.keys == expected
|
|
124
|
+
raise ArgumentError, "finding summary id is invalid" unless finding["id"].is_a?(String) && !finding["id"].empty?
|
|
125
|
+
raise ArgumentError, "finding summary fingerprint is invalid" unless finding["fingerprint"].match?(/\Asha256:[0-9a-f]{64}\z/)
|
|
126
|
+
raise ArgumentError, "finding summary severity is invalid" unless Finding::SEVERITIES.include?(finding["severity"])
|
|
127
|
+
raise ArgumentError, "finding summary state is invalid" unless Finding::STATES.include?(finding["state"])
|
|
128
|
+
raise ArgumentError, "finding summary blocking must be boolean" unless [true, false].include?(finding["blocking"])
|
|
129
|
+
|
|
130
|
+
finding.dup.freeze
|
|
131
|
+
end.freeze
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def validate_analyzer_results(results)
|
|
135
|
+
raise ArgumentError, "analyzer_results must be an array" unless results.is_a?(Array)
|
|
136
|
+
raise ArgumentError, "analyzer_results must contain AnalyzerResult values" unless results.all? { |result| result.is_a?(AnalyzerResult) }
|
|
137
|
+
|
|
138
|
+
results.sort_by(&:analyzer).freeze
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def validate_operational_failures(failures)
|
|
142
|
+
raise ArgumentError, "operational_failures must be an array" unless failures.is_a?(Array)
|
|
143
|
+
|
|
144
|
+
failures.map do |failure|
|
|
145
|
+
raise ArgumentError, "operational failure must be a Hash" unless failure.is_a?(Hash)
|
|
146
|
+
allowed = %w[code analyzer message]
|
|
147
|
+
raise ArgumentError, "operational failure contains unknown fields" unless (failure.keys - allowed).empty?
|
|
148
|
+
raise ArgumentError, "operational failure has invalid code" unless FAILURE_CODES.include?(failure["code"])
|
|
149
|
+
raise ArgumentError, "operational failure requires a message" unless failure["message"].is_a?(String) && !failure["message"].empty?
|
|
150
|
+
if failure.key?("analyzer") && !failure["analyzer"].is_a?(String)
|
|
151
|
+
raise ArgumentError, "operational failure analyzer must be a string"
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
failure.dup.freeze
|
|
155
|
+
end.freeze
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def validate_decision_reasons(reasons)
|
|
159
|
+
raise ArgumentError, "decision_reasons must be an array" unless reasons.is_a?(Array)
|
|
160
|
+
|
|
161
|
+
reasons.map do |reason|
|
|
162
|
+
raise ArgumentError, "decision reason must be a Hash" unless reason.is_a?(Hash)
|
|
163
|
+
raise ArgumentError, "decision reason fields are invalid" unless reason.keys == %w[code message]
|
|
164
|
+
raise ArgumentError, "decision reason code is invalid" unless reason["code"].is_a?(String) && !reason["code"].empty?
|
|
165
|
+
raise ArgumentError, "decision reason message is invalid" unless reason["message"].is_a?(String) && !reason["message"].empty?
|
|
166
|
+
|
|
167
|
+
reason.dup.freeze
|
|
168
|
+
end.freeze
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailVerdict
|
|
4
|
+
module Coverage
|
|
5
|
+
module ChangedLineEvaluator
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def evaluate(coverage_document:, line_set:)
|
|
9
|
+
raise ArgumentError, "coverage_document must be a Hash" unless coverage_document.is_a?(Hash)
|
|
10
|
+
raise ArgumentError, "line_set must be a Hash" unless line_set.is_a?(Hash)
|
|
11
|
+
|
|
12
|
+
files = coverage_document["files"]
|
|
13
|
+
raise ArgumentError, "coverage document has no files array" unless files.is_a?(Array)
|
|
14
|
+
|
|
15
|
+
coverage_map = {}
|
|
16
|
+
files.each do |file|
|
|
17
|
+
filename = file["filename"]
|
|
18
|
+
coverage = file.dig("coverage", "lines")
|
|
19
|
+
next unless filename.is_a?(String) && coverage.is_a?(Array)
|
|
20
|
+
|
|
21
|
+
coverage_map[filename] = coverage
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
executable = 0
|
|
25
|
+
covered = 0
|
|
26
|
+
missing = []
|
|
27
|
+
|
|
28
|
+
line_set.each do |path, lines|
|
|
29
|
+
raise ArgumentError, "line_set path must be a string" unless path.is_a?(String)
|
|
30
|
+
raise ArgumentError, "line_set lines must be an array" unless lines.is_a?(Array)
|
|
31
|
+
|
|
32
|
+
coverage = coverage_map[path]
|
|
33
|
+
lines.each do |line_number|
|
|
34
|
+
raise ArgumentError, "line number must be an integer >= 1" unless line_number.is_a?(Integer) && line_number >= 1
|
|
35
|
+
|
|
36
|
+
if coverage.nil? || line_number > coverage.length
|
|
37
|
+
executable += 1
|
|
38
|
+
missing << [path, line_number]
|
|
39
|
+
next
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
hit = coverage[line_number - 1]
|
|
43
|
+
if hit.nil?
|
|
44
|
+
next
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
executable += 1
|
|
48
|
+
if hit.to_i > 0
|
|
49
|
+
covered += 1
|
|
50
|
+
else
|
|
51
|
+
missing << [path, line_number]
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
missing = missing.sort
|
|
57
|
+
|
|
58
|
+
percent = executable == 0 ? 100.0 : ((covered.to_f / executable) * 100).round(2)
|
|
59
|
+
|
|
60
|
+
{
|
|
61
|
+
"covered_lines" => covered,
|
|
62
|
+
"executable_lines" => executable,
|
|
63
|
+
"missing_lines" => missing,
|
|
64
|
+
"percent" => percent,
|
|
65
|
+
"paths" => line_set.keys.sort
|
|
66
|
+
}.freeze
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|