scryer 0.3.0 → 1.1.1
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 +322 -0
- data/README.md +546 -43
- data/lib/generators/scryer/USAGE +10 -2
- data/lib/generators/scryer/templates/scryer_initializer.rb +15 -0
- data/lib/scryer/ai_fix_suggester.rb +29 -9
- data/lib/scryer/ast.rb +95 -6
- data/lib/scryer/authorization_watcher.rb +156 -0
- data/lib/scryer/baseline.rb +75 -0
- data/lib/scryer/cli.rb +209 -11
- data/lib/scryer/dependency_audit.rb +108 -5
- data/lib/scryer/finding.rb +6 -0
- data/lib/scryer/fix_verifier.rb +82 -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 +637 -44
- data/lib/scryer/rspec.rb +55 -0
- data/lib/scryer/rule.rb +22 -2
- data/lib/scryer/rules/action_cable_forgery_protection_rule.rb +50 -0
- data/lib/scryer/rules/active_storage_inline_disposition_rule.rb +50 -0
- data/lib/scryer/rules/active_storage_missing_content_type_validation_rule.rb +79 -0
- data/lib/scryer/rules/authentication_bypass_rule.rb +95 -0
- 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 +100 -0
- data/lib/scryer/rules/csrf_protection_rule.rb +60 -11
- data/lib/scryer/rules/force_ssl_rule.rb +48 -0
- data/lib/scryer/rules/graphql_missing_query_limits_rule.rb +106 -0
- data/lib/scryer/rules/hardcoded_basic_auth_rule.rb +51 -0
- data/lib/scryer/rules/hardcoded_secret_key_base_rule.rb +58 -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 +165 -0
- data/lib/scryer/rules/insecure_cookie_serializer_rule.rb +47 -0
- data/lib/scryer/rules/job_raw_params_rule.rb +131 -0
- data/lib/scryer/rules/jwt_insecure_rule.rb +123 -0
- data/lib/scryer/rules/mass_assignment_rule.rb +34 -10
- 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 +110 -0
- data/lib/scryer/rules/security_headers_rule.rb +133 -0
- data/lib/scryer/rules/sql_injection_rule.rb +3 -0
- data/lib/scryer/rules/ssrf_rule.rb +139 -0
- 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 +51 -0
- data/lib/scryer/rules/xss_unsafe_html_rule.rb +41 -0
- data/lib/scryer/style_rules/frozen_string_literal_rule.rb +1 -0
- data/lib/scryer/version.rb +1 -1
- data/lib/scryer.rb +15 -0
- data/lib/tasks/scryer.rake +138 -13
- metadata +41 -12
data/lib/scryer/cli.rb
CHANGED
|
@@ -9,7 +9,7 @@ module Scryer
|
|
|
9
9
|
# only needed for this CLI entry point, not when the gem is required
|
|
10
10
|
# inside a host app.
|
|
11
11
|
class CLI
|
|
12
|
-
EXTENSION_FORMATS = { ".json" => "json", ".html" => "html", ".htm" => "html", ".csv" => "csv" }.freeze
|
|
12
|
+
EXTENSION_FORMATS = { ".json" => "json", ".html" => "html", ".htm" => "html", ".csv" => "csv", ".sarif" => "sarif" }.freeze
|
|
13
13
|
|
|
14
14
|
def initialize(argv, stdout: $stdout, stderr: $stderr)
|
|
15
15
|
@argv = argv
|
|
@@ -21,6 +21,12 @@ module Scryer
|
|
|
21
21
|
# found (so `scryer -o report.json` can gate CI the way `brakeman -o
|
|
22
22
|
# report.json` does), 2 on a usage error.
|
|
23
23
|
def run
|
|
24
|
+
# `scryer verify` is a distinct subcommand, not a flag on the normal
|
|
25
|
+
# scan — it takes its own small option set (--rule/--file/--path) that
|
|
26
|
+
# would collide with the main parser's -p/-o meanings, so it's
|
|
27
|
+
# dispatched before the main OptionParser ever sees the rest of argv.
|
|
28
|
+
return run_verify(@argv[1..]) if @argv.first == "verify"
|
|
29
|
+
|
|
24
30
|
options = parse(@argv)
|
|
25
31
|
return 0 if options[:exit_early]
|
|
26
32
|
|
|
@@ -50,12 +56,20 @@ module Scryer
|
|
|
50
56
|
dependency_findings = []
|
|
51
57
|
if ran_deps
|
|
52
58
|
@stdout.puts "Scryer: querying OSV.dev for known-vulnerable gems (needs network)..."
|
|
53
|
-
dependency_findings = DependencyAudit.insecure_sources(root) + DependencyAudit.vulnerable_gems(root)
|
|
59
|
+
dependency_findings = DependencyAudit.insecure_sources(root) + DependencyAudit.vulnerable_gems(root) +
|
|
60
|
+
DependencyAudit.ruby_eol_check(root) + DependencyAudit.credentials_exposure_check(root)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
return save_baseline(options[:save_baseline], result, dependency_findings) if options[:save_baseline]
|
|
64
|
+
|
|
65
|
+
fixed_count = 0
|
|
66
|
+
if options[:baseline]
|
|
67
|
+
dependency_findings, fixed_count = apply_baseline(options[:baseline], result, dependency_findings)
|
|
54
68
|
end
|
|
55
69
|
|
|
56
70
|
if Scryer.configuration.ai_client
|
|
57
71
|
@stdout.puts "Scryer: rewriting suggested fixes via the configured AI client..."
|
|
58
|
-
AiFixSuggester.enhance_result!(result)
|
|
72
|
+
AiFixSuggester.enhance_result!(result, root: root)
|
|
59
73
|
AiFixSuggester.enhance_many!(dependency_findings) unless dependency_findings.empty?
|
|
60
74
|
end
|
|
61
75
|
|
|
@@ -71,7 +85,8 @@ module Scryer
|
|
|
71
85
|
outputs = options[:outputs].empty? ? default_outputs(root) : options[:outputs]
|
|
72
86
|
outputs.each { |path| write_report(renderer, path) }
|
|
73
87
|
|
|
74
|
-
print_summary(result: result, dependency_findings: dependency_findings, ran_deps: ran_deps, outputs: outputs
|
|
88
|
+
print_summary(result: result, dependency_findings: dependency_findings, ran_deps: ran_deps, outputs: outputs,
|
|
89
|
+
renderer: renderer, fixed_count: fixed_count, baseline_path: options[:baseline])
|
|
75
90
|
|
|
76
91
|
result.security_findings.empty? && dependency_findings.empty? ? 0 : 1
|
|
77
92
|
rescue UsageError => e
|
|
@@ -91,23 +106,33 @@ module Scryer
|
|
|
91
106
|
|
|
92
107
|
@stdout.puts "Scryer: querying OSV.dev for known vulnerabilities (needs network)..."
|
|
93
108
|
vulnerable = DependencyAudit.vulnerable_gems(root)
|
|
109
|
+
ruby_eol = DependencyAudit.ruby_eol_check(root)
|
|
110
|
+
credentials_exposure = DependencyAudit.credentials_exposure_check(root)
|
|
94
111
|
|
|
95
|
-
(insecure + vulnerable).each do |f|
|
|
96
|
-
|
|
97
|
-
@stdout.puts label
|
|
112
|
+
(insecure + vulnerable + ruby_eol + credentials_exposure).each do |f|
|
|
113
|
+
@stdout.puts "[#{f.severity.upcase}] #{dependency_label(f)}"
|
|
98
114
|
@stdout.puts " fix: #{f.suggested_fix}"
|
|
99
115
|
end
|
|
100
116
|
|
|
101
|
-
total = insecure.size + vulnerable.size
|
|
102
|
-
@stdout.puts "\nScryer: #{total} dependency finding(s) (#{insecure.size} insecure source,
|
|
117
|
+
total = insecure.size + vulnerable.size + ruby_eol.size + credentials_exposure.size
|
|
118
|
+
@stdout.puts "\nScryer: #{total} dependency finding(s) (#{insecure.size} insecure source, " \
|
|
119
|
+
"#{vulnerable.size} vulnerable gem, #{ruby_eol.size} Ruby EOL, " \
|
|
120
|
+
"#{credentials_exposure.size} credentials exposure)."
|
|
103
121
|
total.positive? ? 1 : 0
|
|
104
122
|
end
|
|
105
123
|
|
|
124
|
+
def dependency_label(f)
|
|
125
|
+
return f.message if f.kind == "insecure_source"
|
|
126
|
+
return "#{f.gem_name} #{f.installed_version} - #{f.advisory_id}: #{f.title}" if f.advisory_id
|
|
127
|
+
|
|
128
|
+
"#{f.gem_name} #{f.installed_version}: #{f.title}"
|
|
129
|
+
end
|
|
130
|
+
|
|
106
131
|
# The "one audit command" summary — a single scan's worth of every
|
|
107
132
|
# category Scryer covers (security, performance, duplicate/smelly code,
|
|
108
133
|
# dependencies), the same categories usually split across RuboCop +
|
|
109
134
|
# Brakeman + bundler-audit + Reek, side by side in one box.
|
|
110
|
-
def print_summary(result:, dependency_findings:, ran_deps:, outputs:)
|
|
135
|
+
def print_summary(result:, dependency_findings:, ran_deps:, outputs:, renderer:, fixed_count: 0, baseline_path: nil)
|
|
111
136
|
# "Code Quality" is the umbrella label for both duplicate-code groups
|
|
112
137
|
# and rule-based style findings (e.g. frozen_string_literal) — two
|
|
113
138
|
# different detectors, same broad concern, one row in the box.
|
|
@@ -123,14 +148,26 @@ module Scryer
|
|
|
123
148
|
]
|
|
124
149
|
|
|
125
150
|
divider = "─" * 32
|
|
151
|
+
score = renderer.security_score
|
|
126
152
|
@stdout.puts ""
|
|
127
153
|
@stdout.puts "Scryer Audit — #{result.files_scanned} files scanned"
|
|
128
154
|
@stdout.puts divider
|
|
129
155
|
@stdout.puts ""
|
|
156
|
+
if baseline_path
|
|
157
|
+
@stdout.puts "Baseline: #{baseline_path} — showing new findings only " \
|
|
158
|
+
"(#{fixed_count} fixed since baseline)."
|
|
159
|
+
@stdout.puts ""
|
|
160
|
+
end
|
|
161
|
+
clean_rate = renderer.rules_clean_rate
|
|
162
|
+
@stdout.puts "Security Score: #{score["score"]}/100 (#{score["grade"]})"
|
|
163
|
+
@stdout.puts "Checks: #{clean_rate["clean"]}/#{clean_rate["total"]} rules clean (#{clean_rate["percent"]}%)"
|
|
164
|
+
@stdout.puts ""
|
|
130
165
|
rows.each { |label, count| @stdout.puts summary_row(label, count) }
|
|
131
166
|
@stdout.puts divider
|
|
132
167
|
@stdout.puts summary_row("Total", total)
|
|
133
168
|
@stdout.puts ""
|
|
169
|
+
print_top_priorities(renderer.top_risks)
|
|
170
|
+
print_owasp_coverage(renderer.owasp_coverage)
|
|
134
171
|
outputs.each { |path| @stdout.puts "#{format_for(path).upcase} report: #{path}" }
|
|
135
172
|
end
|
|
136
173
|
|
|
@@ -139,6 +176,99 @@ module Scryer
|
|
|
139
176
|
"#{label.ljust(14)}#{value.rjust(20)}"
|
|
140
177
|
end
|
|
141
178
|
|
|
179
|
+
# The categories above are counted separately, but nothing else ranks
|
|
180
|
+
# across them — this is what actually backs "tells you what to fix
|
|
181
|
+
# first" rather than just splitting findings into four buckets. Same
|
|
182
|
+
# data ReportRenderer#top_risks already sorts for the HTML report.
|
|
183
|
+
def print_top_priorities(risks)
|
|
184
|
+
return if risks.empty?
|
|
185
|
+
|
|
186
|
+
@stdout.puts "Top priorities:"
|
|
187
|
+
risks.each_with_index do |r, i|
|
|
188
|
+
@stdout.puts " #{i + 1}. [#{r[:severity]}] #{r[:category]} — #{r[:label]} (#{r[:location]})"
|
|
189
|
+
end
|
|
190
|
+
@stdout.puts ""
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Byproduct of every security rule carrying an owasp_category — see
|
|
194
|
+
# ReportRenderer#owasp_coverage. Scryer's own best-effort tagging, not an
|
|
195
|
+
# OWASP-audited mapping (documented in full in the README).
|
|
196
|
+
def print_owasp_coverage(coverage)
|
|
197
|
+
return if coverage.empty?
|
|
198
|
+
|
|
199
|
+
@stdout.puts "OWASP Top 10 (2021) coverage:"
|
|
200
|
+
coverage.each { |category, count| @stdout.puts " #{category}: #{count} finding#{"s" unless count == 1}" }
|
|
201
|
+
@stdout.puts ""
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# `scryer verify --rule RULE_ID --file PATH` — re-parses just that one
|
|
205
|
+
# file and re-runs just that one rule against it, independent of a full
|
|
206
|
+
# scan. Meant to run right after applying a fix (by hand or via an LLM)
|
|
207
|
+
# to confirm the specific finding it targeted is actually gone, without
|
|
208
|
+
# waiting on/paying for a full project scan. Deliberately narrower than
|
|
209
|
+
# "did this fix introduce a NEW finding elsewhere" — that's what a normal
|
|
210
|
+
# `scryer` run (or `--baseline`) already answers; this only answers "does
|
|
211
|
+
# the one thing I just tried to fix still fire."
|
|
212
|
+
def run_verify(argv)
|
|
213
|
+
options = {}
|
|
214
|
+
|
|
215
|
+
parser = OptionParser.new do |opts|
|
|
216
|
+
opts.banner = "Usage: scryer verify --rule RULE_ID --file PATH [--path ROOT]"
|
|
217
|
+
opts.on("--rule RULE_ID", "The rule_id to re-check (required) — see `scryer verify --list-rules`.") { |v| options[:rule] = v }
|
|
218
|
+
opts.on("--file PATH", "File to re-scan (required) — relative to --path, or absolute.") { |v| options[:file] = v }
|
|
219
|
+
opts.on("--path ROOT", "Project root PATH is relative to (default: current directory).") { |v| options[:root] = v }
|
|
220
|
+
opts.on("--list-rules", "List every known rule_id and exit.") { options[:list_rules] = true }
|
|
221
|
+
opts.on("-h", "--help", "Show this help.") { options[:exit_early] = true; @stdout.puts opts }
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
begin
|
|
225
|
+
parser.parse!(argv)
|
|
226
|
+
rescue OptionParser::ParseError => e
|
|
227
|
+
raise UsageError, "#{e.message}\n#{parser}"
|
|
228
|
+
end
|
|
229
|
+
return 0 if options[:exit_early]
|
|
230
|
+
|
|
231
|
+
if options[:list_rules]
|
|
232
|
+
RuleSet.all.map(&:rule_id).sort.each { |id| @stdout.puts id }
|
|
233
|
+
return 0
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
raise UsageError, "scryer verify needs --rule RULE_ID and --file PATH\n#{parser}" unless options[:rule] && options[:file]
|
|
237
|
+
|
|
238
|
+
rule_class = RuleSet.all.find { |r| r.rule_id == options[:rule] }
|
|
239
|
+
unless rule_class
|
|
240
|
+
raise UsageError, "unknown rule_id #{options[:rule].inspect} — run `scryer verify --list-rules` to see valid ids."
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
root = File.expand_path(options[:root] || Dir.pwd)
|
|
244
|
+
abs_path = File.expand_path(options[:file], root)
|
|
245
|
+
raise UsageError, "no such file: #{abs_path}" unless File.file?(abs_path)
|
|
246
|
+
|
|
247
|
+
rel_path = abs_path.sub(/\A#{Regexp.escape(root)}\/?/, "")
|
|
248
|
+
source = File.read(abs_path)
|
|
249
|
+
|
|
250
|
+
sexp = begin
|
|
251
|
+
Ripper.sexp(source)
|
|
252
|
+
rescue StandardError => e
|
|
253
|
+
raise UsageError, "#{rel_path} failed to parse: #{e.message}"
|
|
254
|
+
end
|
|
255
|
+
if sexp.nil?
|
|
256
|
+
raise UsageError, "#{rel_path} could not be parsed (a syntax error, or Ruby syntax newer " \
|
|
257
|
+
"than this gem's Ruby runtime supports)."
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
findings = rule_class.new(file: rel_path, source: source, sexp: sexp).scan
|
|
261
|
+
|
|
262
|
+
if findings.empty?
|
|
263
|
+
@stdout.puts "scryer verify: #{options[:rule]} no longer fires on #{rel_path} — fix verified."
|
|
264
|
+
0
|
|
265
|
+
else
|
|
266
|
+
@stdout.puts "scryer verify: #{options[:rule]} still fires on #{rel_path} (#{findings.size} finding(s)):"
|
|
267
|
+
findings.each { |f| @stdout.puts " line #{f.line}: #{f.message}" }
|
|
268
|
+
1
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
|
|
142
272
|
# One-off OSV.dev lookup for a single gem — no Gemfile.lock, no scan,
|
|
143
273
|
# no other network calls. `spec` is "name" or "name:version" (colon
|
|
144
274
|
# rather than a second CLI arg, so this stays a single -o-style flag).
|
|
@@ -158,6 +288,62 @@ module Scryer
|
|
|
158
288
|
findings.empty? ? 0 : 1
|
|
159
289
|
end
|
|
160
290
|
|
|
291
|
+
# `--save-baseline PATH` is a distinct mode, same as --audit-deps/
|
|
292
|
+
# --check-gem: it captures every finding across every category (not
|
|
293
|
+
# just security — a legacy app's existing performance/style debt is
|
|
294
|
+
# just as much "not what I'm here to re-litigate today" as its security
|
|
295
|
+
# debt), writes the fingerprints, and exits without writing the normal
|
|
296
|
+
# -o reports. See Scryer::Baseline for why fingerprints, not file:line.
|
|
297
|
+
def save_baseline(path, result, dependency_findings)
|
|
298
|
+
all_findings = (result.security_findings + result.performance_findings + result.style_findings)
|
|
299
|
+
.map(&:to_h) + dependency_findings.map(&:to_h)
|
|
300
|
+
Baseline.save(path, all_findings)
|
|
301
|
+
@stdout.puts "Scryer: saved baseline of #{all_findings.size} finding(s) to #{path}."
|
|
302
|
+
0
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# Filters `result`'s finding arrays (mutated in place — Result is a
|
|
306
|
+
# plain Struct, this is the same object the caller already holds) and
|
|
307
|
+
# returns [new_dependency_findings, fixed_count] since dependency_findings
|
|
308
|
+
# is a local array in the caller, not a field this method can mutate by
|
|
309
|
+
# reference the way it can Struct fields.
|
|
310
|
+
# `fixed_count` has to be computed ONCE against the union of every
|
|
311
|
+
# category's current fingerprints, not once per category summed
|
|
312
|
+
# together — Baseline.diff's fixed_count is "baseline fingerprints not
|
|
313
|
+
# present in *this* call's findings," so calling it separately per
|
|
314
|
+
# category and summing would count every other category's
|
|
315
|
+
# still-present findings as "fixed" too (verified: this exact bug
|
|
316
|
+
# produced a nonsensical "762 fixed" on a rescan with zero changes,
|
|
317
|
+
# against a 255-finding baseline — fixed by computing fixed_count from
|
|
318
|
+
# the combined set once, while still filtering "new" per category since
|
|
319
|
+
# that part only checks baseline membership, which is fine to do
|
|
320
|
+
# separately).
|
|
321
|
+
def apply_baseline(path, result, dependency_findings)
|
|
322
|
+
baseline_fingerprints = Baseline.load(path)
|
|
323
|
+
|
|
324
|
+
security_hashes = result.security_findings.map(&:to_h)
|
|
325
|
+
performance_hashes = result.performance_findings.map(&:to_h)
|
|
326
|
+
style_hashes = result.style_findings.map(&:to_h)
|
|
327
|
+
dependency_hashes = dependency_findings.map(&:to_h)
|
|
328
|
+
|
|
329
|
+
all_current_fingerprints = Baseline.fingerprints(security_hashes + performance_hashes + style_hashes + dependency_hashes)
|
|
330
|
+
fixed_count = (baseline_fingerprints - all_current_fingerprints.to_set).size
|
|
331
|
+
|
|
332
|
+
result.security_findings = filter_new(result.security_findings, security_hashes, baseline_fingerprints)
|
|
333
|
+
result.performance_findings = filter_new(result.performance_findings, performance_hashes, baseline_fingerprints)
|
|
334
|
+
result.style_findings = filter_new(result.style_findings, style_hashes, baseline_fingerprints)
|
|
335
|
+
filtered_deps = filter_new(dependency_findings, dependency_hashes, baseline_fingerprints)
|
|
336
|
+
|
|
337
|
+
[filtered_deps, fixed_count]
|
|
338
|
+
rescue Baseline::LoadError => e
|
|
339
|
+
raise UsageError, e.message
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def filter_new(objects, hashes, baseline_fingerprints)
|
|
343
|
+
fingerprints = Baseline.fingerprints(hashes)
|
|
344
|
+
objects.each_with_index.reject { |_, i| baseline_fingerprints.include?(fingerprints[i]) }.map(&:first)
|
|
345
|
+
end
|
|
346
|
+
|
|
161
347
|
def parse(argv)
|
|
162
348
|
options = { outputs: [] }
|
|
163
349
|
|
|
@@ -165,7 +351,7 @@ module Scryer
|
|
|
165
351
|
opts.banner = "Usage: scryer [options]"
|
|
166
352
|
opts.on("-o PATH", "--output PATH",
|
|
167
353
|
"Write a report to PATH (repeatable). Format is inferred from the " \
|
|
168
|
-
"extension: .json, .html, or .
|
|
354
|
+
"extension: .json, .html, .csv, or .sarif.") { |v| options[:outputs] << v }
|
|
169
355
|
opts.on("-p PATH", "--path PATH", "Root directory to scan (default: current directory).") { |v| options[:path] = v }
|
|
170
356
|
opts.on("-r PATH", "--require PATH",
|
|
171
357
|
"Require a Ruby file before scanning (repeatable) — the file can call " \
|
|
@@ -178,6 +364,17 @@ module Scryer
|
|
|
178
364
|
"insecure git/http sources (offline), instead of running the normal static scan. " \
|
|
179
365
|
"Exits non-zero if anything is found, so this can gate CI the same way " \
|
|
180
366
|
"`bundle-audit check` does.") { options[:audit_deps] = true }
|
|
367
|
+
opts.on("--save-baseline PATH",
|
|
368
|
+
"Run the normal scan, save every finding's fingerprint to PATH, then exit — no " \
|
|
369
|
+
"reports written. A later `scryer --baseline PATH` scan reports only findings " \
|
|
370
|
+
"new since this snapshot, so an app with existing security debt can gate CI on " \
|
|
371
|
+
"new issues without being forced to fix everything on day one.") { |v| options[:save_baseline] = v }
|
|
372
|
+
opts.on("--baseline PATH",
|
|
373
|
+
"Compare this scan against a baseline saved by --save-baseline: every report " \
|
|
374
|
+
"(-o files, console summary, exit code) reflects only findings new since PATH " \
|
|
375
|
+
"was saved. Fingerprints ignore line number (rule + file + offending code), so " \
|
|
376
|
+
"an unrelated edit elsewhere in the file won't make an existing finding look " \
|
|
377
|
+
"new.") { |v| options[:baseline] = v }
|
|
181
378
|
opts.on("--no-deps",
|
|
182
379
|
"Skip the dependency audit (OSV.dev vulnerable gems + insecure git/http sources) " \
|
|
183
380
|
"that otherwise runs as part of every normal scan. Use this for a fast, fully " \
|
|
@@ -212,6 +409,7 @@ module Scryer
|
|
|
212
409
|
content = case format
|
|
213
410
|
when "json" then renderer.as_json
|
|
214
411
|
when "csv" then renderer.as_csv
|
|
412
|
+
when "sarif" then renderer.as_sarif
|
|
215
413
|
else renderer.as_html
|
|
216
414
|
end
|
|
217
415
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
require "json"
|
|
2
|
+
require "date"
|
|
2
3
|
|
|
3
4
|
module Scryer
|
|
4
5
|
# Dependency vulnerability + supply-chain-hygiene checks for Gemfile.lock —
|
|
@@ -55,15 +56,35 @@ module Scryer
|
|
|
55
56
|
"LOW" => "info"
|
|
56
57
|
}.freeze
|
|
57
58
|
|
|
59
|
+
# Ruby's own published maintenance-branch EOL dates
|
|
60
|
+
# (ruby-lang.org/en/downloads/branches/) — these are announced years in
|
|
61
|
+
# advance and essentially never move, unlike a CVE feed, so this is a
|
|
62
|
+
# one-time/occasional-update cost rather than an ongoing sync burden.
|
|
63
|
+
# Update when a new branch's EOL is announced, or an older branch not
|
|
64
|
+
# listed here needs adding.
|
|
65
|
+
RUBY_EOL_DATES = {
|
|
66
|
+
"2.5" => Date.new(2021, 3, 31),
|
|
67
|
+
"2.6" => Date.new(2022, 3, 31),
|
|
68
|
+
"2.7" => Date.new(2023, 3, 31),
|
|
69
|
+
"3.0" => Date.new(2024, 3, 31),
|
|
70
|
+
"3.1" => Date.new(2025, 3, 31),
|
|
71
|
+
"3.2" => Date.new(2026, 3, 31),
|
|
72
|
+
"3.3" => Date.new(2027, 3, 31),
|
|
73
|
+
"3.4" => Date.new(2028, 3, 31)
|
|
74
|
+
}.freeze
|
|
75
|
+
|
|
58
76
|
class << self
|
|
59
|
-
# Parses a Gemfile.lock into `{ gems: { name => {version:, source:} },
|
|
60
|
-
#
|
|
61
|
-
#
|
|
77
|
+
# Parses a Gemfile.lock into `{ gems: { name => {version:, source:} },
|
|
78
|
+
# git_or_path_sources: [...], ruby_version: "3.3.3" | nil }`. `source`
|
|
79
|
+
# is "gem", "git", or "path" — taken from which top-level block
|
|
80
|
+
# (GEM/GIT/PATH) the spec's `specs:` list appeared under.
|
|
62
81
|
def parse_lockfile(path)
|
|
63
82
|
gems = {}
|
|
64
83
|
git_or_path_sources = []
|
|
84
|
+
ruby_version = nil
|
|
65
85
|
|
|
66
|
-
current_block = nil # "gem" | "git" | "path" | other section
|
|
86
|
+
current_block = nil # "gem" | "git" | "path" | nil (other section)
|
|
87
|
+
current_section = nil # PLATFORMS | DEPENDENCIES | BUNDLED WITH | RUBY VERSION | nil
|
|
67
88
|
current_remote = nil
|
|
68
89
|
in_specs = false
|
|
69
90
|
|
|
@@ -71,10 +92,12 @@ module Scryer
|
|
|
71
92
|
case line
|
|
72
93
|
when /\A(GEM|GIT|PATH)\s*\z/
|
|
73
94
|
current_block = Regexp.last_match(1).downcase
|
|
95
|
+
current_section = nil
|
|
74
96
|
current_remote = nil
|
|
75
97
|
in_specs = false
|
|
76
98
|
when /\A(PLATFORMS|DEPENDENCIES|BUNDLED WITH|RUBY VERSION)\s*\z/
|
|
77
99
|
current_block = nil
|
|
100
|
+
current_section = Regexp.last_match(1)
|
|
78
101
|
in_specs = false
|
|
79
102
|
when /\A {2}remote:\s*(\S+)\s*\z/
|
|
80
103
|
current_remote = Regexp.last_match(1)
|
|
@@ -90,10 +113,46 @@ module Scryer
|
|
|
90
113
|
# in pathological Gemfiles; last one wins, consistent with how
|
|
91
114
|
# Bundler itself resolves a single spec per gem name.
|
|
92
115
|
gems[name] = { version: version, source: current_block }
|
|
116
|
+
when /\A\s*ruby\s+(\S+)\s*\z/
|
|
117
|
+
ruby_version = Regexp.last_match(1) if current_section == "RUBY VERSION"
|
|
93
118
|
end
|
|
94
119
|
end
|
|
95
120
|
|
|
96
|
-
{ gems: gems, git_or_path_sources: git_or_path_sources }
|
|
121
|
+
{ gems: gems, git_or_path_sources: git_or_path_sources, ruby_version: ruby_version }
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Offline. Flags the Ruby version pinned in Gemfile.lock's RUBY
|
|
125
|
+
# VERSION section if its minor series is past end-of-life — after that
|
|
126
|
+
# date Ruby publishes no more security patches for it, for any issue,
|
|
127
|
+
# so this is a real gap even with every gem otherwise up to date.
|
|
128
|
+
# Returns [] if no version is pinned, or its series isn't one
|
|
129
|
+
# RUBY_EOL_DATES knows about (never guessed as "fine" by omission).
|
|
130
|
+
def ruby_eol_check(root)
|
|
131
|
+
lockfile = File.join(root, "Gemfile.lock")
|
|
132
|
+
return [] unless File.exist?(lockfile)
|
|
133
|
+
|
|
134
|
+
ruby_version = parse_lockfile(lockfile)[:ruby_version]
|
|
135
|
+
return [] unless ruby_version
|
|
136
|
+
|
|
137
|
+
series = ruby_version[/\A\d+\.\d+/]
|
|
138
|
+
eol_date = series && RUBY_EOL_DATES[series]
|
|
139
|
+
return [] unless eol_date && Date.today > eol_date
|
|
140
|
+
|
|
141
|
+
[
|
|
142
|
+
Finding.new(
|
|
143
|
+
kind: "ruby_eol",
|
|
144
|
+
gem_name: "Ruby",
|
|
145
|
+
installed_version: ruby_version,
|
|
146
|
+
severity: "critical",
|
|
147
|
+
title: "Ruby #{series} is end-of-life",
|
|
148
|
+
url: "https://www.ruby-lang.org/en/downloads/branches/",
|
|
149
|
+
patched_versions: [],
|
|
150
|
+
message: "Ruby #{ruby_version} (#{series} series) reached end-of-life on " \
|
|
151
|
+
"#{eol_date} — no security patches are published for it anymore, for any issue.",
|
|
152
|
+
suggested_fix: "Upgrade to a Ruby version still receiving security maintenance — see " \
|
|
153
|
+
"https://www.ruby-lang.org/en/downloads/branches/ for current status."
|
|
154
|
+
)
|
|
155
|
+
]
|
|
97
156
|
end
|
|
98
157
|
|
|
99
158
|
# Offline. Flags GIT/PATH sources recorded with an unencrypted remote
|
|
@@ -160,6 +219,41 @@ module Scryer
|
|
|
160
219
|
findings
|
|
161
220
|
end
|
|
162
221
|
|
|
222
|
+
# Offline. Flags `config/master.key` (the key that decrypts Rails
|
|
223
|
+
# encrypted credentials, config/credentials.yml.enc) if it exists on
|
|
224
|
+
# disk and .gitignore doesn't exclude it — Rails generates it
|
|
225
|
+
# gitignored by default (`/config/master.key`), but that line is easy
|
|
226
|
+
# to lose (a merge, a from-scratch .gitignore, copying the file into a
|
|
227
|
+
# repo that never had the Rails default). If this file is ever
|
|
228
|
+
# committed, anyone with repo access — including git history, even
|
|
229
|
+
# after a later removal — can decrypt every credential in
|
|
230
|
+
# config/credentials.yml.enc.
|
|
231
|
+
def credentials_exposure_check(root)
|
|
232
|
+
master_key_path = File.join(root, "config", "master.key")
|
|
233
|
+
return [] unless File.exist?(master_key_path)
|
|
234
|
+
return [] if master_key_gitignored?(root)
|
|
235
|
+
|
|
236
|
+
[
|
|
237
|
+
Finding.new(
|
|
238
|
+
kind: "credentials_exposure",
|
|
239
|
+
gem_name: "Rails",
|
|
240
|
+
severity: "critical",
|
|
241
|
+
title: "config/master.key is present and not gitignored",
|
|
242
|
+
url: "https://guides.rubyonrails.org/security.html",
|
|
243
|
+
patched_versions: [],
|
|
244
|
+
message: "config/master.key exists in this app, but .gitignore doesn't exclude it " \
|
|
245
|
+
"(checked for both `/config/master.key` and `config/master.key`) — if this " \
|
|
246
|
+
"file is ever committed, anyone with repository access, including git " \
|
|
247
|
+
"history even after a later removal, can decrypt every credential in " \
|
|
248
|
+
"config/credentials.yml.enc.",
|
|
249
|
+
suggested_fix: "Add `/config/master.key` to .gitignore immediately. If this key was " \
|
|
250
|
+
"ever actually committed to git history, treat every credential in " \
|
|
251
|
+
"config/credentials.yml.enc as compromised: rotate them and regenerate " \
|
|
252
|
+
"the master key (delete both files, then `bin/rails credentials:edit`)."
|
|
253
|
+
)
|
|
254
|
+
]
|
|
255
|
+
end
|
|
256
|
+
|
|
163
257
|
# Needs network. One-off OSV.dev lookup for a single gem, independent
|
|
164
258
|
# of any Gemfile.lock — backs `scryer --check-gem NAME[:VERSION]`.
|
|
165
259
|
# With a version, only vulnerabilities affecting that exact version
|
|
@@ -171,6 +265,15 @@ module Scryer
|
|
|
171
265
|
|
|
172
266
|
private
|
|
173
267
|
|
|
268
|
+
def master_key_gitignored?(root)
|
|
269
|
+
gitignore_path = File.join(root, ".gitignore")
|
|
270
|
+
return false unless File.exist?(gitignore_path)
|
|
271
|
+
|
|
272
|
+
File.foreach(gitignore_path).any? do |line|
|
|
273
|
+
line.strip.match?(%r{\A/?config/master\.key\z})
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
|
|
174
277
|
def query_osv(name, version = nil)
|
|
175
278
|
require "net/http"
|
|
176
279
|
require "uri"
|
data/lib/scryer/finding.rb
CHANGED
|
@@ -7,11 +7,17 @@ module Scryer
|
|
|
7
7
|
:rule_id, # e.g. "sql_injection"
|
|
8
8
|
:category, # "security" | "performance" | "duplication"
|
|
9
9
|
:severity, # "critical" | "warning" | "info"
|
|
10
|
+
:confidence, # "high" | "medium" | "low" — see Rule.confidence
|
|
11
|
+
:cwe, # e.g. "CWE-89", or nil for non-security rules
|
|
12
|
+
:owasp_category, # e.g. "A03:2021-Injection", or nil for non-security rules
|
|
10
13
|
:file, # relative path
|
|
11
14
|
:line, # integer line number (1-indexed) or nil
|
|
12
15
|
:code_snippet, # the offending source line, stripped
|
|
13
16
|
:message, # human-readable description of the issue
|
|
14
17
|
:suggested_fix, # human-readable explanation + example patch
|
|
18
|
+
:fix_verified, # true/false/nil — see Scryer::FixVerifier; nil unless
|
|
19
|
+
# an ai_client is configured AND the AI's reply had a
|
|
20
|
+
# verifiable AFTER: block, not "no fix exists"
|
|
15
21
|
keyword_init: true
|
|
16
22
|
) do
|
|
17
23
|
def to_h
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
require "ripper"
|
|
2
|
+
|
|
3
|
+
module Scryer
|
|
4
|
+
# Best-effort verification that an AI-rewritten suggested_fix actually
|
|
5
|
+
# clears the finding it was generated for — "AI-verified remediation" in
|
|
6
|
+
# the sense of "Scryer independently re-checked this," not in the sense of
|
|
7
|
+
# a human review being unnecessary (suggested_fix is still never
|
|
8
|
+
# auto-applied to a real file; see AiFixSuggester's own header comment).
|
|
9
|
+
#
|
|
10
|
+
# How: AiFixSuggester's prompt (see static_prompt_for) asks the model to
|
|
11
|
+
# end its reply with a fenced "AFTER:" code block containing a drop-in
|
|
12
|
+
# replacement for the single offending source line. This class extracts
|
|
13
|
+
# that block, substitutes it for that one line in an in-memory copy of the
|
|
14
|
+
# file (nothing is ever written to disk), re-parses the result, and
|
|
15
|
+
# re-runs *only the one rule that flagged this finding* against it. If
|
|
16
|
+
# that rule no longer fires anywhere in the modified file, the fix is
|
|
17
|
+
# marked verified.
|
|
18
|
+
#
|
|
19
|
+
# Deliberately narrow, same spirit as `scryer verify` (the CLI command
|
|
20
|
+
# this shares its core logic with): this confirms the ONE finding it
|
|
21
|
+
# targeted is gone, not that the fix is otherwise correct, idiomatic, or
|
|
22
|
+
# free of introducing a different problem — a full rescan (or `scryer
|
|
23
|
+
# verify` again with a different --rule) answers that.
|
|
24
|
+
#
|
|
25
|
+
# Returns true (rule no longer fires), false (attempted but the rule still
|
|
26
|
+
# fires, or the rewritten line doesn't even parse), or nil (verification
|
|
27
|
+
# wasn't attempted at all — no AFTER: block in the AI's reply, the
|
|
28
|
+
# original file isn't readable, or this isn't a rule-backed Finding to
|
|
29
|
+
# begin with). nil is deliberately distinct from false: it means "we don't
|
|
30
|
+
# know," not "we checked and it's still broken."
|
|
31
|
+
module FixVerifier
|
|
32
|
+
module_function
|
|
33
|
+
|
|
34
|
+
AFTER_BLOCK = /AFTER:\s*```\w*\n(.*?)\n?```/m.freeze
|
|
35
|
+
|
|
36
|
+
def verify(finding:, root:)
|
|
37
|
+
return nil unless finding.is_a?(Scryer::Finding)
|
|
38
|
+
return nil unless finding.line && finding.rule_id
|
|
39
|
+
|
|
40
|
+
after_snippet = extract_after_snippet(finding.suggested_fix)
|
|
41
|
+
return nil unless after_snippet
|
|
42
|
+
|
|
43
|
+
abs_path = File.join(root, finding.file.to_s)
|
|
44
|
+
return nil unless File.file?(abs_path)
|
|
45
|
+
|
|
46
|
+
lines = File.read(abs_path).lines
|
|
47
|
+
return nil unless finding.line.between?(1, lines.size)
|
|
48
|
+
|
|
49
|
+
modified_source = apply_line_replacement(lines, finding.line, after_snippet)
|
|
50
|
+
|
|
51
|
+
sexp = begin
|
|
52
|
+
Ripper.sexp(modified_source)
|
|
53
|
+
rescue StandardError
|
|
54
|
+
nil
|
|
55
|
+
end
|
|
56
|
+
return false if sexp.nil? # the rewritten line doesn't even parse — not a usable fix
|
|
57
|
+
|
|
58
|
+
rule_class = Scryer::RuleSet.all.find { |r| r.rule_id == finding.rule_id }
|
|
59
|
+
return nil unless rule_class
|
|
60
|
+
|
|
61
|
+
remaining = rule_class.new(file: finding.file, source: modified_source, sexp: sexp).scan
|
|
62
|
+
remaining.none? { |f| f.rule_id == finding.rule_id }
|
|
63
|
+
rescue StandardError
|
|
64
|
+
nil
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def extract_after_snippet(suggested_fix)
|
|
68
|
+
match = AFTER_BLOCK.match(suggested_fix.to_s)
|
|
69
|
+
return nil unless match
|
|
70
|
+
|
|
71
|
+
content = match[1].to_s
|
|
72
|
+
content.strip.empty? ? nil : content
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def apply_line_replacement(lines, line_number, replacement)
|
|
76
|
+
replacement_text = replacement.end_with?("\n") ? replacement : "#{replacement}\n"
|
|
77
|
+
modified = lines.dup
|
|
78
|
+
modified[line_number - 1] = replacement_text
|
|
79
|
+
modified.join
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# Opt-in Minitest integration — require this file yourself (e.g. `require
|
|
2
|
+
# "scryer/minitest"` in test_helper.rb) rather than it loading automatically
|
|
3
|
+
# with the gem; same reasoning as lib/scryer/rspec.rb (Minitest is never a
|
|
4
|
+
# Scryer runtime dependency, and this file's assertions only make sense once
|
|
5
|
+
# the host app's own test framework is already loaded).
|
|
6
|
+
#
|
|
7
|
+
# Mix Scryer::MinitestAssertions into a Minitest::Test (or
|
|
8
|
+
# ActiveSupport::TestCase, which is one) to assert this app's own security
|
|
9
|
+
# scan stays clean as part of its normal test suite:
|
|
10
|
+
#
|
|
11
|
+
# class SecurityTest < ActiveSupport::TestCase
|
|
12
|
+
# include Scryer::MinitestAssertions
|
|
13
|
+
#
|
|
14
|
+
# test "no critical findings" do
|
|
15
|
+
# assert_no_critical_scryer_findings(Scryer.scan(root: Rails.root.to_s))
|
|
16
|
+
# end
|
|
17
|
+
#
|
|
18
|
+
# test "the mass-assignment bug fixed in PR #123 doesn't come back" do
|
|
19
|
+
# assert_no_scryer_findings_for(Scryer.scan(root: Rails.root.to_s), "mass_assignment")
|
|
20
|
+
# end
|
|
21
|
+
# end
|
|
22
|
+
module Scryer
|
|
23
|
+
module MinitestAssertions
|
|
24
|
+
# Only security findings — style/performance findings aren't a security
|
|
25
|
+
# regression, same scoping as the RSpec have_no_critical_findings matcher.
|
|
26
|
+
def assert_no_critical_scryer_findings(result, msg = nil)
|
|
27
|
+
criticals = result.security_findings.select { |f| f.severity == "critical" }
|
|
28
|
+
default_msg = -> {
|
|
29
|
+
lines = criticals.map { |f| " - #{f.rule_id} at #{f.file}:#{f.line} — #{f.message}" }
|
|
30
|
+
"expected no critical security findings, but got #{criticals.size}:\n#{lines.join("\n")}"
|
|
31
|
+
}
|
|
32
|
+
assert criticals.empty?, msg || default_msg.call
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Checks all three categories (security/performance/style) — a rule
|
|
36
|
+
# regressing is worth catching regardless of which category it's filed
|
|
37
|
+
# under, unlike assert_no_critical_scryer_findings above.
|
|
38
|
+
def assert_no_scryer_findings_for(result, rule_id, msg = nil)
|
|
39
|
+
matches = (result.security_findings + result.performance_findings + result.style_findings)
|
|
40
|
+
.select { |f| f.rule_id == rule_id.to_s }
|
|
41
|
+
default_msg = -> {
|
|
42
|
+
lines = matches.map { |f| " - #{f.file}:#{f.line} — #{f.message}" }
|
|
43
|
+
"expected no findings for rule #{rule_id.inspect}, but got #{matches.size}:\n#{lines.join("\n")}"
|
|
44
|
+
}
|
|
45
|
+
assert matches.empty?, msg || default_msg.call
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|