quality_gate 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 +14 -0
- data/LICENSE.txt +21 -0
- data/README.md +571 -0
- data/config/37signals.yml +26 -0
- data/config/cops.yml +37 -0
- data/config/reek.yml +6 -0
- data/config/rubocop.yml +60 -0
- data/config/ruby.yml +8 -0
- data/docs/codex.md +31 -0
- data/docs/dogfood-log.md +47 -0
- data/docs/incidents.md +18 -0
- data/docs/releasing.md +46 -0
- data/exe/quality_gate +6 -0
- data/lib/generators/quality_gate/install/install_generator.rb +66 -0
- data/lib/generators/quality_gate/install/templates/agents_section.md.tt +15 -0
- data/lib/generators/quality_gate/install/templates/bullet.rb.tt +10 -0
- data/lib/generators/quality_gate/install/templates/claude_settings.json.tt +29 -0
- data/lib/generators/quality_gate/install/templates/hook_log_filesystem.rb.tt +76 -0
- data/lib/generators/quality_gate/install/templates/quality_gate.yml.tt +42 -0
- data/lib/generators/quality_gate/install/templates/quality_gate_fast.rb.tt +248 -0
- data/lib/generators/quality_gate/install/templates/quality_gate_verify_stop.rb.tt +466 -0
- data/lib/generators/quality_gate/install/templates/rubocop.yml.tt +1 -0
- data/lib/generators/quality_gate/install/templates/ruby_agents_section.md.tt +15 -0
- data/lib/generators/quality_gate/install/templates/ruby_quality_gate.yml.tt +40 -0
- data/lib/generators/quality_gate/install/templates/ruby_rubocop.yml.tt +1 -0
- data/lib/generators/quality_gate/install/templates/ruby_simplecov.rb.tt +11 -0
- data/lib/generators/quality_gate/install/templates/simplecov.rb.tt +10 -0
- data/lib/generators/quality_gate/install/templates/strong_migrations.rb.tt +10 -0
- data/lib/quality_gate/adapter.rb +328 -0
- data/lib/quality_gate/adapters/brakeman.rb +125 -0
- data/lib/quality_gate/adapters/bundler_audit.rb +235 -0
- data/lib/quality_gate/adapters/reek.rb +108 -0
- data/lib/quality_gate/adapters/rubocop.rb +142 -0
- data/lib/quality_gate/adapters/simplecov.rb +103 -0
- data/lib/quality_gate/adapters/test_suite.rb +81 -0
- data/lib/quality_gate/adapters/undercover.rb +357 -0
- data/lib/quality_gate/cli.rb +451 -0
- data/lib/quality_gate/config.rb +290 -0
- data/lib/quality_gate/exit_code.rb +14 -0
- data/lib/quality_gate/finding.rb +50 -0
- data/lib/quality_gate/hook_log.rb +131 -0
- data/lib/quality_gate/init_command.rb +90 -0
- data/lib/quality_gate/installation.rb +1577 -0
- data/lib/quality_gate/installer.rb +104 -0
- data/lib/quality_gate/railtie.rb +16 -0
- data/lib/quality_gate/reporters/field_sanitizer.rb +42 -0
- data/lib/quality_gate/reporters/json.rb +59 -0
- data/lib/quality_gate/reporters/text.rb +45 -0
- data/lib/quality_gate/rubocop.rb +34 -0
- data/lib/quality_gate/ruby_profile.rb +170 -0
- data/lib/quality_gate/runner.rb +150 -0
- data/lib/quality_gate/version.rb +5 -0
- data/lib/quality_gate.rb +27 -0
- data/lib/rubocop/cop/quality_gate/association_default_block_value.rb +46 -0
- data/lib/rubocop/cop/quality_gate/broadcast_in_controller.rb +64 -0
- data/lib/rubocop/cop/quality_gate/controller_instance_variables.rb +47 -0
- data/lib/rubocop/cop/quality_gate/prefer_after_save_commit.rb +84 -0
- data/lib/rubocop/cop/quality_gate/private_only_concern.rb +77 -0
- data/llm.txt +13 -0
- data/sig/quality_gate.rbs +226 -0
- metadata +260 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<!-- quality_gate agent contract — start -->
|
|
2
|
+
## Quality Gate agent contract
|
|
3
|
+
|
|
4
|
+
Run `bundle exec quality_gate fast` after Ruby edits. Installed Claude Code hooks can run `bundle exec quality_gate verify` automatically at session end; other clients, including Codex, must run `bundle exec quality_gate verify` manually before you finish a change. Run `bundle exec quality_gate audit` before you merge security-sensitive work.
|
|
5
|
+
|
|
6
|
+
Fix findings before you continue editing.
|
|
7
|
+
|
|
8
|
+
- Exit 0 means the gate ran clean.
|
|
9
|
+
- Exit 1 means the gate reported findings and no tool failures.
|
|
10
|
+
- Exit 2 means the gate could not complete cleanly because of invalid input, configuration, or another tool failure.
|
|
11
|
+
|
|
12
|
+
The default fast path keeps feedback tight with a 10-second RuboCop timeout. Verify and audit keep the broader suite work on the 120-second default timeout.
|
|
13
|
+
|
|
14
|
+
To reinstall the Ruby profile's agent integration, run `bundle exec quality_gate init --profile ruby --agents`.
|
|
15
|
+
<!-- quality_gate agent contract — end -->
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# # Output format: "text" or "json".
|
|
2
|
+
# format: text
|
|
3
|
+
#
|
|
4
|
+
# # Optional file paths passed to RuboCop and Reek; empty means the project scope.
|
|
5
|
+
# files: []
|
|
6
|
+
#
|
|
7
|
+
# # Plain Ruby adapters run by each gate, in order.
|
|
8
|
+
adapters:
|
|
9
|
+
fast:
|
|
10
|
+
- rubocop
|
|
11
|
+
verify:
|
|
12
|
+
- reek
|
|
13
|
+
- test_suite
|
|
14
|
+
<% if @profile.coverage? %> - undercover
|
|
15
|
+
<% end %> audit:
|
|
16
|
+
- bundler_audit
|
|
17
|
+
|
|
18
|
+
# # Command overrides by gate.
|
|
19
|
+
commands:
|
|
20
|
+
fast: {}
|
|
21
|
+
verify:
|
|
22
|
+
test_suite:
|
|
23
|
+
<% @profile.test_command.each do |argument| %> - <%= JSON.generate(argument) %>
|
|
24
|
+
<% end %> audit: {}
|
|
25
|
+
|
|
26
|
+
# # Command timeouts in seconds.
|
|
27
|
+
timeouts:
|
|
28
|
+
default: 120
|
|
29
|
+
rubocop: 10
|
|
30
|
+
test_suite: 120
|
|
31
|
+
undercover: 120
|
|
32
|
+
|
|
33
|
+
# # Aggregate SimpleCov budgets. Leave the entire mapping commented to disable coverage budgeting.
|
|
34
|
+
# coverage:
|
|
35
|
+
|
|
36
|
+
# # Git ref or commit for Undercover; blank selects the comparison point automatically.
|
|
37
|
+
# compare_point:
|
|
38
|
+
|
|
39
|
+
# # Explicit RuboCop config path; blank uses host config when present, otherwise the shipped config.
|
|
40
|
+
# rubocop_config:
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
inherit_gem: { quality_gate: config/ruby.yml }
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'strong_migrations'
|
|
4
|
+
|
|
5
|
+
<%= strong_migrations_provenance %>
|
|
6
|
+
<% if (version = strong_migrations_baseline) -%>
|
|
7
|
+
StrongMigrations.start_after = <%= migration_version_literal(version) %>
|
|
8
|
+
<% else -%>
|
|
9
|
+
# Set StrongMigrations.start_after after the first migration exists.
|
|
10
|
+
<% end -%>
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "English"
|
|
4
|
+
require "open3"
|
|
5
|
+
|
|
6
|
+
module QualityGate
|
|
7
|
+
# Raised when adapter output cannot be parsed into findings.
|
|
8
|
+
class ParseError < Error
|
|
9
|
+
attr_reader :tool
|
|
10
|
+
|
|
11
|
+
def initialize(tool:, reason:)
|
|
12
|
+
@tool = tool.dup.freeze
|
|
13
|
+
super("Could not parse #{@tool} output: #{reason}")
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Executes one quality tool without involving a shell and normalizes failures.
|
|
18
|
+
# rubocop:disable Metrics/ClassLength
|
|
19
|
+
class Adapter
|
|
20
|
+
STDERR_LINE_LIMIT = 20
|
|
21
|
+
TERMINATE_GRACE_SECONDS = 0.2
|
|
22
|
+
|
|
23
|
+
def initialize(config:, files: [], diagnostic_io: $stderr)
|
|
24
|
+
@config = config
|
|
25
|
+
@files = files.map { copy_string(_1) }.freeze
|
|
26
|
+
@diagnostic_io = diagnostic_io
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def call
|
|
30
|
+
tool = name
|
|
31
|
+
stderr = +""
|
|
32
|
+
stdout, stderr, = capture(validated_command, resolved_timeout(tool))
|
|
33
|
+
findings = parse(stdout)
|
|
34
|
+
validate_findings!(findings, expected_tool: tool)
|
|
35
|
+
findings
|
|
36
|
+
rescue StandardError => e
|
|
37
|
+
[failure_finding(tool, e, stderr)]
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def name
|
|
41
|
+
raise NotImplementedError, "#{self.class} must implement #name"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def command
|
|
45
|
+
raise NotImplementedError, "#{self.class} must implement #command"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def parse(_stdout)
|
|
49
|
+
raise NotImplementedError, "#{self.class} must implement #parse"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def timeout
|
|
53
|
+
resolved_timeout(name)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
attr_reader :config, :diagnostic_io, :files
|
|
59
|
+
|
|
60
|
+
def validated_command
|
|
61
|
+
value = command
|
|
62
|
+
return value if value.is_a?(Array) && value.all?(String)
|
|
63
|
+
|
|
64
|
+
raise ArgumentError, "command must be an array of strings"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def validate_findings!(findings, expected_tool:)
|
|
68
|
+
raise TypeError, "parse must return an array of findings" unless findings.is_a?(Array)
|
|
69
|
+
|
|
70
|
+
findings.each do |finding|
|
|
71
|
+
raise TypeError, "parse must return finding objects" unless finding.is_a?(Finding)
|
|
72
|
+
unless finding.tool == expected_tool
|
|
73
|
+
raise ArgumentError, "finding tool must match adapter #{expected_tool.inspect}"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
unless Finding::SEVERITIES.include?(finding.severity)
|
|
77
|
+
raise ArgumentError, "invalid finding severity #{finding.severity.inspect}"
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def capture(argv, timeout_seconds, env: {}, combine_output: false)
|
|
83
|
+
stdin, stdout, stderr, wait_thread = open_capture(argv, env, combine_output)
|
|
84
|
+
resources = {
|
|
85
|
+
process_group_id: -wait_thread.pid,
|
|
86
|
+
wait_thread:,
|
|
87
|
+
stdin:,
|
|
88
|
+
stdout:,
|
|
89
|
+
stderr:,
|
|
90
|
+
readers: {}
|
|
91
|
+
}
|
|
92
|
+
deadline = monotonic_deadline(timeout_seconds)
|
|
93
|
+
|
|
94
|
+
stdin.close
|
|
95
|
+
register_readers(resources)
|
|
96
|
+
|
|
97
|
+
wait_for_process(wait_thread, timeout_seconds, deadline)
|
|
98
|
+
stdout_text = captured_output(resources, :stdout, timeout_seconds, deadline)
|
|
99
|
+
stderr_text = captured_output(resources, :stderr, timeout_seconds, deadline)
|
|
100
|
+
|
|
101
|
+
[stdout_text, stderr_text, wait_thread.value]
|
|
102
|
+
ensure
|
|
103
|
+
cleanup_capture(resources, $ERROR_INFO)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def open_capture(argv, env, combine_output)
|
|
107
|
+
return merged_capture(env, argv) if combine_output
|
|
108
|
+
|
|
109
|
+
Open3.popen3(env, *argv, pgroup: true)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def merged_capture(env, argv)
|
|
113
|
+
stdin, output, wait_thread = Open3.popen2e(env, *argv, pgroup: true)
|
|
114
|
+
[stdin, output, nil, wait_thread]
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def register_readers(resources)
|
|
118
|
+
readers = resources.fetch(:readers)
|
|
119
|
+
readers[:stdout] = reader_thread(resources.fetch(:stdout))
|
|
120
|
+
stderr = resources.fetch(:stderr)
|
|
121
|
+
readers[:stderr] = reader_thread(stderr) if stderr
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def captured_output(resources, stream, timeout_seconds, deadline)
|
|
125
|
+
reader = resources.fetch(:readers).fetch(stream, nil)
|
|
126
|
+
return +"" unless reader
|
|
127
|
+
|
|
128
|
+
fetch_reader_output(reader, timeout_seconds, deadline)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def wait_for_process(wait_thread, timeout_seconds, deadline)
|
|
132
|
+
return if wait_thread.join(remaining_before(deadline))
|
|
133
|
+
|
|
134
|
+
raise TimeoutError, "timeout after #{timeout_seconds} seconds"
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def fetch_reader_output(reader, timeout_seconds, deadline)
|
|
138
|
+
return reader.value if reader.join(remaining_before(deadline))
|
|
139
|
+
|
|
140
|
+
raise TimeoutError, "timeout after #{timeout_seconds} seconds"
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# rubocop:disable Metrics/AbcSize
|
|
144
|
+
def cleanup_capture(resources, active_exception)
|
|
145
|
+
return unless resources
|
|
146
|
+
|
|
147
|
+
terminate_process_group(resources, active_exception) if active_exception
|
|
148
|
+
cleanup_action(active_exception) { close_pipe(resources.fetch(:stdin)) }
|
|
149
|
+
cleanup_action(active_exception) { close_pipe(resources.fetch(:stdout)) }
|
|
150
|
+
cleanup_action(active_exception) { close_pipe(resources.fetch(:stderr)) }
|
|
151
|
+
cleanup_action(active_exception) { join_reader(resources.fetch(:readers)&.fetch(:stdout, nil)) }
|
|
152
|
+
cleanup_action(active_exception) { join_reader(resources.fetch(:readers)&.fetch(:stderr, nil)) }
|
|
153
|
+
end
|
|
154
|
+
# rubocop:enable Metrics/AbcSize
|
|
155
|
+
|
|
156
|
+
def terminate_process_group(resources, active_exception)
|
|
157
|
+
process_group_id = resources.fetch(:process_group_id)
|
|
158
|
+
wait_thread = resources.fetch(:wait_thread)
|
|
159
|
+
process_pid = wait_thread.pid
|
|
160
|
+
|
|
161
|
+
cleanup_action(active_exception) { signal_with_fallback("TERM", process_group_id, process_pid) }
|
|
162
|
+
wait_thread.join(TERMINATE_GRACE_SECONDS)
|
|
163
|
+
return unless process_group_alive?(process_group_id)
|
|
164
|
+
|
|
165
|
+
cleanup_action(active_exception) { signal_with_fallback("KILL", process_group_id, process_pid) }
|
|
166
|
+
wait_thread.join if wait_thread&.alive?
|
|
167
|
+
rescue Errno::ESRCH
|
|
168
|
+
wait_thread.join if wait_thread&.alive?
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def signal_with_fallback(signal, process_group_id, process_pid)
|
|
172
|
+
signal_process_group(signal, process_group_id)
|
|
173
|
+
rescue StandardError
|
|
174
|
+
signal_process(signal, process_pid)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def signal_process_group(signal, process_group_id)
|
|
178
|
+
return unless process_group_id
|
|
179
|
+
|
|
180
|
+
Process.kill(signal, process_group_id)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def signal_process(signal, process_pid)
|
|
184
|
+
Process.kill(signal, process_pid)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def process_group_alive?(process_group_id)
|
|
188
|
+
Process.kill(0, process_group_id)
|
|
189
|
+
true
|
|
190
|
+
rescue Errno::EPERM
|
|
191
|
+
true
|
|
192
|
+
rescue Errno::ESRCH
|
|
193
|
+
false
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def cleanup_action(active_exception)
|
|
197
|
+
yield
|
|
198
|
+
rescue StandardError
|
|
199
|
+
raise unless active_exception
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def reader_thread(pipe)
|
|
203
|
+
Thread.new do
|
|
204
|
+
Thread.current.report_on_exception = false
|
|
205
|
+
pipe.read.to_s
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def close_pipe(pipe)
|
|
210
|
+
pipe.close unless pipe.nil? || pipe.closed?
|
|
211
|
+
rescue IOError, SystemCallError
|
|
212
|
+
nil
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def join_reader(reader)
|
|
216
|
+
return unless reader
|
|
217
|
+
|
|
218
|
+
reader.join
|
|
219
|
+
reader.value
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def monotonic_deadline(timeout_seconds)
|
|
223
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout_seconds
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def remaining_before(deadline)
|
|
227
|
+
[deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC), 0].max
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def failure_message(tool, error, stderr)
|
|
231
|
+
reason = sanitize_fragment(error.message)
|
|
232
|
+
message = "#{tool}: #{reason}"
|
|
233
|
+
stderr_tail = sanitized_stderr_tail(stderr)
|
|
234
|
+
return message if stderr_tail.empty? || !unreadable_output?(error)
|
|
235
|
+
|
|
236
|
+
"#{message} stderr: #{stderr_tail}"
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def failure_finding(tool, error, stderr)
|
|
240
|
+
safe_tool = safe_failure_tool(tool)
|
|
241
|
+
Finding.tool_failure(tool: safe_tool, message: safe_failure_message(safe_tool, error, stderr))
|
|
242
|
+
rescue StandardError
|
|
243
|
+
Finding.tool_failure(tool: fallback_tool_name, message: "#{fallback_tool_name}: adapter failure")
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def safe_failure_tool(tool)
|
|
247
|
+
normalized_tool_name(tool || fallback_tool_name)
|
|
248
|
+
rescue StandardError
|
|
249
|
+
fallback_tool_name
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def safe_failure_message(tool, error, stderr)
|
|
253
|
+
failure_message(tool, error, stderr)
|
|
254
|
+
rescue StandardError
|
|
255
|
+
"#{tool}: adapter failure"
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def resolved_timeout(tool)
|
|
259
|
+
tool_timeouts = config.fetch(:timeouts)
|
|
260
|
+
raise ArgumentError, "timeouts config must be a mapping" unless tool_timeouts.is_a?(Hash)
|
|
261
|
+
|
|
262
|
+
validate_timeout_value(timeout_value_for(tool_timeouts, tool))
|
|
263
|
+
rescue KeyError
|
|
264
|
+
raise ArgumentError, "timeouts config is missing"
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def timeout_value_for(tool_timeouts, tool)
|
|
268
|
+
return tool_timeouts.fetch(tool.to_sym) if tool_timeouts.key?(tool.to_sym)
|
|
269
|
+
return tool_timeouts.fetch(:default) if tool_timeouts.key?(:default)
|
|
270
|
+
|
|
271
|
+
raise ArgumentError, "timeout default is missing"
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def validate_timeout_value(timeout_value)
|
|
275
|
+
return timeout_value if timeout_value.is_a?(Integer) && timeout_value.positive?
|
|
276
|
+
|
|
277
|
+
raise ArgumentError, "timeout must be a positive Integer"
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def unreadable_output?(error)
|
|
281
|
+
error.is_a?(ParseError)
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def sanitized_stderr_tail(stderr)
|
|
285
|
+
stderr
|
|
286
|
+
.to_s
|
|
287
|
+
.dup
|
|
288
|
+
.force_encoding(Encoding::UTF_8)
|
|
289
|
+
.scrub("?")
|
|
290
|
+
.lines
|
|
291
|
+
.map { sanitize_fragment(_1) }
|
|
292
|
+
.reject(&:empty?)
|
|
293
|
+
.last(STDERR_LINE_LIMIT)
|
|
294
|
+
.join(" | ")
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def sanitize_fragment(text)
|
|
298
|
+
text
|
|
299
|
+
.to_s
|
|
300
|
+
.dup
|
|
301
|
+
.force_encoding(Encoding::UTF_8)
|
|
302
|
+
.scrub("?")
|
|
303
|
+
.gsub(/[[:cntrl:]]+/, " ")
|
|
304
|
+
.gsub(/\s+/, " ")
|
|
305
|
+
.strip
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
def fallback_tool_name
|
|
309
|
+
component = self.class.name.to_s.split("::").last.to_s
|
|
310
|
+
tool_name = sanitize_fragment(component).downcase
|
|
311
|
+
tool_name.empty? ? "adapter" : tool_name
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def normalized_tool_name(tool)
|
|
315
|
+
sanitized = sanitize_fragment(tool)
|
|
316
|
+
sanitized.empty? ? fallback_tool_name : sanitized
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def copy_string(value)
|
|
320
|
+
value.dup.freeze
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
class TimeoutError < StandardError; end
|
|
324
|
+
|
|
325
|
+
private_constant :STDERR_LINE_LIMIT, :TERMINATE_GRACE_SECONDS, :TimeoutError
|
|
326
|
+
end
|
|
327
|
+
# rubocop:enable Metrics/ClassLength
|
|
328
|
+
end
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module QualityGate
|
|
6
|
+
module Adapters
|
|
7
|
+
# Runs Brakeman across the application and normalizes its JSON report.
|
|
8
|
+
class Brakeman < Adapter
|
|
9
|
+
class InvalidReport < StandardError; end
|
|
10
|
+
private_constant :InvalidReport
|
|
11
|
+
|
|
12
|
+
CONFIDENCE_MAP = {
|
|
13
|
+
"High" => :error,
|
|
14
|
+
"Medium" => :warning,
|
|
15
|
+
"Weak" => :info
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
def call
|
|
19
|
+
tool = name
|
|
20
|
+
stderr = +""
|
|
21
|
+
stdout, stderr, status = capture(validated_command, resolved_timeout(tool))
|
|
22
|
+
findings = parse(stdout)
|
|
23
|
+
validate_findings!(findings, expected_tool: tool)
|
|
24
|
+
validate_cli_result!(status, findings)
|
|
25
|
+
findings
|
|
26
|
+
rescue StandardError => e
|
|
27
|
+
[failure_finding(tool, e, stderr)]
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def name = "brakeman"
|
|
31
|
+
|
|
32
|
+
def command = ["brakeman", "-f", "json", "-q", "--no-pager"]
|
|
33
|
+
|
|
34
|
+
def parse(stdout)
|
|
35
|
+
report = parse_report(stdout)
|
|
36
|
+
warnings = report.fetch("warnings") { invalid_report!("report must include warnings") }
|
|
37
|
+
errors = report.fetch("errors") { invalid_report!("report must include errors") }
|
|
38
|
+
validate_errors!(errors)
|
|
39
|
+
parse_warnings(warnings)
|
|
40
|
+
rescue JSON::ParserError, InvalidReport => e
|
|
41
|
+
fail ParseError.new(tool: name, reason: e.message) # rubocop:disable Style/SignalException
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def parse_report(stdout)
|
|
47
|
+
report = JSON.parse(stdout)
|
|
48
|
+
invalid_report!("report must be a JSON object") unless report.is_a?(Hash)
|
|
49
|
+
|
|
50
|
+
report
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def parse_warnings(entries)
|
|
54
|
+
invalid_report!("warnings must be an array") unless entries.is_a?(Array)
|
|
55
|
+
|
|
56
|
+
entries.map { build_finding(_1) }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def validate_errors!(errors)
|
|
60
|
+
invalid_report!("errors must be an array") unless errors.is_a?(Array)
|
|
61
|
+
invalid_report!("report contains scan errors") unless errors.empty?
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def validate_cli_result!(status, findings)
|
|
65
|
+
cli_contract_error!("Brakeman did not exit normally") unless status&.exited?
|
|
66
|
+
|
|
67
|
+
exitstatus = status.exitstatus
|
|
68
|
+
return if exitstatus.zero?
|
|
69
|
+
return if exitstatus == 3 && findings.any?
|
|
70
|
+
|
|
71
|
+
if exitstatus == 3
|
|
72
|
+
cli_contract_error!("Brakeman exit status 3 disagrees with its warning count")
|
|
73
|
+
else
|
|
74
|
+
cli_contract_error!("Brakeman exited with unsupported status #{exitstatus.inspect}")
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def cli_contract_error!(reason)
|
|
79
|
+
fail ParseError.new(tool: name, reason:) # rubocop:disable Style/SignalException
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def build_finding(warning)
|
|
83
|
+
invalid_report!("warning must be an object") unless warning.is_a?(Hash)
|
|
84
|
+
|
|
85
|
+
Finding.new(tool: name, **finding_attributes(warning))
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def finding_attributes(warning)
|
|
89
|
+
line = warning_field(warning, "line")
|
|
90
|
+
confidence = string_field(warning, "confidence")
|
|
91
|
+
|
|
92
|
+
{
|
|
93
|
+
file: string_field(warning, "file"),
|
|
94
|
+
line: validated_line(line),
|
|
95
|
+
rule: string_field(warning, "warning_type"),
|
|
96
|
+
severity: CONFIDENCE_MAP.fetch(confidence) { invalid_report!("unknown confidence #{confidence.inspect}") },
|
|
97
|
+
message: string_field(warning, "message")
|
|
98
|
+
}
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def warning_field(warning, field)
|
|
102
|
+
warning.fetch(field) { invalid_report!("warning must include #{field}") }
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def string_field(warning, field)
|
|
106
|
+
value = warning_field(warning, field)
|
|
107
|
+
invalid_report!("#{field} must be a String") unless value.is_a?(String)
|
|
108
|
+
|
|
109
|
+
value
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def validated_line(line)
|
|
113
|
+
return 0 if line.nil?
|
|
114
|
+
|
|
115
|
+
invalid_report!("line must be a non-negative Integer") unless line.is_a?(Integer) && line >= 0
|
|
116
|
+
|
|
117
|
+
line
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def invalid_report!(reason)
|
|
121
|
+
raise InvalidReport, reason
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|