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/tasks/scryer.rake
CHANGED
|
@@ -11,12 +11,14 @@ namespace :scryer do
|
|
|
11
11
|
desc "Run Scryer and write a report. Args are any mix of json/html/csv/sarif (which formats " \
|
|
12
12
|
"to write — default json,html), the token 'nodeps' (skip the dependency audit — OSV.dev " \
|
|
13
13
|
"vulnerable gems + insecure git/http sources — that otherwise runs by default; use for a " \
|
|
14
|
-
"fast, fully offline run),
|
|
15
|
-
"
|
|
16
|
-
"
|
|
17
|
-
"
|
|
14
|
+
"fast, fully offline run), the token 'noduplicates' (skip duplicate-code detection for " \
|
|
15
|
+
"this run only, regardless of c.detect_duplicates), plus at most one path (a directory " \
|
|
16
|
+
"when writing more than one format, or an exact file for a single format). Rake splits " \
|
|
17
|
+
"bracket args on every comma, so pass each token as its own item rather than one " \
|
|
18
|
+
"comma-joined string. e.g. rails scryer:report, rails 'scryer:report[html]', " \
|
|
18
19
|
"rails 'scryer:report[json,doc/security.json]', rails 'scryer:report[json,html]', " \
|
|
19
|
-
"rails 'scryer:report[html,nodeps]', rails 'scryer:report[
|
|
20
|
+
"rails 'scryer:report[html,nodeps]', rails 'scryer:report[html,noduplicates]', " \
|
|
21
|
+
"rails 'scryer:report[csv]', rails 'scryer:report[sarif]'. " \
|
|
20
22
|
"Set SCRYER_BASELINE=PATH (an env var, not a bracket arg — see scryer:save_baseline) to " \
|
|
21
23
|
"report only findings new since that baseline was saved."
|
|
22
24
|
task :report, [:format] do |_, args|
|
|
@@ -25,13 +27,14 @@ namespace :scryer do
|
|
|
25
27
|
|
|
26
28
|
# Ignore the declared :format name and read every positional value Rake
|
|
27
29
|
# was given (args.to_a) — the whole point is accepting a variable number
|
|
28
|
-
# of format/nodeps tokens plus one path, which a single
|
|
29
|
-
|
|
30
|
+
# of format/nodeps/noduplicates tokens plus one path, which a single
|
|
31
|
+
# named param can't do.
|
|
32
|
+
formats, path_arg, run_deps, detect_duplicates = parse_report_args(args.to_a)
|
|
30
33
|
|
|
31
34
|
skip_rules = Scryer.configuration.skip_rules
|
|
32
35
|
puts "Scryer: skipping #{skip_rules.join(', ')}." if skip_rules.any?
|
|
33
36
|
|
|
34
|
-
result = Scryer::Scanner.new(root: root, dirs: dirs, skip_rules: skip_rules).call
|
|
37
|
+
result = Scryer::Scanner.new(root: root, dirs: dirs, skip_rules: skip_rules, detect_duplicates: detect_duplicates).call
|
|
35
38
|
|
|
36
39
|
# Dependency auditing (OSV.dev) runs by default — a single scryer:report
|
|
37
40
|
# run is meant to cover the same ground as RuboCop + Brakeman +
|
|
@@ -120,7 +123,10 @@ namespace :scryer do
|
|
|
120
123
|
root = defined?(Rails) ? Rails.root.to_s : Dir.pwd
|
|
121
124
|
dirs = Scryer.configuration.dirs
|
|
122
125
|
|
|
123
|
-
|
|
126
|
+
# detect_duplicates: false — a baseline never includes duplicate_groups
|
|
127
|
+
# (see all_findings below and Scryer::CLI#apply_baseline's own comment
|
|
128
|
+
# on why), so there's nothing to gain from that pass here.
|
|
129
|
+
result = Scryer::Scanner.new(root: root, dirs: dirs, skip_rules: Scryer.configuration.skip_rules, detect_duplicates: false).call
|
|
124
130
|
puts "Scryer: querying OSV.dev for known-vulnerable gems (needs network)..."
|
|
125
131
|
dependency_findings = Scryer::DependencyAudit.insecure_sources(root) + Scryer::DependencyAudit.vulnerable_gems(root) +
|
|
126
132
|
Scryer::DependencyAudit.ruby_eol_check(root) + Scryer::DependencyAudit.credentials_exposure_check(root)
|
|
@@ -131,6 +137,124 @@ namespace :scryer do
|
|
|
131
137
|
puts "Scryer: saved baseline of #{all_findings.size} finding(s) to #{path}."
|
|
132
138
|
end
|
|
133
139
|
|
|
140
|
+
desc "Scan, then ask the configured c.ai_client for a rewrite of every qualifying finding and " \
|
|
141
|
+
"write the ones independently verified to actually clear it (see Scryer::FixVerifier) — " \
|
|
142
|
+
"requires c.ai_client to be set (config/initializers/scryer.rb); anything not verified is " \
|
|
143
|
+
"left alone and reported for manual review, same as a normal report would show it. Takes " \
|
|
144
|
+
"an optional rule_id bracket arg to only fix that rule; set SCRYER_FIX_DRY_RUN=1 to preview " \
|
|
145
|
+
"without writing anything, SCRYER_FIX_LIST=1 to print the numbered candidates and exit, " \
|
|
146
|
+
"SCRYER_FIX_NUMBERS=1,3 to only fix those numbered positions (see SCRYER_FIX_LIST), or " \
|
|
147
|
+
"SCRYER_FIX_YES=1 to skip the per-finding accept/skip prompt this task otherwise shows in " \
|
|
148
|
+
"an interactive terminal, or SCRYER_FIX_DEPS=1 to fix vulnerable dependencies instead " \
|
|
149
|
+
"(runs `bundle update GEM --conservative` per vulnerable gem with a published patched " \
|
|
150
|
+
"version, then re-checks OSV.dev — see Scryer::DependencyFixer; ignores the rule_id arg " \
|
|
151
|
+
"and SCRYER_FIX_LIST/SCRYER_FIX_NUMBERS, which only make sense for rule-based findings). " \
|
|
152
|
+
"e.g. rails scryer:fix, rails 'scryer:fix[sql_injection]', " \
|
|
153
|
+
"SCRYER_FIX_DRY_RUN=1 rails scryer:fix, SCRYER_FIX_DEPS=1 rails scryer:fix"
|
|
154
|
+
task :fix, [:rule_id] do |_, args|
|
|
155
|
+
root = defined?(Rails) ? Rails.root.to_s : Dir.pwd
|
|
156
|
+
dry_run = !blank_to_nil(ENV["SCRYER_FIX_DRY_RUN"]).nil?
|
|
157
|
+
|
|
158
|
+
if !blank_to_nil(ENV["SCRYER_FIX_DEPS"]).nil?
|
|
159
|
+
puts "Scryer: querying OSV.dev for known-vulnerable gems (needs network)..."
|
|
160
|
+
findings = Scryer::DependencyAudit.vulnerable_gems(root)
|
|
161
|
+
|
|
162
|
+
if findings.empty?
|
|
163
|
+
puts "Scryer: no vulnerable gems found."
|
|
164
|
+
next
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
actionable = findings.select { |f| Array(f.patched_versions).any? }
|
|
168
|
+
if actionable.empty?
|
|
169
|
+
puts "Scryer: #{findings.size} vulnerable gem finding(s), but none have a published " \
|
|
170
|
+
"patched version yet to upgrade to — nothing this task can run. See " \
|
|
171
|
+
"`rails scryer:audit_dependencies` for details."
|
|
172
|
+
next
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
gem_count = actionable.map(&:gem_name).uniq.size
|
|
176
|
+
puts "Scryer: #{actionable.size} finding(s) across #{gem_count} gem(s) — running " \
|
|
177
|
+
"`bundle update GEM --conservative` for each" \
|
|
178
|
+
"#{dry_run ? " (SCRYER_FIX_DRY_RUN: nothing will actually run)" : ""}..."
|
|
179
|
+
|
|
180
|
+
fixed, skipped = Scryer::DependencyFixer.apply(findings, root: root, dry_run: dry_run) do |finding, status, error|
|
|
181
|
+
ScryerTasks.print_deps_fix_progress(finding, status, error)
|
|
182
|
+
end
|
|
183
|
+
ScryerTasks.print_deps_fix_summary(fixed: fixed, skipped: skipped, dry_run: dry_run)
|
|
184
|
+
|
|
185
|
+
abort("Scryer: #{skipped.size} finding(s) not applied.") if !dry_run && skipped.any?
|
|
186
|
+
next
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
ai_client = Scryer.configuration.ai_client
|
|
190
|
+
dirs = Scryer.configuration.dirs
|
|
191
|
+
skip_rules = Scryer.configuration.skip_rules
|
|
192
|
+
list_only = !blank_to_nil(ENV["SCRYER_FIX_LIST"]).nil?
|
|
193
|
+
yes = !blank_to_nil(ENV["SCRYER_FIX_YES"]).nil?
|
|
194
|
+
numbers = blank_to_nil(ENV["SCRYER_FIX_NUMBERS"])&.split(",")&.map(&:strip)
|
|
195
|
+
|
|
196
|
+
# detect_duplicates: false — candidates below are only ever
|
|
197
|
+
# security/performance/style findings; a duplicate-code group has no
|
|
198
|
+
# rule_id/single-line suggested_fix for FixRunner to act on.
|
|
199
|
+
result = Scryer::Scanner.new(root: root, dirs: dirs, skip_rules: skip_rules, detect_duplicates: false).call
|
|
200
|
+
candidates = result.security_findings + result.performance_findings + result.style_findings
|
|
201
|
+
candidates = candidates.select { |f| f.rule_id == args[:rule_id] } if args[:rule_id]
|
|
202
|
+
candidates = candidates.sort_by { |f| [f.file.to_s, f.line || 0] }
|
|
203
|
+
|
|
204
|
+
if candidates.empty?
|
|
205
|
+
puts "Scryer: no matching findings to fix."
|
|
206
|
+
next
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
if list_only
|
|
210
|
+
ScryerTasks.print_candidate_list(candidates)
|
|
211
|
+
next
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
candidates = ScryerTasks.gate_frozen_string_literal(candidates, explicit: !args[:rule_id].nil?)
|
|
215
|
+
if candidates.empty?
|
|
216
|
+
puts "Scryer: no matching findings to fix."
|
|
217
|
+
next
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
candidates = ScryerTasks.select_by_number(candidates, numbers) if numbers
|
|
221
|
+
|
|
222
|
+
# See Scryer::MechanicalFixer — a handful of rules have exactly one
|
|
223
|
+
# correct fix and don't need c.ai_client at all. Only abort when
|
|
224
|
+
# there's genuinely nothing this run could fix: no ai_client AND
|
|
225
|
+
# nothing matched has a built-in fixer.
|
|
226
|
+
if ai_client.nil? && candidates.none? { |f| Scryer::MechanicalFixer.supported?(f.rule_id) }
|
|
227
|
+
abort("Scryer: scryer:fix needs c.ai_client configured in config/initializers/scryer.rb — " \
|
|
228
|
+
"none of the matched finding(s) have a built-in mechanical fixer " \
|
|
229
|
+
"(#{Scryer::MechanicalFixer::SUPPORTED_RULES.join(", ")}), and a rule's generic " \
|
|
230
|
+
"suggested_fix is prose, not something this task can apply automatically on its own. " \
|
|
231
|
+
"Nothing has been changed.")
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# See ScryerTasks#build_fix_confirmer / Scryer::CLI#build_fix_confirmer:
|
|
235
|
+
# in an interactive terminal (and not dry-run/SCRYER_FIX_YES), review
|
|
236
|
+
# each verified fix one at a time before writing it.
|
|
237
|
+
confirm = ScryerTasks.build_fix_confirmer if $stdin.respond_to?(:tty?) && $stdin.tty? && !dry_run && !yes
|
|
238
|
+
|
|
239
|
+
puts "Scryer: #{candidates.size} candidate finding(s) — applying built-in fixes where one " \
|
|
240
|
+
"exists, otherwise asking the configured AI client for a rewrite; either way, only " \
|
|
241
|
+
"writing what's independently verified to clear the finding" \
|
|
242
|
+
"#{dry_run ? " (SCRYER_FIX_DRY_RUN: nothing will actually be written)" : ""}..."
|
|
243
|
+
|
|
244
|
+
fixed, skipped = Scryer::FixRunner.apply(candidates, client: ai_client, root: root, dry_run: dry_run, confirm: confirm) do |finding, status, error|
|
|
245
|
+
ScryerTasks.print_fix_progress(finding, status, error)
|
|
246
|
+
end
|
|
247
|
+
ScryerTasks.print_fix_summary(fixed: fixed, skipped: skipped, dry_run: dry_run)
|
|
248
|
+
|
|
249
|
+
if !dry_run && fixed.any?
|
|
250
|
+
puts "Scryer: re-scanning to verify every applied fix..."
|
|
251
|
+
regressed = Scryer::FixRunner.verify(fixed, root: root, dirs: dirs, skip_rules: skip_rules)
|
|
252
|
+
ScryerTasks.print_fix_verification(regressed, total: fixed.size)
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
abort("Scryer: #{skipped.size} finding(s) still need manual review.") if !dry_run && skipped.any?
|
|
256
|
+
end
|
|
257
|
+
|
|
134
258
|
desc "Check Gemfile.lock for known-vulnerable gem versions (via OSV.dev — needs network) " \
|
|
135
259
|
"and insecure git/http gem sources (offline). Exits non-zero if anything is found, so " \
|
|
136
260
|
"this can gate CI the same way `bundle-audit check` does."
|
|
@@ -146,7 +270,7 @@ namespace :scryer do
|
|
|
146
270
|
credentials_exposure = Scryer::DependencyAudit.credentials_exposure_check(root)
|
|
147
271
|
|
|
148
272
|
(insecure + vulnerable + ruby_eol + credentials_exposure).each do |f|
|
|
149
|
-
puts "[#{f.severity.upcase}] #{ScryerTasks.dependency_label(f)}"
|
|
273
|
+
puts "[#{ScryerTasks.paint_severity(f.severity.upcase, f.severity)}] #{ScryerTasks.dependency_label(f)}"
|
|
150
274
|
puts " fix: #{f.suggested_fix}"
|
|
151
275
|
end
|
|
152
276
|
|
|
@@ -160,16 +284,22 @@ namespace :scryer do
|
|
|
160
284
|
VALID_FORMATS = %w[json html csv sarif].freeze
|
|
161
285
|
EXTENSION_FOR_FORMAT = { "json" => "json", "html" => "html", "csv" => "csv", "sarif" => "sarif" }.freeze
|
|
162
286
|
NO_DEPS_TOKEN = "nodeps".freeze
|
|
287
|
+
NO_DUPLICATES_TOKEN = "noduplicates".freeze
|
|
163
288
|
|
|
164
289
|
# tokens is every bracket arg Rake was given, e.g. %w[json doc/security.json] or
|
|
165
290
|
# %w[json html] or %w[html nodeps] or []. Returns [formats, path_arg,
|
|
166
|
-
# run_deps] — any token matching a known format is a
|
|
167
|
-
# "nodeps" token opts *out* of the dependency audit
|
|
168
|
-
#
|
|
291
|
+
# run_deps, detect_duplicates] — any token matching a known format is a
|
|
292
|
+
# format, the literal "nodeps" token opts *out* of the dependency audit
|
|
293
|
+
# that otherwise runs by default, the literal "noduplicates" token opts
|
|
294
|
+
# out of duplicate-code detection for this run only (c.detect_duplicates
|
|
295
|
+
# already off is still off either way — this token can only turn it off,
|
|
296
|
+
# never back on), and at most one other token is allowed, which is the
|
|
297
|
+
# path.
|
|
169
298
|
def parse_report_args(tokens)
|
|
170
299
|
tokens = tokens.map { |t| blank_to_nil(t) }.compact
|
|
171
300
|
run_deps = tokens.none? { |t| t.downcase == NO_DEPS_TOKEN }
|
|
172
|
-
|
|
301
|
+
detect_duplicates = Scryer.configuration.detect_duplicates && tokens.none? { |t| t.downcase == NO_DUPLICATES_TOKEN }
|
|
302
|
+
tokens = tokens.reject { |t| [NO_DEPS_TOKEN, NO_DUPLICATES_TOKEN].include?(t.downcase) }
|
|
173
303
|
|
|
174
304
|
format_tokens, other_tokens = tokens.partition { |t| VALID_FORMATS.include?(t.downcase) }
|
|
175
305
|
|
|
@@ -180,7 +310,7 @@ namespace :scryer do
|
|
|
180
310
|
formats = format_tokens.map(&:downcase).uniq
|
|
181
311
|
formats = %w[json html] if formats.empty?
|
|
182
312
|
|
|
183
|
-
[formats, other_tokens.first, run_deps]
|
|
313
|
+
[formats, other_tokens.first, run_deps, detect_duplicates]
|
|
184
314
|
end
|
|
185
315
|
|
|
186
316
|
# No path given: default filenames under tmp/. A path given with a single
|
|
@@ -212,6 +342,29 @@ end
|
|
|
212
342
|
module ScryerTasks
|
|
213
343
|
module_function
|
|
214
344
|
|
|
345
|
+
# SCRYER_COLOR/SCRYER_NO_COLOR are this task's equivalent of the CLI's
|
|
346
|
+
# --color/--no-color — an explicit env var wins over Scryer::Colorizer's
|
|
347
|
+
# own NO_COLOR/TTY auto-detection either way, same precedence the CLI
|
|
348
|
+
# flags use (see Scryer::Colorizer's own comment for why).
|
|
349
|
+
def color_override
|
|
350
|
+
return true unless blank_to_nil(ENV["SCRYER_COLOR"]).nil?
|
|
351
|
+
return false unless blank_to_nil(ENV["SCRYER_NO_COLOR"]).nil?
|
|
352
|
+
|
|
353
|
+
nil
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
def paint(text, *styles)
|
|
357
|
+
Scryer::Colorizer.paint(text, *styles, stream: $stdout, override: color_override)
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
def paint_severity(text, severity)
|
|
361
|
+
Scryer::Colorizer.severity(text, severity, stream: $stdout, override: color_override)
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
def paint_grade(text, letter)
|
|
365
|
+
Scryer::Colorizer.grade(text, letter, stream: $stdout, override: color_override)
|
|
366
|
+
end
|
|
367
|
+
|
|
215
368
|
def git_commit_sha
|
|
216
369
|
shell_out("git rev-parse HEAD")
|
|
217
370
|
end
|
|
@@ -286,10 +439,10 @@ module ScryerTasks
|
|
|
286
439
|
["Dependencies", deps_count]
|
|
287
440
|
]
|
|
288
441
|
|
|
289
|
-
divider = "─" * 32
|
|
442
|
+
divider = paint("─" * 32, :gray)
|
|
290
443
|
score = renderer.security_score
|
|
291
444
|
puts ""
|
|
292
|
-
puts "Scryer Audit — #{result.files_scanned} files scanned"
|
|
445
|
+
puts paint("Scryer Audit — #{result.files_scanned} files scanned", :bold)
|
|
293
446
|
puts divider
|
|
294
447
|
puts ""
|
|
295
448
|
if baseline_path
|
|
@@ -297,12 +450,12 @@ module ScryerTasks
|
|
|
297
450
|
puts ""
|
|
298
451
|
end
|
|
299
452
|
clean_rate = renderer.rules_clean_rate
|
|
300
|
-
puts "Security Score: #{score["score"]}/100 (#{score["grade"]})"
|
|
453
|
+
puts "Security Score: #{score["score"]}/100 (#{paint_grade(score["grade"], score["grade"])})"
|
|
301
454
|
puts "Checks: #{clean_rate["clean"]}/#{clean_rate["total"]} rules clean (#{clean_rate["percent"]}%)"
|
|
302
455
|
puts ""
|
|
303
456
|
rows.each { |label, count| puts summary_row(label, count) }
|
|
304
457
|
puts divider
|
|
305
|
-
puts summary_row("Total", total)
|
|
458
|
+
puts paint(summary_row("Total", total), :bold)
|
|
306
459
|
puts ""
|
|
307
460
|
print_top_priorities(renderer.top_risks)
|
|
308
461
|
print_owasp_coverage(renderer.owasp_coverage)
|
|
@@ -321,9 +474,9 @@ module ScryerTasks
|
|
|
321
474
|
def print_top_priorities(risks)
|
|
322
475
|
return if risks.empty?
|
|
323
476
|
|
|
324
|
-
puts "Top priorities:"
|
|
477
|
+
puts paint("Top priorities:", :bold)
|
|
325
478
|
risks.each_with_index do |r, i|
|
|
326
|
-
puts " #{i + 1}. [#{r[:severity]}] #{r[:category]} — #{r[:label]} (#{r[:location]})"
|
|
479
|
+
puts " #{i + 1}. [#{paint_severity(r[:severity], r[:severity])}] #{r[:category]} — #{r[:label]} (#{r[:location]})"
|
|
327
480
|
end
|
|
328
481
|
puts ""
|
|
329
482
|
end
|
|
@@ -338,4 +491,165 @@ module ScryerTasks
|
|
|
338
491
|
coverage.each { |category, count| puts " #{category}: #{count} finding#{"s" unless count == 1}" }
|
|
339
492
|
puts ""
|
|
340
493
|
end
|
|
494
|
+
|
|
495
|
+
# Same real-time progress line as Scryer::CLI#print_fix_progress.
|
|
496
|
+
def print_fix_progress(finding, status, error = nil)
|
|
497
|
+
label = { fixed: "Fixed", would_fix: "Would fix", declined: "Skipped (declined)",
|
|
498
|
+
ai_error: "Skipped (AI client error)", cancelled: "Skipped (cancelled)",
|
|
499
|
+
skipped: "Skipped (needs manual review)" }.fetch(status)
|
|
500
|
+
colored_label = %i[fixed would_fix].include?(status) ? paint(label, :green, :bold) : paint(label, :yellow)
|
|
501
|
+
puts "#{colored_label}: #{finding.rule_id} — #{finding.file}:#{finding.line}"
|
|
502
|
+
|
|
503
|
+
if status == :ai_error && error
|
|
504
|
+
puts " #{paint("#{error.class}: #{error.message}", :red)}"
|
|
505
|
+
else
|
|
506
|
+
explanation = Scryer::FixRunner.explain(finding.suggested_fix)
|
|
507
|
+
puts " #{explanation}" unless explanation.empty?
|
|
508
|
+
end
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
def print_fix_summary(fixed:, skipped:, dry_run:)
|
|
512
|
+
verb = dry_run ? "Would fix" : "Fixed"
|
|
513
|
+
puts ""
|
|
514
|
+
puts paint("#{verb} #{fixed.size} finding(s):", :green, :bold)
|
|
515
|
+
fixed.each { |f| puts " #{f.rule_id} — #{f.file}:#{f.line}" }
|
|
516
|
+
puts ""
|
|
517
|
+
|
|
518
|
+
return if skipped.empty?
|
|
519
|
+
|
|
520
|
+
puts paint("#{skipped.size} finding(s) not applied (declined, or fix not independently verified):", :yellow)
|
|
521
|
+
skipped.each { |f| puts " #{f.rule_id} — #{f.file}:#{f.line}" }
|
|
522
|
+
puts ""
|
|
523
|
+
end
|
|
524
|
+
|
|
525
|
+
# Same real-time progress line as Scryer::CLI#print_deps_fix_progress —
|
|
526
|
+
# SCRYER_FIX_DEPS's dependency-finding analog of print_fix_progress above.
|
|
527
|
+
def print_deps_fix_progress(finding, status, error = nil)
|
|
528
|
+
label = { fixed: "Fixed", would_fix: "Would fix", error: "Error running bundle update",
|
|
529
|
+
skipped: "Skipped (needs manual review)" }.fetch(status)
|
|
530
|
+
colored_label = %i[fixed would_fix].include?(status) ? paint(label, :green, :bold) : paint(label, :yellow)
|
|
531
|
+
puts "#{colored_label}: #{finding.gem_name}#{finding.advisory_id ? " (#{finding.advisory_id})" : ""}"
|
|
532
|
+
|
|
533
|
+
if error
|
|
534
|
+
puts " #{paint(error.to_s.strip, :red)}"
|
|
535
|
+
elsif status == :skipped && finding.suggested_fix
|
|
536
|
+
puts " #{finding.suggested_fix}"
|
|
537
|
+
end
|
|
538
|
+
end
|
|
539
|
+
|
|
540
|
+
def print_deps_fix_summary(fixed:, skipped:, dry_run:)
|
|
541
|
+
verb = dry_run ? "Would fix" : "Fixed"
|
|
542
|
+
puts ""
|
|
543
|
+
puts paint("#{verb} #{fixed.size} finding(s):", :green, :bold)
|
|
544
|
+
fixed.each { |f| puts " #{f.gem_name}#{f.advisory_id ? " (#{f.advisory_id})" : ""}" }
|
|
545
|
+
puts ""
|
|
546
|
+
|
|
547
|
+
return if skipped.empty?
|
|
548
|
+
|
|
549
|
+
puts paint("#{skipped.size} finding(s) not applied (no patched version yet, bundle update " \
|
|
550
|
+
"failed, or the re-check still shows it vulnerable):", :yellow)
|
|
551
|
+
skipped.each { |f| puts " #{f.gem_name}#{f.advisory_id ? " (#{f.advisory_id})" : ""}" }
|
|
552
|
+
puts ""
|
|
553
|
+
end
|
|
554
|
+
|
|
555
|
+
# Same real-time per-finding review as Scryer::CLI#build_fix_confirmer —
|
|
556
|
+
# SCRYER_FIX_YES is this task's equivalent of the CLI's --yes. Numeric
|
|
557
|
+
# choices only; "4" (cancel) returns :cancel, which FixRunner.apply treats
|
|
558
|
+
# as "stop entirely" — see Scryer::FixRunner.apply's own doc comment.
|
|
559
|
+
def build_fix_confirmer
|
|
560
|
+
mode = :ask
|
|
561
|
+
lambda do |finding|
|
|
562
|
+
next true if mode == :all
|
|
563
|
+
|
|
564
|
+
print_finding_preview(finding)
|
|
565
|
+
loop do
|
|
566
|
+
puts "Apply this fix?"
|
|
567
|
+
puts " 1) Yes"
|
|
568
|
+
puts " 2) Skip"
|
|
569
|
+
puts " 3) Yes to all remaining"
|
|
570
|
+
puts " 4) Cancel (stop reviewing — nothing further will be attempted)"
|
|
571
|
+
print "Choice: "
|
|
572
|
+
$stdout.flush
|
|
573
|
+
case $stdin.gets.to_s.strip
|
|
574
|
+
when "1" then break true
|
|
575
|
+
when "2" then break false
|
|
576
|
+
when "3" then mode = :all; break true
|
|
577
|
+
when "4" then break :cancel
|
|
578
|
+
else puts "Please enter 1, 2, 3, or 4."
|
|
579
|
+
end
|
|
580
|
+
end
|
|
581
|
+
end
|
|
582
|
+
end
|
|
583
|
+
|
|
584
|
+
def print_finding_preview(finding)
|
|
585
|
+
puts ""
|
|
586
|
+
puts "#{finding.rule_id} — #{finding.file}:#{finding.line}"
|
|
587
|
+
puts " #{finding.message}" if finding.message
|
|
588
|
+
explanation = Scryer::FixRunner.explain(finding.suggested_fix)
|
|
589
|
+
puts " Fix: #{explanation}" unless explanation.empty?
|
|
590
|
+
after = Scryer::FixVerifier.extract_after_snippet(finding.suggested_fix)
|
|
591
|
+
return unless after
|
|
592
|
+
|
|
593
|
+
puts " AFTER:"
|
|
594
|
+
after.each_line { |line| puts " #{line.chomp}" }
|
|
595
|
+
end
|
|
596
|
+
|
|
597
|
+
def print_fix_verification(regressed, total:)
|
|
598
|
+
if regressed.empty?
|
|
599
|
+
puts paint("Verified: all #{total} applied fix(es) confirmed clean on a full re-scan.", :green, :bold)
|
|
600
|
+
else
|
|
601
|
+
puts paint("Warning: #{regressed.size} of #{total} applied fix(es) still show up on a full " \
|
|
602
|
+
"re-scan (an edit may have shifted another finding onto the same rule, or a " \
|
|
603
|
+
"duplicate finding existed elsewhere) — review these by hand:", :yellow)
|
|
604
|
+
regressed.each { |f| puts " #{f.rule_id} — #{f.file}:#{f.line}" }
|
|
605
|
+
end
|
|
606
|
+
end
|
|
607
|
+
|
|
608
|
+
def print_candidate_list(candidates)
|
|
609
|
+
puts ""
|
|
610
|
+
candidates.each_with_index { |f, i| puts " #{i + 1}) #{f.rule_id} — #{f.file}:#{f.line} — #{f.message}" }
|
|
611
|
+
puts ""
|
|
612
|
+
end
|
|
613
|
+
|
|
614
|
+
# Same mapping as Scryer::CLI#select_by_number (see that method's comment)
|
|
615
|
+
# — SCRYER_FIX_NUMBERS is this task's equivalent of the CLI's --number.
|
|
616
|
+
# Aborts (this file's usual bad-input response, matching e.g. apply_baseline
|
|
617
|
+
# above) rather than fixing anything other than what was asked for.
|
|
618
|
+
def select_by_number(candidates, numbers)
|
|
619
|
+
indices = numbers.map do |n|
|
|
620
|
+
i = Integer(n, exception: false)
|
|
621
|
+
abort("Scryer: SCRYER_FIX_NUMBERS value #{n.inspect} isn't a valid position — set " \
|
|
622
|
+
"SCRYER_FIX_LIST=1 to see the numbered candidates first.") unless i
|
|
623
|
+
abort("Scryer: SCRYER_FIX_NUMBERS position #{i} is out of range — only #{candidates.size} " \
|
|
624
|
+
"matching finding(s) (set SCRYER_FIX_LIST=1 to list them).") unless i.between?(1, candidates.size)
|
|
625
|
+
|
|
626
|
+
i
|
|
627
|
+
end
|
|
628
|
+
indices.uniq.sort.map { |i| candidates[i - 1] }
|
|
629
|
+
end
|
|
630
|
+
|
|
631
|
+
# Same gate as Scryer::CLI#gate_frozen_string_literal — see that method's
|
|
632
|
+
# comment. `explicit` is true when the rake task's rule_id bracket arg
|
|
633
|
+
# was given (e.g. `scryer:fix[frozen_string_literal]`), which is already
|
|
634
|
+
# informed consent for that rule.
|
|
635
|
+
def gate_frozen_string_literal(candidates, explicit:)
|
|
636
|
+
return candidates if explicit
|
|
637
|
+
|
|
638
|
+
opted_in, rest = candidates.partition { |f| Scryer::MechanicalFixer.opt_in?(f.rule_id) }
|
|
639
|
+
return candidates if opted_in.empty?
|
|
640
|
+
|
|
641
|
+
if $stdin.respond_to?(:tty?) && $stdin.tty?
|
|
642
|
+
print "#{opted_in.size} frozen_string_literal finding(s) matched — this only adds the " \
|
|
643
|
+
"magic comment to #{opted_in.size} file(s) (skipped automatically wherever an " \
|
|
644
|
+
"in-place string mutation makes it unsafe). Include them in this run? [y/N] "
|
|
645
|
+
$stdout.flush
|
|
646
|
+
answer = $stdin.gets.to_s.strip
|
|
647
|
+
return candidates if answer.downcase.start_with?("y")
|
|
648
|
+
end
|
|
649
|
+
|
|
650
|
+
puts "Scryer: excluding #{opted_in.size} frozen_string_literal finding(s) from this run — " \
|
|
651
|
+
"pass it as the rule_id (e.g. rails 'scryer:fix[frozen_string_literal]') to include them " \
|
|
652
|
+
"explicitly."
|
|
653
|
+
rest
|
|
654
|
+
end
|
|
341
655
|
end
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: scryer
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ram Laxman Yadav
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-08-
|
|
11
|
+
date: 2026-08-16 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rake
|
|
@@ -24,11 +24,27 @@ dependencies:
|
|
|
24
24
|
- - "~>"
|
|
25
25
|
- !ruby/object:Gem::Version
|
|
26
26
|
version: '13.0'
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: minitest
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - "~>"
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '5.0'
|
|
34
|
+
type: :development
|
|
35
|
+
prerelease: false
|
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - "~>"
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '5.0'
|
|
27
41
|
description: |
|
|
28
|
-
Scryer
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
42
|
+
Scryer is an all-in-one security auditing and static analysis tool for Ruby on Rails
|
|
43
|
+
applications — tells you what to fix first, across security, performance, dependencies, and
|
|
44
|
+
code quality. It scans a Ruby/Rails codebase for security vulnerabilities, performance
|
|
45
|
+
problems, dependency risk, and code-quality issues, then ranks everything it finds by severity
|
|
46
|
+
across all of those categories — so a scan ends with one answer to "what's most worth fixing,"
|
|
47
|
+
not four separate reports to reconcile by hand. (Style/lint is RuboCop's job — Scryer doesn't
|
|
32
48
|
touch that, except one narrow check.)
|
|
33
49
|
|
|
34
50
|
Detects SQL injection, mass assignment, SSRF, path traversal, IDOR, insecure JWT/CORS/session
|
|
@@ -49,6 +65,12 @@ files:
|
|
|
49
65
|
- CHANGELOG.md
|
|
50
66
|
- LICENSE.txt
|
|
51
67
|
- README.md
|
|
68
|
+
- docs/architecture.md
|
|
69
|
+
- docs/contributing.md
|
|
70
|
+
- docs/fix-mode.md
|
|
71
|
+
- docs/rails-integration.md
|
|
72
|
+
- docs/rules.md
|
|
73
|
+
- docs/usage.md
|
|
52
74
|
- exe/scryer
|
|
53
75
|
- lib/generators/scryer/USAGE
|
|
54
76
|
- lib/generators/scryer/install_generator.rb
|
|
@@ -61,10 +83,14 @@ files:
|
|
|
61
83
|
- lib/scryer/baseline.rb
|
|
62
84
|
- lib/scryer/cache_extractor.rb
|
|
63
85
|
- lib/scryer/cli.rb
|
|
86
|
+
- lib/scryer/colorizer.rb
|
|
64
87
|
- lib/scryer/dependency_audit.rb
|
|
88
|
+
- lib/scryer/dependency_fixer.rb
|
|
65
89
|
- lib/scryer/duplicate_detector.rb
|
|
66
90
|
- lib/scryer/finding.rb
|
|
91
|
+
- lib/scryer/fix_runner.rb
|
|
67
92
|
- lib/scryer/fix_verifier.rb
|
|
93
|
+
- lib/scryer/mechanical_fixer.rb
|
|
68
94
|
- lib/scryer/method_extractor.rb
|
|
69
95
|
- lib/scryer/minitest.rb
|
|
70
96
|
- lib/scryer/performance_rules/inefficient_save_loop_rule.rb
|
|
@@ -140,5 +166,7 @@ requirements: []
|
|
|
140
166
|
rubygems_version: 3.5.11
|
|
141
167
|
signing_key:
|
|
142
168
|
specification_version: 4
|
|
143
|
-
summary:
|
|
169
|
+
summary: An all-in-one security auditing and static analysis tool for Ruby on Rails
|
|
170
|
+
applications — tells you what to fix first, across security, performance, dependencies,
|
|
171
|
+
and code quality.
|
|
144
172
|
test_files: []
|