scryer 1.1.1 → 1.2.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 +165 -4
- data/README.md +74 -1027
- data/docs/architecture.md +268 -0
- data/docs/contributing.md +42 -0
- data/docs/fix-mode.md +364 -0
- data/docs/rails-integration.md +162 -0
- data/docs/rules.md +310 -0
- data/docs/usage.md +301 -0
- data/lib/scryer/ai_fix_suggester.rb +9 -3
- data/lib/scryer/cli.rb +524 -36
- data/lib/scryer/colorizer.rb +56 -0
- data/lib/scryer/dependency_fixer.rb +96 -0
- data/lib/scryer/fix_runner.rb +161 -0
- data/lib/scryer/fix_verifier.rb +97 -10
- data/lib/scryer/mechanical_fixer.rb +288 -0
- data/lib/scryer/scanner.rb +25 -12
- data/lib/scryer/version.rb +1 -1
- data/lib/scryer.rb +17 -3
- data/lib/tasks/scryer.rake +335 -21
- metadata +35 -7
data/lib/scryer/cli.rb
CHANGED
|
@@ -11,10 +11,12 @@ module Scryer
|
|
|
11
11
|
class CLI
|
|
12
12
|
EXTENSION_FORMATS = { ".json" => "json", ".html" => "html", ".htm" => "html", ".csv" => "csv", ".sarif" => "sarif" }.freeze
|
|
13
13
|
|
|
14
|
-
def initialize(argv, stdout: $stdout, stderr: $stderr)
|
|
14
|
+
def initialize(argv, stdout: $stdout, stderr: $stderr, stdin: $stdin)
|
|
15
15
|
@argv = argv
|
|
16
16
|
@stdout = stdout
|
|
17
17
|
@stderr = stderr
|
|
18
|
+
@stdin = stdin
|
|
19
|
+
@color_override = nil
|
|
18
20
|
end
|
|
19
21
|
|
|
20
22
|
# Returns a process exit code: 0 if clean, 1 if security findings were
|
|
@@ -26,25 +28,32 @@ module Scryer
|
|
|
26
28
|
# would collide with the main parser's -p/-o meanings, so it's
|
|
27
29
|
# dispatched before the main OptionParser ever sees the rest of argv.
|
|
28
30
|
return run_verify(@argv[1..]) if @argv.first == "verify"
|
|
31
|
+
return run_fix(@argv[1..]) if @argv.first == "fix"
|
|
29
32
|
|
|
30
33
|
options = parse(@argv)
|
|
31
34
|
return 0 if options[:exit_early]
|
|
32
35
|
|
|
36
|
+
root = File.expand_path(options[:path] || Dir.pwd)
|
|
37
|
+
|
|
33
38
|
# The standalone executable has no equivalent of a Rails app's
|
|
34
39
|
# config/initializers/scryer.rb getting autoloaded at boot — this is
|
|
35
40
|
# the only way to run Scryer.configure (set c.ai_client, c.skip_rules,
|
|
36
|
-
# c.dirs, ...) before a scan starts outside Rails.
|
|
37
|
-
|
|
41
|
+
# c.dirs, ...) before a scan starts outside Rails. With no explicit
|
|
42
|
+
# `-r`, fall back to auto-requiring config/initializers/scryer.rb
|
|
43
|
+
# under `root`, if it exists — see #auto_discover_initializer.
|
|
44
|
+
require_paths = Array(options[:require])
|
|
45
|
+
require_paths = auto_discover_initializer(root) if require_paths.empty?
|
|
46
|
+
require_paths.each { |path| require File.expand_path(path) }
|
|
38
47
|
|
|
39
48
|
return check_gem(options[:check_gem]) if options[:check_gem]
|
|
40
49
|
|
|
41
|
-
root = File.expand_path(options[:path] || Dir.pwd)
|
|
42
50
|
return audit_deps(root) if options[:audit_deps]
|
|
43
51
|
|
|
44
52
|
skip_rules = Scryer.configuration.skip_rules + (options[:skip] || [])
|
|
45
53
|
@stdout.puts "Scryer: skipping #{skip_rules.join(', ')}." if skip_rules.any?
|
|
54
|
+
detect_duplicates = options[:no_duplicates] ? false : Scryer.configuration.detect_duplicates
|
|
46
55
|
|
|
47
|
-
result = Scanner.new(root: root, dirs: Scryer.configuration.dirs, skip_rules: skip_rules).call
|
|
56
|
+
result = Scanner.new(root: root, dirs: Scryer.configuration.dirs, skip_rules: skip_rules, detect_duplicates: detect_duplicates).call
|
|
48
57
|
|
|
49
58
|
# Dependency auditing (OSV.dev) runs by default — a single `scryer`
|
|
50
59
|
# invocation is meant to cover the same ground as RuboCop + Brakeman +
|
|
@@ -98,6 +107,41 @@ module Scryer
|
|
|
98
107
|
|
|
99
108
|
UsageError = Class.new(StandardError)
|
|
100
109
|
|
|
110
|
+
# Only consulted when `-r`/`--require` wasn't passed at all — an explicit
|
|
111
|
+
# `-r` (even to a file that doesn't exist, which `require` will raise on)
|
|
112
|
+
# always wins and this is never consulted. config/initializers/scryer.rb
|
|
113
|
+
# is exactly where a Rails app's own boot process would autoload
|
|
114
|
+
# Scryer.configure from (see the generator template at
|
|
115
|
+
# lib/generators/scryer/templates/scryer_initializer.rb); this just saves
|
|
116
|
+
# having to pass `-r config/initializers/scryer.rb` by hand every time
|
|
117
|
+
# when running the standalone executable against a Rails project that
|
|
118
|
+
# already has one. Prints a one-line notice so this is never a silent
|
|
119
|
+
# behavior switch — the whole point is to avoid the "AI client configured
|
|
120
|
+
# but scryer fix isn't using it" confusion an unnoticed missing `-r` used
|
|
121
|
+
# to cause.
|
|
122
|
+
def auto_discover_initializer(root)
|
|
123
|
+
candidate = File.join(root, "config", "initializers", "scryer.rb")
|
|
124
|
+
return [] unless File.file?(candidate)
|
|
125
|
+
|
|
126
|
+
@stdout.puts "Scryer: no -r/--require given — found and requiring #{candidate} (pass -r to override)."
|
|
127
|
+
[candidate]
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Thin wrappers around Scryer::Colorizer bound to this CLI's own stdout
|
|
131
|
+
# and --color/--no-color override — every color call in this file goes
|
|
132
|
+
# through these three instead of calling Colorizer directly.
|
|
133
|
+
def paint(text, *styles)
|
|
134
|
+
Colorizer.paint(text, *styles, stream: @stdout, override: @color_override)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def paint_severity(text, severity)
|
|
138
|
+
Colorizer.severity(text, severity, stream: @stdout, override: @color_override)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def paint_grade(text, letter)
|
|
142
|
+
Colorizer.grade(text, letter, stream: @stdout, override: @color_override)
|
|
143
|
+
end
|
|
144
|
+
|
|
101
145
|
# Mirrors the `scryer:audit_dependencies` rake task's output/exit-code
|
|
102
146
|
# behavior, so `scryer --audit-deps` works the same outside a Rails app.
|
|
103
147
|
def audit_deps(root)
|
|
@@ -110,7 +154,7 @@ module Scryer
|
|
|
110
154
|
credentials_exposure = DependencyAudit.credentials_exposure_check(root)
|
|
111
155
|
|
|
112
156
|
(insecure + vulnerable + ruby_eol + credentials_exposure).each do |f|
|
|
113
|
-
@stdout.puts "[#{f.severity.upcase}] #{dependency_label(f)}"
|
|
157
|
+
@stdout.puts "[#{paint_severity(f.severity.upcase, f.severity)}] #{dependency_label(f)}"
|
|
114
158
|
@stdout.puts " fix: #{f.suggested_fix}"
|
|
115
159
|
end
|
|
116
160
|
|
|
@@ -147,10 +191,10 @@ module Scryer
|
|
|
147
191
|
["Dependencies", deps_count]
|
|
148
192
|
]
|
|
149
193
|
|
|
150
|
-
divider = "─" * 32
|
|
194
|
+
divider = paint("─" * 32, :gray)
|
|
151
195
|
score = renderer.security_score
|
|
152
196
|
@stdout.puts ""
|
|
153
|
-
@stdout.puts "Scryer Audit — #{result.files_scanned} files scanned"
|
|
197
|
+
@stdout.puts paint("Scryer Audit — #{result.files_scanned} files scanned", :bold)
|
|
154
198
|
@stdout.puts divider
|
|
155
199
|
@stdout.puts ""
|
|
156
200
|
if baseline_path
|
|
@@ -159,12 +203,12 @@ module Scryer
|
|
|
159
203
|
@stdout.puts ""
|
|
160
204
|
end
|
|
161
205
|
clean_rate = renderer.rules_clean_rate
|
|
162
|
-
@stdout.puts "Security Score: #{score["score"]}/100 (#{score["grade"]})"
|
|
206
|
+
@stdout.puts "Security Score: #{score["score"]}/100 (#{paint_grade(score["grade"], score["grade"])})"
|
|
163
207
|
@stdout.puts "Checks: #{clean_rate["clean"]}/#{clean_rate["total"]} rules clean (#{clean_rate["percent"]}%)"
|
|
164
208
|
@stdout.puts ""
|
|
165
209
|
rows.each { |label, count| @stdout.puts summary_row(label, count) }
|
|
166
210
|
@stdout.puts divider
|
|
167
|
-
@stdout.puts summary_row("Total", total)
|
|
211
|
+
@stdout.puts paint(summary_row("Total", total), :bold)
|
|
168
212
|
@stdout.puts ""
|
|
169
213
|
print_top_priorities(renderer.top_risks)
|
|
170
214
|
print_owasp_coverage(renderer.owasp_coverage)
|
|
@@ -183,9 +227,9 @@ module Scryer
|
|
|
183
227
|
def print_top_priorities(risks)
|
|
184
228
|
return if risks.empty?
|
|
185
229
|
|
|
186
|
-
@stdout.puts "Top priorities:"
|
|
230
|
+
@stdout.puts paint("Top priorities:", :bold)
|
|
187
231
|
risks.each_with_index do |r, i|
|
|
188
|
-
@stdout.puts " #{i + 1}. [#{r[:severity]}] #{r[:category]} — #{r[:label]} (#{r[:location]})"
|
|
232
|
+
@stdout.puts " #{i + 1}. [#{paint_severity(r[:severity], r[:severity])}] #{r[:category]} — #{r[:label]} (#{r[:location]})"
|
|
189
233
|
end
|
|
190
234
|
@stdout.puts ""
|
|
191
235
|
end
|
|
@@ -201,23 +245,32 @@ module Scryer
|
|
|
201
245
|
@stdout.puts ""
|
|
202
246
|
end
|
|
203
247
|
|
|
204
|
-
# `scryer verify
|
|
205
|
-
#
|
|
206
|
-
#
|
|
207
|
-
#
|
|
208
|
-
#
|
|
209
|
-
#
|
|
210
|
-
#
|
|
211
|
-
#
|
|
248
|
+
# `scryer verify` — re-checks whether specific findings still fire,
|
|
249
|
+
# independent of writing a full report. `--rule`/`--file` narrow the
|
|
250
|
+
# scope; either or both can be omitted to broaden it:
|
|
251
|
+
# --rule ID --file PATH → re-parses just that one file and re-runs
|
|
252
|
+
# just that one rule (the original, narrowest
|
|
253
|
+
# case — meant to run right after applying a
|
|
254
|
+
# fix by hand or reviewing an AI suggestion,
|
|
255
|
+
# without waiting on/paying for a full scan)
|
|
256
|
+
# --file PATH only → re-parses that one file, runs every rule
|
|
257
|
+
# --rule ID only → full project scan, filtered to that rule
|
|
258
|
+
# neither → full project scan, every rule, every file
|
|
259
|
+
# Deliberately narrower than "did this change introduce a NEW finding
|
|
260
|
+
# elsewhere" in the --rule+--file case — that's what a normal `scryer`
|
|
261
|
+
# run (or `--baseline`) already answers; only the "neither" case here
|
|
262
|
+
# actually covers the whole project, and even then without a report.
|
|
212
263
|
def run_verify(argv)
|
|
213
264
|
options = {}
|
|
214
265
|
|
|
215
266
|
parser = OptionParser.new do |opts|
|
|
216
|
-
opts.banner = "Usage: scryer verify --rule RULE_ID --file PATH [--path ROOT]"
|
|
217
|
-
opts.on("--rule RULE_ID", "
|
|
218
|
-
opts.on("--file PATH", "
|
|
219
|
-
opts.on("--path ROOT", "Project root PATH is relative to (default: current directory).") { |v| options[:root] = v }
|
|
267
|
+
opts.banner = "Usage: scryer verify [--rule RULE_ID] [--file PATH] [--path ROOT]"
|
|
268
|
+
opts.on("--rule RULE_ID", "Only re-check this rule_id (omit to check every rule) — see `scryer verify --list-rules`.") { |v| options[:rule] = v }
|
|
269
|
+
opts.on("--file PATH", "Only re-check this file (omit to check the whole project) — relative to --path, or absolute.") { |v| options[:file] = v }
|
|
270
|
+
opts.on("--path ROOT", "Project root PATH is relative to, or that gets checked entirely if --file is omitted (default: current directory).") { |v| options[:root] = v }
|
|
220
271
|
opts.on("--list-rules", "List every known rule_id and exit.") { options[:list_rules] = true }
|
|
272
|
+
opts.on("--color", "Force colored output even when stdout isn't a terminal.") { options[:color] = true }
|
|
273
|
+
opts.on("--no-color", "Disable colored output even at a real terminal.") { options[:color] = false }
|
|
221
274
|
opts.on("-h", "--help", "Show this help.") { options[:exit_early] = true; @stdout.puts opts }
|
|
222
275
|
end
|
|
223
276
|
|
|
@@ -226,6 +279,7 @@ module Scryer
|
|
|
226
279
|
rescue OptionParser::ParseError => e
|
|
227
280
|
raise UsageError, "#{e.message}\n#{parser}"
|
|
228
281
|
end
|
|
282
|
+
@color_override = options[:color]
|
|
229
283
|
return 0 if options[:exit_early]
|
|
230
284
|
|
|
231
285
|
if options[:list_rules]
|
|
@@ -233,15 +287,23 @@ module Scryer
|
|
|
233
287
|
return 0
|
|
234
288
|
end
|
|
235
289
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
raise UsageError, "unknown rule_id #{options[:rule].inspect} — run `scryer verify --list-rules` to see valid ids."
|
|
290
|
+
rule_class = nil
|
|
291
|
+
if options[:rule]
|
|
292
|
+
rule_class = RuleSet.all.find { |r| r.rule_id == options[:rule] }
|
|
293
|
+
raise UsageError, "unknown rule_id #{options[:rule].inspect} — run `scryer verify --list-rules` to see valid ids." unless rule_class
|
|
241
294
|
end
|
|
242
295
|
|
|
243
296
|
root = File.expand_path(options[:root] || Dir.pwd)
|
|
244
|
-
|
|
297
|
+
|
|
298
|
+
if options[:file]
|
|
299
|
+
verify_file(options[:file], root: root, rule_class: rule_class, rule_id: options[:rule])
|
|
300
|
+
else
|
|
301
|
+
verify_project(root: root, rule_id: options[:rule])
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def verify_file(file, root:, rule_class:, rule_id:)
|
|
306
|
+
abs_path = File.expand_path(file, root)
|
|
245
307
|
raise UsageError, "no such file: #{abs_path}" unless File.file?(abs_path)
|
|
246
308
|
|
|
247
309
|
rel_path = abs_path.sub(/\A#{Regexp.escape(root)}\/?/, "")
|
|
@@ -257,18 +319,437 @@ module Scryer
|
|
|
257
319
|
"than this gem's Ruby runtime supports)."
|
|
258
320
|
end
|
|
259
321
|
|
|
260
|
-
|
|
322
|
+
rule_classes = rule_class ? [rule_class] : RuleSet.all
|
|
323
|
+
findings = rule_classes.flat_map { |rc| rc.new(file: rel_path, source: source, sexp: sexp).scan }
|
|
261
324
|
|
|
262
325
|
if findings.empty?
|
|
263
|
-
|
|
326
|
+
subject = rule_id ? "#{rule_id} no longer fires on #{rel_path}" : "#{rel_path} is clean — no findings"
|
|
327
|
+
@stdout.puts "scryer verify: #{subject} — #{paint('fix verified', :green, :bold)}."
|
|
264
328
|
0
|
|
265
329
|
else
|
|
266
|
-
|
|
267
|
-
|
|
330
|
+
subject = rule_id ? "#{rule_id} #{paint('still fires', :red, :bold)} on #{rel_path}" : "#{rel_path} #{paint('still has findings', :red, :bold)}"
|
|
331
|
+
@stdout.puts "scryer verify: #{subject} (#{findings.size} finding(s)):"
|
|
332
|
+
if rule_class
|
|
333
|
+
findings.each { |f| @stdout.puts " line #{f.line}: #{f.message}" }
|
|
334
|
+
else
|
|
335
|
+
@stdout.puts ""
|
|
336
|
+
print_findings_list(findings)
|
|
337
|
+
end
|
|
268
338
|
1
|
|
269
339
|
end
|
|
270
340
|
end
|
|
271
341
|
|
|
342
|
+
def verify_project(root:, rule_id:)
|
|
343
|
+
# detect_duplicates: false — `scryer verify` only ever looks at
|
|
344
|
+
# security/performance/style findings (see below), never
|
|
345
|
+
# duplicate_groups, so there's nothing to gain from running that pass
|
|
346
|
+
# here regardless of Scryer.configuration.detect_duplicates.
|
|
347
|
+
result = Scanner.new(root: root, dirs: Scryer.configuration.dirs, skip_rules: Scryer.configuration.skip_rules, detect_duplicates: false).call
|
|
348
|
+
candidates = result.security_findings + result.performance_findings + result.style_findings
|
|
349
|
+
candidates = candidates.select { |f| f.rule_id == rule_id } if rule_id
|
|
350
|
+
|
|
351
|
+
if candidates.empty?
|
|
352
|
+
subject = rule_id ? "#{rule_id} no longer fires anywhere under #{root}" : "#{root} is clean — no findings"
|
|
353
|
+
@stdout.puts "scryer verify: #{subject} — #{paint('fix verified', :green, :bold)}."
|
|
354
|
+
0
|
|
355
|
+
else
|
|
356
|
+
subject = rule_id ? "#{rule_id} #{paint('still fires', :red, :bold)} under #{root}" : "#{paint('still has findings', :red, :bold)} under #{root}"
|
|
357
|
+
@stdout.puts "scryer verify: #{subject} (#{candidates.size} finding(s)):"
|
|
358
|
+
@stdout.puts ""
|
|
359
|
+
print_findings_list(candidates)
|
|
360
|
+
1
|
|
361
|
+
end
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
SEVERITY_ORDER = { "critical" => 0, "warning" => 1, "info" => 2 }.freeze
|
|
365
|
+
|
|
366
|
+
# Grouped by severity (critical first, matching "Top priorities"), one
|
|
367
|
+
# short line per finding (severity tag + location) followed by the
|
|
368
|
+
# message truncated to a scannable length — full untruncated detail is
|
|
369
|
+
# what a real `scryer` report (JSON/HTML) is for; this is meant to be a
|
|
370
|
+
# quick "what's still failing" glance, not a wall of text.
|
|
371
|
+
def print_findings_list(findings)
|
|
372
|
+
findings.sort_by { |f| SEVERITY_ORDER.fetch(f.severity, 3) }.each do |f|
|
|
373
|
+
@stdout.puts " [#{paint_severity(f.severity, f.severity)}] #{f.rule_id} — #{f.file}:#{f.line}"
|
|
374
|
+
@stdout.puts " #{truncate_message(f.message)}"
|
|
375
|
+
end
|
|
376
|
+
@stdout.puts ""
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
def truncate_message(text, limit: 100)
|
|
380
|
+
text = text.to_s.strip
|
|
381
|
+
return text if text.length <= limit
|
|
382
|
+
|
|
383
|
+
cut = text[0...limit]
|
|
384
|
+
cut = cut[0...(cut.rindex(" ") || limit)]
|
|
385
|
+
"#{cut}…"
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
# `scryer fix` — the third leg of scan → fix → verify. Scans, asks the
|
|
389
|
+
# configured `ai_client` for a rewrite of every qualifying finding (same
|
|
390
|
+
# AiFixSuggester/FixVerifier machinery `scryer:report` already uses to
|
|
391
|
+
# populate `fix_verified`), and — this is the one command in the whole
|
|
392
|
+
# gem that does this — actually writes a fix to a real file, but ONLY
|
|
393
|
+
# when FixVerifier's in-memory check says that exact rewrite clears the
|
|
394
|
+
# finding. Anything not independently verified this way is left alone
|
|
395
|
+
# and reported as needing manual review, same as it would be in a normal
|
|
396
|
+
# report; this command never writes an unverified guess. Requires an
|
|
397
|
+
# `ai_client` (see -r/--require) — there's no non-AI fallback, since a
|
|
398
|
+
# rule's generic suggested_fix is prose, not a machine-applicable patch.
|
|
399
|
+
# `--deps` switches to a completely separate pipeline for dependency
|
|
400
|
+
# findings instead — see Scryer::DependencyFixer and #run_fix_deps below.
|
|
401
|
+
def run_fix(argv)
|
|
402
|
+
options = {}
|
|
403
|
+
|
|
404
|
+
parser = OptionParser.new do |opts|
|
|
405
|
+
opts.banner = "Usage: scryer fix [--rule RULE_ID] [--file PATH] [--number N] [--list] [--path ROOT] [--dry-run]\n" \
|
|
406
|
+
" scryer fix --deps [--path ROOT] [--dry-run]"
|
|
407
|
+
opts.on("--rule RULE_ID", "Only fix findings for this rule_id (repeatable).") { |v| (options[:rules] ||= []) << v }
|
|
408
|
+
opts.on("--file PATH", "Only fix findings in this file (repeatable) — relative to --path, or absolute.") { |v| (options[:files] ||= []) << v }
|
|
409
|
+
opts.on("--number N", "Only fix the finding(s) at this position in the numbered candidate " \
|
|
410
|
+
"list (see --list; repeatable, or comma-separated: --number 1,3). " \
|
|
411
|
+
"Numbering is stable across runs with the same --rule/--file filters.") do |v|
|
|
412
|
+
(options[:numbers] ||= []).concat(v.split(",").map(&:strip))
|
|
413
|
+
end
|
|
414
|
+
opts.on("--list", "Print the matching findings as a numbered list (after --rule/--file " \
|
|
415
|
+
"filters) and exit without fixing anything.") { options[:list] = true }
|
|
416
|
+
opts.on("--path ROOT", "Project root to scan (default: current directory).") { |v| options[:root] = v }
|
|
417
|
+
opts.on("-r PATH", "--require PATH",
|
|
418
|
+
"Require a Ruby file before scanning (repeatable) — use this to call " \
|
|
419
|
+
"Scryer.configure and set c.ai_client, same as the main scan command. If " \
|
|
420
|
+
"omitted, config/initializers/scryer.rb under --path is auto-required when it " \
|
|
421
|
+
"exists.") { |v| (options[:require] ||= []) << v }
|
|
422
|
+
opts.on("--skip RULE_ID", "Skip a rule by rule_id (repeatable), same as the main scan command.") { |v| (options[:skip] ||= []) << v }
|
|
423
|
+
opts.on("--dry-run", "Show what would be fixed without writing anything.") { options[:dry_run] = true }
|
|
424
|
+
opts.on("--yes", "Apply every independently-verified fix automatically, skipping the " \
|
|
425
|
+
"per-finding accept/skip prompt this command otherwise shows in an " \
|
|
426
|
+
"interactive terminal.") { options[:yes] = true }
|
|
427
|
+
opts.on("--deps", "Fix vulnerable dependencies instead of rule-based findings — runs " \
|
|
428
|
+
"`bundle update GEM --conservative` for each vulnerable gem " \
|
|
429
|
+
"(DependencyAudit.vulnerable_gems) that has a published patched " \
|
|
430
|
+
"version, then re-checks OSV.dev to confirm it cleared. Ignores " \
|
|
431
|
+
"--rule/--file/--number/--list, which only make sense for rule-based " \
|
|
432
|
+
"findings; --path/--dry-run/--yes/--color/--no-color still apply.") { options[:deps] = true }
|
|
433
|
+
opts.on("--color", "Force colored output even when stdout isn't a terminal.") { options[:color] = true }
|
|
434
|
+
opts.on("--no-color", "Disable colored output even at a real terminal.") { options[:color] = false }
|
|
435
|
+
opts.on("-h", "--help", "Show this help.") { options[:exit_early] = true; @stdout.puts opts }
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
begin
|
|
439
|
+
parser.parse!(argv)
|
|
440
|
+
rescue OptionParser::ParseError => e
|
|
441
|
+
raise UsageError, "#{e.message}\n#{parser}"
|
|
442
|
+
end
|
|
443
|
+
@color_override = options[:color]
|
|
444
|
+
return 0 if options[:exit_early]
|
|
445
|
+
|
|
446
|
+
root = File.expand_path(options[:root] || Dir.pwd)
|
|
447
|
+
|
|
448
|
+
# No explicit `-r`: fall back to auto-requiring
|
|
449
|
+
# config/initializers/scryer.rb under `root`, if it exists — see
|
|
450
|
+
# #auto_discover_initializer.
|
|
451
|
+
require_paths = Array(options[:require])
|
|
452
|
+
require_paths = auto_discover_initializer(root) if require_paths.empty?
|
|
453
|
+
require_paths.each { |path| require File.expand_path(path) }
|
|
454
|
+
|
|
455
|
+
return run_fix_deps(options, root) if options[:deps]
|
|
456
|
+
|
|
457
|
+
ai_client = Scryer.configuration.ai_client
|
|
458
|
+
skip_rules = Scryer.configuration.skip_rules + (options[:skip] || [])
|
|
459
|
+
# detect_duplicates: false — `scryer fix` only ever works from
|
|
460
|
+
# security/performance/style findings (see `candidates` below); a
|
|
461
|
+
# duplicate-code group has no rule_id/single-line suggested_fix for
|
|
462
|
+
# FixRunner to act on, so there's nothing to gain from that pass here.
|
|
463
|
+
result = Scanner.new(root: root, dirs: Scryer.configuration.dirs, skip_rules: skip_rules, detect_duplicates: false).call
|
|
464
|
+
|
|
465
|
+
candidates = (result.security_findings + result.performance_findings + result.style_findings)
|
|
466
|
+
candidates = candidates.select { |f| options[:rules].include?(f.rule_id) } if options[:rules]
|
|
467
|
+
candidates = candidates.select { |f| fix_target_file?(f, options[:files], root) } if options[:files]
|
|
468
|
+
candidates = candidates.sort_by { |f| [f.file.to_s, f.line || 0] }
|
|
469
|
+
|
|
470
|
+
if candidates.empty?
|
|
471
|
+
@stdout.puts "scryer fix: no matching findings to fix."
|
|
472
|
+
return 0
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
if options[:list]
|
|
476
|
+
print_candidate_list(candidates)
|
|
477
|
+
return 0
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
candidates = gate_frozen_string_literal(candidates, explicit: !options[:rules].nil?)
|
|
481
|
+
if candidates.empty?
|
|
482
|
+
@stdout.puts "scryer fix: no matching findings to fix."
|
|
483
|
+
return 0
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
candidates = select_by_number(candidates, options[:numbers]) if options[:numbers]
|
|
487
|
+
|
|
488
|
+
# A handful of rules (see Scryer::MechanicalFixer) have exactly one
|
|
489
|
+
# correct, deterministic fix and don't need an ai_client at all — only
|
|
490
|
+
# refuse to run when there's genuinely nothing this invocation could
|
|
491
|
+
# possibly fix: no ai_client configured AND none of the matched
|
|
492
|
+
# findings are mechanically fixable. Anything mechanically fixable
|
|
493
|
+
# still gets fixed even with no ai_client set; anything that needs AI
|
|
494
|
+
# but has none configured is simply left for manual review, same as
|
|
495
|
+
# an AI reply the verifier rejects.
|
|
496
|
+
if ai_client.nil? && candidates.none? { |f| MechanicalFixer.supported?(f.rule_id) }
|
|
497
|
+
raise UsageError, "scryer fix needs an ai_client configured (see -r/--require and the " \
|
|
498
|
+
"README's \"AI-assisted fix suggestions\" section) — none of the " \
|
|
499
|
+
"matched finding(s) have a built-in mechanical fixer " \
|
|
500
|
+
"(#{MechanicalFixer::SUPPORTED_RULES.join(", ")}), and a rule's generic " \
|
|
501
|
+
"suggested_fix is prose, not something this command can apply " \
|
|
502
|
+
"automatically on its own. Nothing has been changed.\n#{parser}"
|
|
503
|
+
end
|
|
504
|
+
|
|
505
|
+
# In an interactive terminal (and not --dry-run/--yes), review each
|
|
506
|
+
# independently-verified fix one at a time before writing it — "yes /
|
|
507
|
+
# skip / yes to all remaining / cancel" — instead of silently applying
|
|
508
|
+
# everything that verified clean. Non-interactive runs (CI, piped
|
|
509
|
+
# stdin) and --dry-run/--yes keep the old apply-everything-verified
|
|
510
|
+
# behavior, since there's no one to ask.
|
|
511
|
+
confirm = build_fix_confirmer if interactive_terminal? && !options[:dry_run] && !options[:yes]
|
|
512
|
+
|
|
513
|
+
@stdout.puts "scryer fix: #{candidates.size} candidate finding(s) — applying built-in fixes " \
|
|
514
|
+
"where one exists, otherwise asking the configured AI client for a rewrite; " \
|
|
515
|
+
"either way, only writing what's independently verified to clear the " \
|
|
516
|
+
"finding#{options[:dry_run] ? " (--dry-run: nothing will actually be written)" : ""}..."
|
|
517
|
+
|
|
518
|
+
fixed, skipped = FixRunner.apply(candidates, client: ai_client, root: root, dry_run: options[:dry_run], confirm: confirm) do |finding, status, error|
|
|
519
|
+
print_fix_progress(finding, status, error)
|
|
520
|
+
end
|
|
521
|
+
|
|
522
|
+
print_fix_summary(fixed: fixed, skipped: skipped, dry_run: options[:dry_run])
|
|
523
|
+
|
|
524
|
+
return 0 if options[:dry_run]
|
|
525
|
+
|
|
526
|
+
verify_applied_fixes(fixed, root: root, skip_rules: skip_rules) if fixed.any?
|
|
527
|
+
|
|
528
|
+
skipped.empty? ? 0 : 1
|
|
529
|
+
end
|
|
530
|
+
|
|
531
|
+
# Printed once per finding, as FixRunner.apply resolves it — not just in
|
|
532
|
+
# the final summary — so a developer watching the run can see what's
|
|
533
|
+
# happening and why, not just a wall of silence until it's all done.
|
|
534
|
+
def print_fix_progress(finding, status, error = nil)
|
|
535
|
+
label = { fixed: "Fixed", would_fix: "Would fix", declined: "Skipped (declined)",
|
|
536
|
+
ai_error: "Skipped (AI client error)", cancelled: "Skipped (cancelled)",
|
|
537
|
+
skipped: "Skipped (needs manual review)" }.fetch(status)
|
|
538
|
+
colored_label = %i[fixed would_fix].include?(status) ? paint(label, :green, :bold) : paint(label, :yellow)
|
|
539
|
+
@stdout.puts "#{colored_label}: #{finding.rule_id} — #{finding.file}:#{finding.line}"
|
|
540
|
+
|
|
541
|
+
if status == :ai_error && error
|
|
542
|
+
@stdout.puts " #{paint("#{error.class}: #{error.message}", :red)}"
|
|
543
|
+
else
|
|
544
|
+
explanation = FixRunner.explain(finding.suggested_fix)
|
|
545
|
+
@stdout.puts " #{explanation}" unless explanation.empty?
|
|
546
|
+
end
|
|
547
|
+
end
|
|
548
|
+
|
|
549
|
+
def print_fix_summary(fixed:, skipped:, dry_run:)
|
|
550
|
+
verb = dry_run ? "Would fix" : "Fixed"
|
|
551
|
+
@stdout.puts ""
|
|
552
|
+
@stdout.puts paint("#{verb} #{fixed.size} finding(s):", :green, :bold)
|
|
553
|
+
fixed.each { |f| @stdout.puts " #{f.rule_id} — #{f.file}:#{f.line}" }
|
|
554
|
+
@stdout.puts ""
|
|
555
|
+
|
|
556
|
+
return if skipped.empty?
|
|
557
|
+
|
|
558
|
+
@stdout.puts paint("#{skipped.size} finding(s) not applied (declined, or fix not independently verified):", :yellow)
|
|
559
|
+
skipped.each { |f| @stdout.puts " #{f.rule_id} — #{f.file}:#{f.line}" }
|
|
560
|
+
@stdout.puts ""
|
|
561
|
+
end
|
|
562
|
+
|
|
563
|
+
# `scryer fix --deps` — see Scryer::DependencyFixer for why this is a
|
|
564
|
+
# separate pipeline from the rule-based one above (a DependencyAudit
|
|
565
|
+
# finding has no .line/.file/.rule_id to rewrite). Needs network (OSV.dev)
|
|
566
|
+
# both up front and again for DependencyFixer's own post-update re-check.
|
|
567
|
+
def run_fix_deps(options, root)
|
|
568
|
+
@stdout.puts "scryer fix --deps: querying OSV.dev for known-vulnerable gems (needs network)..."
|
|
569
|
+
findings = DependencyAudit.vulnerable_gems(root)
|
|
570
|
+
|
|
571
|
+
if findings.empty?
|
|
572
|
+
@stdout.puts "scryer fix --deps: no vulnerable gems found."
|
|
573
|
+
return 0
|
|
574
|
+
end
|
|
575
|
+
|
|
576
|
+
actionable = findings.select { |f| Array(f.patched_versions).any? }
|
|
577
|
+
if actionable.empty?
|
|
578
|
+
@stdout.puts "scryer fix --deps: #{findings.size} vulnerable gem finding(s), but none have " \
|
|
579
|
+
"a published patched version yet to upgrade to — nothing this command can run. " \
|
|
580
|
+
"See `scryer --audit-deps` for details."
|
|
581
|
+
return 1
|
|
582
|
+
end
|
|
583
|
+
|
|
584
|
+
gem_count = actionable.map(&:gem_name).uniq.size
|
|
585
|
+
@stdout.puts "scryer fix --deps: #{actionable.size} finding(s) across #{gem_count} gem(s) — " \
|
|
586
|
+
"running `bundle update GEM --conservative` for each" \
|
|
587
|
+
"#{options[:dry_run] ? " (--dry-run: nothing will actually run)" : ""}..."
|
|
588
|
+
|
|
589
|
+
fixed, skipped = DependencyFixer.apply(findings, root: root, dry_run: options[:dry_run]) do |finding, status, error|
|
|
590
|
+
print_deps_fix_progress(finding, status, error)
|
|
591
|
+
end
|
|
592
|
+
|
|
593
|
+
print_deps_fix_summary(fixed: fixed, skipped: skipped, dry_run: options[:dry_run])
|
|
594
|
+
return 0 if options[:dry_run]
|
|
595
|
+
|
|
596
|
+
skipped.empty? ? 0 : 1
|
|
597
|
+
end
|
|
598
|
+
|
|
599
|
+
def print_deps_fix_progress(finding, status, error = nil)
|
|
600
|
+
label = { fixed: "Fixed", would_fix: "Would fix", error: "Error running bundle update",
|
|
601
|
+
skipped: "Skipped (needs manual review)" }.fetch(status)
|
|
602
|
+
colored_label = %i[fixed would_fix].include?(status) ? paint(label, :green, :bold) : paint(label, :yellow)
|
|
603
|
+
@stdout.puts "#{colored_label}: #{finding.gem_name}#{finding.advisory_id ? " (#{finding.advisory_id})" : ""}"
|
|
604
|
+
|
|
605
|
+
if error
|
|
606
|
+
@stdout.puts " #{paint(error.to_s.strip, :red)}"
|
|
607
|
+
elsif status == :skipped && finding.suggested_fix
|
|
608
|
+
@stdout.puts " #{finding.suggested_fix}"
|
|
609
|
+
end
|
|
610
|
+
end
|
|
611
|
+
|
|
612
|
+
def print_deps_fix_summary(fixed:, skipped:, dry_run:)
|
|
613
|
+
verb = dry_run ? "Would fix" : "Fixed"
|
|
614
|
+
@stdout.puts ""
|
|
615
|
+
@stdout.puts paint("#{verb} #{fixed.size} finding(s):", :green, :bold)
|
|
616
|
+
fixed.each { |f| @stdout.puts " #{f.gem_name}#{f.advisory_id ? " (#{f.advisory_id})" : ""}" }
|
|
617
|
+
@stdout.puts ""
|
|
618
|
+
|
|
619
|
+
return if skipped.empty?
|
|
620
|
+
|
|
621
|
+
@stdout.puts paint("#{skipped.size} finding(s) not applied (no patched version yet, bundle update " \
|
|
622
|
+
"failed, or the re-check still shows it vulnerable):", :yellow)
|
|
623
|
+
skipped.each { |f| @stdout.puts " #{f.gem_name}#{f.advisory_id ? " (#{f.advisory_id})" : ""}" }
|
|
624
|
+
@stdout.puts ""
|
|
625
|
+
end
|
|
626
|
+
|
|
627
|
+
# The interactive half of FixRunner.apply's `confirm:` hook — shown once
|
|
628
|
+
# per independently-verified finding, right before it would be written.
|
|
629
|
+
# Numeric choices only (no y/n/a/s letters): "3" (yes to all remaining)
|
|
630
|
+
# latches acceptance for every later call without prompting again;
|
|
631
|
+
# "4" (cancel) returns :cancel, which FixRunner.apply treats as "stop
|
|
632
|
+
# entirely" — every remaining candidate is marked :cancelled without
|
|
633
|
+
# this lambda being called again. Anything other than "1"/"2"/"3"/"4"
|
|
634
|
+
# (including a blank answer) re-prompts rather than guessing.
|
|
635
|
+
def build_fix_confirmer
|
|
636
|
+
mode = :ask
|
|
637
|
+
lambda do |finding|
|
|
638
|
+
next true if mode == :all
|
|
639
|
+
|
|
640
|
+
print_finding_preview(finding)
|
|
641
|
+
loop do
|
|
642
|
+
@stdout.puts "Apply this fix?"
|
|
643
|
+
@stdout.puts " 1) Yes"
|
|
644
|
+
@stdout.puts " 2) Skip"
|
|
645
|
+
@stdout.puts " 3) Yes to all remaining"
|
|
646
|
+
@stdout.puts " 4) Cancel (stop reviewing — nothing further will be attempted)"
|
|
647
|
+
@stdout.print "Choice: "
|
|
648
|
+
@stdout.flush
|
|
649
|
+
case @stdin.gets.to_s.strip
|
|
650
|
+
when "1" then break true
|
|
651
|
+
when "2" then break false
|
|
652
|
+
when "3" then mode = :all; break true
|
|
653
|
+
when "4" then break :cancel
|
|
654
|
+
else @stdout.puts "Please enter 1, 2, 3, or 4."
|
|
655
|
+
end
|
|
656
|
+
end
|
|
657
|
+
end
|
|
658
|
+
end
|
|
659
|
+
|
|
660
|
+
def print_finding_preview(finding)
|
|
661
|
+
@stdout.puts ""
|
|
662
|
+
@stdout.puts "#{finding.rule_id} — #{finding.file}:#{finding.line}"
|
|
663
|
+
@stdout.puts " #{finding.message}" if finding.message
|
|
664
|
+
explanation = FixRunner.explain(finding.suggested_fix)
|
|
665
|
+
@stdout.puts " Fix: #{explanation}" unless explanation.empty?
|
|
666
|
+
after = FixVerifier.extract_after_snippet(finding.suggested_fix)
|
|
667
|
+
return unless after
|
|
668
|
+
|
|
669
|
+
@stdout.puts " AFTER:"
|
|
670
|
+
after.each_line { |line| @stdout.puts " #{line.chomp}" }
|
|
671
|
+
end
|
|
672
|
+
|
|
673
|
+
# frozen_string_literal (see MechanicalFixer::OPT_IN_RULES) is left out
|
|
674
|
+
# of an unscoped `scryer fix` sweep by default — it's a cosmetic,
|
|
675
|
+
# `info`-severity finding that would otherwise touch nearly every file
|
|
676
|
+
# in the project. Explicitly naming it via --rule is already informed
|
|
677
|
+
# consent, so this only gates an unscoped run: in an interactive
|
|
678
|
+
# terminal it asks; non-interactively (CI, piped stdin) it's excluded
|
|
679
|
+
# with a one-line notice, discoverable via `--rule frozen_string_literal`.
|
|
680
|
+
def gate_frozen_string_literal(candidates, explicit:)
|
|
681
|
+
return candidates if explicit
|
|
682
|
+
|
|
683
|
+
opted_in, rest = candidates.partition { |f| MechanicalFixer.opt_in?(f.rule_id) }
|
|
684
|
+
return candidates if opted_in.empty?
|
|
685
|
+
|
|
686
|
+
if interactive_terminal?
|
|
687
|
+
@stdout.print "#{opted_in.size} frozen_string_literal finding(s) matched — this only adds " \
|
|
688
|
+
"the magic comment to #{opted_in.size} file(s) (skipped automatically " \
|
|
689
|
+
"wherever an in-place string mutation makes it unsafe). Include them in " \
|
|
690
|
+
"this run? [y/N] "
|
|
691
|
+
@stdout.flush
|
|
692
|
+
answer = @stdin.gets.to_s.strip
|
|
693
|
+
return candidates if answer.downcase.start_with?("y")
|
|
694
|
+
end
|
|
695
|
+
|
|
696
|
+
@stdout.puts "scryer fix: excluding #{opted_in.size} frozen_string_literal finding(s) from " \
|
|
697
|
+
"this run — pass --rule frozen_string_literal to include them explicitly."
|
|
698
|
+
rest
|
|
699
|
+
end
|
|
700
|
+
|
|
701
|
+
# The "verify" leg: re-scan the whole project after every fix has been
|
|
702
|
+
# written and confirm each one is actually gone — see FixRunner.verify.
|
|
703
|
+
def verify_applied_fixes(fixed, root:, skip_rules:)
|
|
704
|
+
@stdout.puts "Re-scanning to verify every applied fix..."
|
|
705
|
+
regressed = FixRunner.verify(fixed, root: root, dirs: Scryer.configuration.dirs, skip_rules: skip_rules)
|
|
706
|
+
|
|
707
|
+
if regressed.empty?
|
|
708
|
+
@stdout.puts paint("Verified: all #{fixed.size} applied fix(es) confirmed clean on a full re-scan.", :green, :bold)
|
|
709
|
+
else
|
|
710
|
+
@stdout.puts paint("Warning: #{regressed.size} of #{fixed.size} applied fix(es) still show up on a full " \
|
|
711
|
+
"re-scan (an edit may have shifted another finding onto the same rule, or a duplicate " \
|
|
712
|
+
"finding existed elsewhere) — review these by hand:", :yellow)
|
|
713
|
+
regressed.each { |f| @stdout.puts " #{f.rule_id} — #{f.file}:#{f.line}" }
|
|
714
|
+
end
|
|
715
|
+
end
|
|
716
|
+
|
|
717
|
+
def print_candidate_list(candidates)
|
|
718
|
+
@stdout.puts ""
|
|
719
|
+
candidates.each_with_index { |f, i| @stdout.puts " #{i + 1}) #{f.rule_id} — #{f.file}:#{f.line} — #{f.message}" }
|
|
720
|
+
@stdout.puts ""
|
|
721
|
+
end
|
|
722
|
+
|
|
723
|
+
# Maps 1-based positions in `candidates` (as printed by print_candidate_list)
|
|
724
|
+
# back to the Finding at that position. Raises UsageError (caught by #run,
|
|
725
|
+
# same as every other bad-input case in this command) on a non-integer or
|
|
726
|
+
# out-of-range value — this never silently fixes something other than
|
|
727
|
+
# what was asked for.
|
|
728
|
+
def select_by_number(candidates, numbers)
|
|
729
|
+
indices = numbers.map do |n|
|
|
730
|
+
i = Integer(n, exception: false)
|
|
731
|
+
raise UsageError, "--number #{n.inspect} isn't a valid position — run with --list to see " \
|
|
732
|
+
"the numbered candidates first." unless i
|
|
733
|
+
raise UsageError, "--number #{i} is out of range — only #{candidates.size} matching " \
|
|
734
|
+
"finding(s) (see --list)." unless i.between?(1, candidates.size)
|
|
735
|
+
|
|
736
|
+
i
|
|
737
|
+
end
|
|
738
|
+
indices.uniq.sort.map { |i| candidates[i - 1] }
|
|
739
|
+
end
|
|
740
|
+
|
|
741
|
+
def interactive_terminal?
|
|
742
|
+
@stdin.respond_to?(:tty?) && @stdin.tty?
|
|
743
|
+
end
|
|
744
|
+
|
|
745
|
+
def fix_target_file?(finding, files, root)
|
|
746
|
+
files.any? do |f|
|
|
747
|
+
abs = File.expand_path(f, root)
|
|
748
|
+
rel = abs.sub(/\A#{Regexp.escape(root)}\/?/, "")
|
|
749
|
+
finding.file == rel || finding.file == f
|
|
750
|
+
end
|
|
751
|
+
end
|
|
752
|
+
|
|
272
753
|
# One-off OSV.dev lookup for a single gem — no Gemfile.lock, no scan,
|
|
273
754
|
# no other network calls. `spec` is "name" or "name:version" (colon
|
|
274
755
|
# rather than a second CLI arg, so this stays a single -o-style flag).
|
|
@@ -357,8 +838,8 @@ module Scryer
|
|
|
357
838
|
"Require a Ruby file before scanning (repeatable) — the file can call " \
|
|
358
839
|
"Scryer.configure to set c.ai_client, c.skip_rules, c.dirs, etc. This is the " \
|
|
359
840
|
"standalone executable's equivalent of a Rails app's config/initializers/scryer.rb " \
|
|
360
|
-
"getting autoloaded at boot
|
|
361
|
-
"
|
|
841
|
+
"getting autoloaded at boot. If omitted, config/initializers/scryer.rb under " \
|
|
842
|
+
"--path is auto-required when it exists.") { |v| (options[:require] ||= []) << v }
|
|
362
843
|
opts.on("--audit-deps",
|
|
363
844
|
"Check Gemfile.lock for known-vulnerable gems (via OSV.dev — needs network) and " \
|
|
364
845
|
"insecure git/http sources (offline), instead of running the normal static scan. " \
|
|
@@ -384,6 +865,10 @@ module Scryer
|
|
|
384
865
|
"Skip a rule by rule_id (repeatable) — e.g. a known false positive on this " \
|
|
385
866
|
"codebase. Adds to c.skip_rules for this run only; doesn't affect other " \
|
|
386
867
|
"invocations.") { |v| (options[:skip] ||= []) << v }
|
|
868
|
+
opts.on("--no-duplicates",
|
|
869
|
+
"Skip duplicate-code detection (method/query/cache-key similarity across models, " \
|
|
870
|
+
"controllers, helpers, and concerns) for this run only, regardless of " \
|
|
871
|
+
"c.detect_duplicates — doesn't affect other invocations.") { options[:no_duplicates] = true }
|
|
387
872
|
opts.on("--check-gem NAME[:VERSION]",
|
|
388
873
|
"Query OSV.dev for known vulnerabilities affecting a single gem, independent of " \
|
|
389
874
|
"any Gemfile.lock or full scan/audit — instead of running the normal static " \
|
|
@@ -391,6 +876,8 @@ module Scryer
|
|
|
391
876
|
"all versions.") { |v| options[:check_gem] = v }
|
|
392
877
|
opts.on("--project-name NAME", "Project name shown in the report header.") { |v| options[:project_name] = v }
|
|
393
878
|
opts.on("--branch BRANCH", "Git branch label recorded in the report (overrides the actual checked-out branch).") { |v| options[:branch] = v }
|
|
879
|
+
opts.on("--color", "Force colored output even when stdout isn't a terminal (e.g. piped to `less -R`).") { options[:color] = true }
|
|
880
|
+
opts.on("--no-color", "Disable colored output even at a real terminal.") { options[:color] = false }
|
|
394
881
|
opts.on("-v", "--version", "Show the Scryer version.") { options[:exit_early] = true; @stdout.puts Scryer::VERSION }
|
|
395
882
|
opts.on("-h", "--help", "Show this help.") { options[:exit_early] = true; @stdout.puts opts }
|
|
396
883
|
end
|
|
@@ -401,6 +888,7 @@ module Scryer
|
|
|
401
888
|
raise UsageError, "#{e.message}\n#{parser}"
|
|
402
889
|
end
|
|
403
890
|
|
|
891
|
+
@color_override = options[:color]
|
|
404
892
|
options
|
|
405
893
|
end
|
|
406
894
|
|