scryer 1.0.0 → 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 +414 -0
- data/README.md +114 -649
- 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/generators/scryer/USAGE +10 -2
- data/lib/generators/scryer/templates/scryer_initializer.rb +15 -0
- data/lib/scryer/ai_fix_suggester.rb +37 -11
- data/lib/scryer/ast.rb +26 -0
- data/lib/scryer/authorization_watcher.rb +156 -0
- data/lib/scryer/baseline.rb +75 -0
- data/lib/scryer/cli.rb +688 -14
- data/lib/scryer/colorizer.rb +56 -0
- data/lib/scryer/dependency_fixer.rb +96 -0
- data/lib/scryer/finding.rb +6 -0
- data/lib/scryer/fix_runner.rb +161 -0
- data/lib/scryer/fix_verifier.rb +169 -0
- data/lib/scryer/mechanical_fixer.rb +288 -0
- data/lib/scryer/minitest.rb +48 -0
- data/lib/scryer/performance_rules/inefficient_save_loop_rule.rb +32 -0
- data/lib/scryer/performance_rules/missing_pagination_rule.rb +1 -0
- data/lib/scryer/performance_rules/n_plus_one_query_rule.rb +1 -0
- data/lib/scryer/performance_rules/unbounded_table_scan_rule.rb +1 -0
- data/lib/scryer/report_renderer.rb +539 -46
- data/lib/scryer/rspec.rb +55 -0
- data/lib/scryer/rule.rb +22 -2
- data/lib/scryer/rules/action_cable_forgery_protection_rule.rb +3 -0
- data/lib/scryer/rules/active_storage_inline_disposition_rule.rb +3 -0
- data/lib/scryer/rules/active_storage_missing_content_type_validation_rule.rb +3 -0
- data/lib/scryer/rules/authentication_bypass_rule.rb +30 -7
- data/lib/scryer/rules/command_injection_rule.rb +3 -0
- data/lib/scryer/rules/consider_all_requests_local_rule.rb +51 -0
- data/lib/scryer/rules/cors_misconfiguration_rule.rb +51 -20
- data/lib/scryer/rules/csrf_protection_rule.rb +60 -11
- data/lib/scryer/rules/force_ssl_rule.rb +3 -0
- data/lib/scryer/rules/graphql_missing_query_limits_rule.rb +31 -0
- data/lib/scryer/rules/hardcoded_basic_auth_rule.rb +3 -0
- data/lib/scryer/rules/hardcoded_secret_key_base_rule.rb +3 -0
- data/lib/scryer/rules/hardcoded_secret_rule.rb +3 -0
- data/lib/scryer/rules/host_authorization_disabled_rule.rb +50 -0
- data/lib/scryer/rules/idor_rule.rb +63 -9
- data/lib/scryer/rules/insecure_cookie_serializer_rule.rb +3 -0
- data/lib/scryer/rules/job_raw_params_rule.rb +40 -7
- data/lib/scryer/rules/jwt_insecure_rule.rb +3 -0
- data/lib/scryer/rules/mass_assignment_rule.rb +32 -5
- data/lib/scryer/rules/missing_authorization_rule.rb +103 -0
- data/lib/scryer/rules/missing_policy_scope_rule.rb +134 -0
- data/lib/scryer/rules/open_redirect_rule.rb +3 -0
- data/lib/scryer/rules/path_traversal_rule.rb +22 -1
- data/lib/scryer/rules/security_headers_rule.rb +3 -0
- data/lib/scryer/rules/sql_injection_rule.rb +3 -0
- data/lib/scryer/rules/ssrf_rule.rb +67 -13
- data/lib/scryer/rules/unsafe_deserialization_rule.rb +3 -0
- data/lib/scryer/rules/verbose_production_log_level_rule.rb +53 -0
- data/lib/scryer/rules/weak_crypto_rule.rb +37 -2
- data/lib/scryer/rules/weak_session_cookie_rule.rb +3 -0
- data/lib/scryer/rules/xss_unsafe_html_rule.rb +41 -0
- data/lib/scryer/scanner.rb +25 -12
- data/lib/scryer/style_rules/frozen_string_literal_rule.rb +1 -0
- data/lib/scryer/version.rb +1 -1
- data/lib/scryer.rb +30 -1
- data/lib/tasks/scryer.rake +447 -20
- metadata +52 -12
data/lib/scryer/cli.rb
CHANGED
|
@@ -11,34 +11,49 @@ 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
|
|
21
23
|
# found (so `scryer -o report.json` can gate CI the way `brakeman -o
|
|
22
24
|
# report.json` does), 2 on a usage error.
|
|
23
25
|
def run
|
|
26
|
+
# `scryer verify` is a distinct subcommand, not a flag on the normal
|
|
27
|
+
# scan — it takes its own small option set (--rule/--file/--path) that
|
|
28
|
+
# would collide with the main parser's -p/-o meanings, so it's
|
|
29
|
+
# dispatched before the main OptionParser ever sees the rest of argv.
|
|
30
|
+
return run_verify(@argv[1..]) if @argv.first == "verify"
|
|
31
|
+
return run_fix(@argv[1..]) if @argv.first == "fix"
|
|
32
|
+
|
|
24
33
|
options = parse(@argv)
|
|
25
34
|
return 0 if options[:exit_early]
|
|
26
35
|
|
|
36
|
+
root = File.expand_path(options[:path] || Dir.pwd)
|
|
37
|
+
|
|
27
38
|
# The standalone executable has no equivalent of a Rails app's
|
|
28
39
|
# config/initializers/scryer.rb getting autoloaded at boot — this is
|
|
29
40
|
# the only way to run Scryer.configure (set c.ai_client, c.skip_rules,
|
|
30
|
-
# c.dirs, ...) before a scan starts outside Rails.
|
|
31
|
-
|
|
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) }
|
|
32
47
|
|
|
33
48
|
return check_gem(options[:check_gem]) if options[:check_gem]
|
|
34
49
|
|
|
35
|
-
root = File.expand_path(options[:path] || Dir.pwd)
|
|
36
50
|
return audit_deps(root) if options[:audit_deps]
|
|
37
51
|
|
|
38
52
|
skip_rules = Scryer.configuration.skip_rules + (options[:skip] || [])
|
|
39
53
|
@stdout.puts "Scryer: skipping #{skip_rules.join(', ')}." if skip_rules.any?
|
|
54
|
+
detect_duplicates = options[:no_duplicates] ? false : Scryer.configuration.detect_duplicates
|
|
40
55
|
|
|
41
|
-
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
|
|
42
57
|
|
|
43
58
|
# Dependency auditing (OSV.dev) runs by default — a single `scryer`
|
|
44
59
|
# invocation is meant to cover the same ground as RuboCop + Brakeman +
|
|
@@ -54,9 +69,16 @@ module Scryer
|
|
|
54
69
|
DependencyAudit.ruby_eol_check(root) + DependencyAudit.credentials_exposure_check(root)
|
|
55
70
|
end
|
|
56
71
|
|
|
72
|
+
return save_baseline(options[:save_baseline], result, dependency_findings) if options[:save_baseline]
|
|
73
|
+
|
|
74
|
+
fixed_count = 0
|
|
75
|
+
if options[:baseline]
|
|
76
|
+
dependency_findings, fixed_count = apply_baseline(options[:baseline], result, dependency_findings)
|
|
77
|
+
end
|
|
78
|
+
|
|
57
79
|
if Scryer.configuration.ai_client
|
|
58
80
|
@stdout.puts "Scryer: rewriting suggested fixes via the configured AI client..."
|
|
59
|
-
AiFixSuggester.enhance_result!(result)
|
|
81
|
+
AiFixSuggester.enhance_result!(result, root: root)
|
|
60
82
|
AiFixSuggester.enhance_many!(dependency_findings) unless dependency_findings.empty?
|
|
61
83
|
end
|
|
62
84
|
|
|
@@ -72,7 +94,8 @@ module Scryer
|
|
|
72
94
|
outputs = options[:outputs].empty? ? default_outputs(root) : options[:outputs]
|
|
73
95
|
outputs.each { |path| write_report(renderer, path) }
|
|
74
96
|
|
|
75
|
-
print_summary(result: result, dependency_findings: dependency_findings, ran_deps: ran_deps, outputs: outputs
|
|
97
|
+
print_summary(result: result, dependency_findings: dependency_findings, ran_deps: ran_deps, outputs: outputs,
|
|
98
|
+
renderer: renderer, fixed_count: fixed_count, baseline_path: options[:baseline])
|
|
76
99
|
|
|
77
100
|
result.security_findings.empty? && dependency_findings.empty? ? 0 : 1
|
|
78
101
|
rescue UsageError => e
|
|
@@ -84,6 +107,41 @@ module Scryer
|
|
|
84
107
|
|
|
85
108
|
UsageError = Class.new(StandardError)
|
|
86
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
|
+
|
|
87
145
|
# Mirrors the `scryer:audit_dependencies` rake task's output/exit-code
|
|
88
146
|
# behavior, so `scryer --audit-deps` works the same outside a Rails app.
|
|
89
147
|
def audit_deps(root)
|
|
@@ -96,7 +154,7 @@ module Scryer
|
|
|
96
154
|
credentials_exposure = DependencyAudit.credentials_exposure_check(root)
|
|
97
155
|
|
|
98
156
|
(insecure + vulnerable + ruby_eol + credentials_exposure).each do |f|
|
|
99
|
-
@stdout.puts "[#{f.severity.upcase}] #{dependency_label(f)}"
|
|
157
|
+
@stdout.puts "[#{paint_severity(f.severity.upcase, f.severity)}] #{dependency_label(f)}"
|
|
100
158
|
@stdout.puts " fix: #{f.suggested_fix}"
|
|
101
159
|
end
|
|
102
160
|
|
|
@@ -118,7 +176,7 @@ module Scryer
|
|
|
118
176
|
# category Scryer covers (security, performance, duplicate/smelly code,
|
|
119
177
|
# dependencies), the same categories usually split across RuboCop +
|
|
120
178
|
# Brakeman + bundler-audit + Reek, side by side in one box.
|
|
121
|
-
def print_summary(result:, dependency_findings:, ran_deps:, outputs:)
|
|
179
|
+
def print_summary(result:, dependency_findings:, ran_deps:, outputs:, renderer:, fixed_count: 0, baseline_path: nil)
|
|
122
180
|
# "Code Quality" is the umbrella label for both duplicate-code groups
|
|
123
181
|
# and rule-based style findings (e.g. frozen_string_literal) — two
|
|
124
182
|
# different detectors, same broad concern, one row in the box.
|
|
@@ -133,15 +191,27 @@ module Scryer
|
|
|
133
191
|
["Dependencies", deps_count]
|
|
134
192
|
]
|
|
135
193
|
|
|
136
|
-
divider = "─" * 32
|
|
194
|
+
divider = paint("─" * 32, :gray)
|
|
195
|
+
score = renderer.security_score
|
|
137
196
|
@stdout.puts ""
|
|
138
|
-
@stdout.puts "Scryer Audit — #{result.files_scanned} files scanned"
|
|
197
|
+
@stdout.puts paint("Scryer Audit — #{result.files_scanned} files scanned", :bold)
|
|
139
198
|
@stdout.puts divider
|
|
140
199
|
@stdout.puts ""
|
|
200
|
+
if baseline_path
|
|
201
|
+
@stdout.puts "Baseline: #{baseline_path} — showing new findings only " \
|
|
202
|
+
"(#{fixed_count} fixed since baseline)."
|
|
203
|
+
@stdout.puts ""
|
|
204
|
+
end
|
|
205
|
+
clean_rate = renderer.rules_clean_rate
|
|
206
|
+
@stdout.puts "Security Score: #{score["score"]}/100 (#{paint_grade(score["grade"], score["grade"])})"
|
|
207
|
+
@stdout.puts "Checks: #{clean_rate["clean"]}/#{clean_rate["total"]} rules clean (#{clean_rate["percent"]}%)"
|
|
208
|
+
@stdout.puts ""
|
|
141
209
|
rows.each { |label, count| @stdout.puts summary_row(label, count) }
|
|
142
210
|
@stdout.puts divider
|
|
143
|
-
@stdout.puts summary_row("Total", total)
|
|
211
|
+
@stdout.puts paint(summary_row("Total", total), :bold)
|
|
144
212
|
@stdout.puts ""
|
|
213
|
+
print_top_priorities(renderer.top_risks)
|
|
214
|
+
print_owasp_coverage(renderer.owasp_coverage)
|
|
145
215
|
outputs.each { |path| @stdout.puts "#{format_for(path).upcase} report: #{path}" }
|
|
146
216
|
end
|
|
147
217
|
|
|
@@ -150,6 +220,536 @@ module Scryer
|
|
|
150
220
|
"#{label.ljust(14)}#{value.rjust(20)}"
|
|
151
221
|
end
|
|
152
222
|
|
|
223
|
+
# The categories above are counted separately, but nothing else ranks
|
|
224
|
+
# across them — this is what actually backs "tells you what to fix
|
|
225
|
+
# first" rather than just splitting findings into four buckets. Same
|
|
226
|
+
# data ReportRenderer#top_risks already sorts for the HTML report.
|
|
227
|
+
def print_top_priorities(risks)
|
|
228
|
+
return if risks.empty?
|
|
229
|
+
|
|
230
|
+
@stdout.puts paint("Top priorities:", :bold)
|
|
231
|
+
risks.each_with_index do |r, i|
|
|
232
|
+
@stdout.puts " #{i + 1}. [#{paint_severity(r[:severity], r[:severity])}] #{r[:category]} — #{r[:label]} (#{r[:location]})"
|
|
233
|
+
end
|
|
234
|
+
@stdout.puts ""
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# Byproduct of every security rule carrying an owasp_category — see
|
|
238
|
+
# ReportRenderer#owasp_coverage. Scryer's own best-effort tagging, not an
|
|
239
|
+
# OWASP-audited mapping (documented in full in the README).
|
|
240
|
+
def print_owasp_coverage(coverage)
|
|
241
|
+
return if coverage.empty?
|
|
242
|
+
|
|
243
|
+
@stdout.puts "OWASP Top 10 (2021) coverage:"
|
|
244
|
+
coverage.each { |category, count| @stdout.puts " #{category}: #{count} finding#{"s" unless count == 1}" }
|
|
245
|
+
@stdout.puts ""
|
|
246
|
+
end
|
|
247
|
+
|
|
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.
|
|
263
|
+
def run_verify(argv)
|
|
264
|
+
options = {}
|
|
265
|
+
|
|
266
|
+
parser = OptionParser.new do |opts|
|
|
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 }
|
|
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 }
|
|
274
|
+
opts.on("-h", "--help", "Show this help.") { options[:exit_early] = true; @stdout.puts opts }
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
begin
|
|
278
|
+
parser.parse!(argv)
|
|
279
|
+
rescue OptionParser::ParseError => e
|
|
280
|
+
raise UsageError, "#{e.message}\n#{parser}"
|
|
281
|
+
end
|
|
282
|
+
@color_override = options[:color]
|
|
283
|
+
return 0 if options[:exit_early]
|
|
284
|
+
|
|
285
|
+
if options[:list_rules]
|
|
286
|
+
RuleSet.all.map(&:rule_id).sort.each { |id| @stdout.puts id }
|
|
287
|
+
return 0
|
|
288
|
+
end
|
|
289
|
+
|
|
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
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
root = File.expand_path(options[:root] || Dir.pwd)
|
|
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)
|
|
307
|
+
raise UsageError, "no such file: #{abs_path}" unless File.file?(abs_path)
|
|
308
|
+
|
|
309
|
+
rel_path = abs_path.sub(/\A#{Regexp.escape(root)}\/?/, "")
|
|
310
|
+
source = File.read(abs_path)
|
|
311
|
+
|
|
312
|
+
sexp = begin
|
|
313
|
+
Ripper.sexp(source)
|
|
314
|
+
rescue StandardError => e
|
|
315
|
+
raise UsageError, "#{rel_path} failed to parse: #{e.message}"
|
|
316
|
+
end
|
|
317
|
+
if sexp.nil?
|
|
318
|
+
raise UsageError, "#{rel_path} could not be parsed (a syntax error, or Ruby syntax newer " \
|
|
319
|
+
"than this gem's Ruby runtime supports)."
|
|
320
|
+
end
|
|
321
|
+
|
|
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 }
|
|
324
|
+
|
|
325
|
+
if findings.empty?
|
|
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)}."
|
|
328
|
+
0
|
|
329
|
+
else
|
|
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
|
|
338
|
+
1
|
|
339
|
+
end
|
|
340
|
+
end
|
|
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
|
+
|
|
153
753
|
# One-off OSV.dev lookup for a single gem — no Gemfile.lock, no scan,
|
|
154
754
|
# no other network calls. `spec` is "name" or "name:version" (colon
|
|
155
755
|
# rather than a second CLI arg, so this stays a single -o-style flag).
|
|
@@ -169,6 +769,62 @@ module Scryer
|
|
|
169
769
|
findings.empty? ? 0 : 1
|
|
170
770
|
end
|
|
171
771
|
|
|
772
|
+
# `--save-baseline PATH` is a distinct mode, same as --audit-deps/
|
|
773
|
+
# --check-gem: it captures every finding across every category (not
|
|
774
|
+
# just security — a legacy app's existing performance/style debt is
|
|
775
|
+
# just as much "not what I'm here to re-litigate today" as its security
|
|
776
|
+
# debt), writes the fingerprints, and exits without writing the normal
|
|
777
|
+
# -o reports. See Scryer::Baseline for why fingerprints, not file:line.
|
|
778
|
+
def save_baseline(path, result, dependency_findings)
|
|
779
|
+
all_findings = (result.security_findings + result.performance_findings + result.style_findings)
|
|
780
|
+
.map(&:to_h) + dependency_findings.map(&:to_h)
|
|
781
|
+
Baseline.save(path, all_findings)
|
|
782
|
+
@stdout.puts "Scryer: saved baseline of #{all_findings.size} finding(s) to #{path}."
|
|
783
|
+
0
|
|
784
|
+
end
|
|
785
|
+
|
|
786
|
+
# Filters `result`'s finding arrays (mutated in place — Result is a
|
|
787
|
+
# plain Struct, this is the same object the caller already holds) and
|
|
788
|
+
# returns [new_dependency_findings, fixed_count] since dependency_findings
|
|
789
|
+
# is a local array in the caller, not a field this method can mutate by
|
|
790
|
+
# reference the way it can Struct fields.
|
|
791
|
+
# `fixed_count` has to be computed ONCE against the union of every
|
|
792
|
+
# category's current fingerprints, not once per category summed
|
|
793
|
+
# together — Baseline.diff's fixed_count is "baseline fingerprints not
|
|
794
|
+
# present in *this* call's findings," so calling it separately per
|
|
795
|
+
# category and summing would count every other category's
|
|
796
|
+
# still-present findings as "fixed" too (verified: this exact bug
|
|
797
|
+
# produced a nonsensical "762 fixed" on a rescan with zero changes,
|
|
798
|
+
# against a 255-finding baseline — fixed by computing fixed_count from
|
|
799
|
+
# the combined set once, while still filtering "new" per category since
|
|
800
|
+
# that part only checks baseline membership, which is fine to do
|
|
801
|
+
# separately).
|
|
802
|
+
def apply_baseline(path, result, dependency_findings)
|
|
803
|
+
baseline_fingerprints = Baseline.load(path)
|
|
804
|
+
|
|
805
|
+
security_hashes = result.security_findings.map(&:to_h)
|
|
806
|
+
performance_hashes = result.performance_findings.map(&:to_h)
|
|
807
|
+
style_hashes = result.style_findings.map(&:to_h)
|
|
808
|
+
dependency_hashes = dependency_findings.map(&:to_h)
|
|
809
|
+
|
|
810
|
+
all_current_fingerprints = Baseline.fingerprints(security_hashes + performance_hashes + style_hashes + dependency_hashes)
|
|
811
|
+
fixed_count = (baseline_fingerprints - all_current_fingerprints.to_set).size
|
|
812
|
+
|
|
813
|
+
result.security_findings = filter_new(result.security_findings, security_hashes, baseline_fingerprints)
|
|
814
|
+
result.performance_findings = filter_new(result.performance_findings, performance_hashes, baseline_fingerprints)
|
|
815
|
+
result.style_findings = filter_new(result.style_findings, style_hashes, baseline_fingerprints)
|
|
816
|
+
filtered_deps = filter_new(dependency_findings, dependency_hashes, baseline_fingerprints)
|
|
817
|
+
|
|
818
|
+
[filtered_deps, fixed_count]
|
|
819
|
+
rescue Baseline::LoadError => e
|
|
820
|
+
raise UsageError, e.message
|
|
821
|
+
end
|
|
822
|
+
|
|
823
|
+
def filter_new(objects, hashes, baseline_fingerprints)
|
|
824
|
+
fingerprints = Baseline.fingerprints(hashes)
|
|
825
|
+
objects.each_with_index.reject { |_, i| baseline_fingerprints.include?(fingerprints[i]) }.map(&:first)
|
|
826
|
+
end
|
|
827
|
+
|
|
172
828
|
def parse(argv)
|
|
173
829
|
options = { outputs: [] }
|
|
174
830
|
|
|
@@ -182,13 +838,24 @@ module Scryer
|
|
|
182
838
|
"Require a Ruby file before scanning (repeatable) — the file can call " \
|
|
183
839
|
"Scryer.configure to set c.ai_client, c.skip_rules, c.dirs, etc. This is the " \
|
|
184
840
|
"standalone executable's equivalent of a Rails app's config/initializers/scryer.rb " \
|
|
185
|
-
"getting autoloaded at boot
|
|
186
|
-
"
|
|
841
|
+
"getting autoloaded at boot. If omitted, config/initializers/scryer.rb under " \
|
|
842
|
+
"--path is auto-required when it exists.") { |v| (options[:require] ||= []) << v }
|
|
187
843
|
opts.on("--audit-deps",
|
|
188
844
|
"Check Gemfile.lock for known-vulnerable gems (via OSV.dev — needs network) and " \
|
|
189
845
|
"insecure git/http sources (offline), instead of running the normal static scan. " \
|
|
190
846
|
"Exits non-zero if anything is found, so this can gate CI the same way " \
|
|
191
847
|
"`bundle-audit check` does.") { options[:audit_deps] = true }
|
|
848
|
+
opts.on("--save-baseline PATH",
|
|
849
|
+
"Run the normal scan, save every finding's fingerprint to PATH, then exit — no " \
|
|
850
|
+
"reports written. A later `scryer --baseline PATH` scan reports only findings " \
|
|
851
|
+
"new since this snapshot, so an app with existing security debt can gate CI on " \
|
|
852
|
+
"new issues without being forced to fix everything on day one.") { |v| options[:save_baseline] = v }
|
|
853
|
+
opts.on("--baseline PATH",
|
|
854
|
+
"Compare this scan against a baseline saved by --save-baseline: every report " \
|
|
855
|
+
"(-o files, console summary, exit code) reflects only findings new since PATH " \
|
|
856
|
+
"was saved. Fingerprints ignore line number (rule + file + offending code), so " \
|
|
857
|
+
"an unrelated edit elsewhere in the file won't make an existing finding look " \
|
|
858
|
+
"new.") { |v| options[:baseline] = v }
|
|
192
859
|
opts.on("--no-deps",
|
|
193
860
|
"Skip the dependency audit (OSV.dev vulnerable gems + insecure git/http sources) " \
|
|
194
861
|
"that otherwise runs as part of every normal scan. Use this for a fast, fully " \
|
|
@@ -198,6 +865,10 @@ module Scryer
|
|
|
198
865
|
"Skip a rule by rule_id (repeatable) — e.g. a known false positive on this " \
|
|
199
866
|
"codebase. Adds to c.skip_rules for this run only; doesn't affect other " \
|
|
200
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 }
|
|
201
872
|
opts.on("--check-gem NAME[:VERSION]",
|
|
202
873
|
"Query OSV.dev for known vulnerabilities affecting a single gem, independent of " \
|
|
203
874
|
"any Gemfile.lock or full scan/audit — instead of running the normal static " \
|
|
@@ -205,6 +876,8 @@ module Scryer
|
|
|
205
876
|
"all versions.") { |v| options[:check_gem] = v }
|
|
206
877
|
opts.on("--project-name NAME", "Project name shown in the report header.") { |v| options[:project_name] = v }
|
|
207
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 }
|
|
208
881
|
opts.on("-v", "--version", "Show the Scryer version.") { options[:exit_early] = true; @stdout.puts Scryer::VERSION }
|
|
209
882
|
opts.on("-h", "--help", "Show this help.") { options[:exit_early] = true; @stdout.puts opts }
|
|
210
883
|
end
|
|
@@ -215,6 +888,7 @@ module Scryer
|
|
|
215
888
|
raise UsageError, "#{e.message}\n#{parser}"
|
|
216
889
|
end
|
|
217
890
|
|
|
891
|
+
@color_override = options[:color]
|
|
218
892
|
options
|
|
219
893
|
end
|
|
220
894
|
|