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/tasks/scryer.rake
CHANGED
|
@@ -11,25 +11,30 @@ 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]'. " \
|
|
22
|
+
"Set SCRYER_BASELINE=PATH (an env var, not a bracket arg — see scryer:save_baseline) to " \
|
|
23
|
+
"report only findings new since that baseline was saved."
|
|
20
24
|
task :report, [:format] do |_, args|
|
|
21
25
|
root = defined?(Rails) ? Rails.root.to_s : Dir.pwd
|
|
22
26
|
dirs = Scryer.configuration.dirs
|
|
23
27
|
|
|
24
28
|
# Ignore the declared :format name and read every positional value Rake
|
|
25
29
|
# was given (args.to_a) — the whole point is accepting a variable number
|
|
26
|
-
# of format/nodeps tokens plus one path, which a single
|
|
27
|
-
|
|
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)
|
|
28
33
|
|
|
29
34
|
skip_rules = Scryer.configuration.skip_rules
|
|
30
35
|
puts "Scryer: skipping #{skip_rules.join(', ')}." if skip_rules.any?
|
|
31
36
|
|
|
32
|
-
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
|
|
33
38
|
|
|
34
39
|
# Dependency auditing (OSV.dev) runs by default — a single scryer:report
|
|
35
40
|
# run is meant to cover the same ground as RuboCop + Brakeman +
|
|
@@ -42,9 +47,23 @@ namespace :scryer do
|
|
|
42
47
|
Scryer::DependencyAudit.ruby_eol_check(root) + Scryer::DependencyAudit.credentials_exposure_check(root)
|
|
43
48
|
end
|
|
44
49
|
|
|
50
|
+
# Rake bracket args are already stretched thin (format tokens + nodeps +
|
|
51
|
+
# one output path) — an ENV var is the more conventional way Rails rake
|
|
52
|
+
# tasks take an extra config value without overloading positional args
|
|
53
|
+
# further. `rails 'scryer:save_baseline[tmp/scryer_baseline.json]'` writes
|
|
54
|
+
# one; `SCRYER_BASELINE=tmp/scryer_baseline.json rails scryer:report`
|
|
55
|
+
# reads it back. See Scryer::Baseline / the CLI's --baseline flag for
|
|
56
|
+
# what this actually does (fingerprint-based new-vs-fixed diffing, not
|
|
57
|
+
# tied to line number).
|
|
58
|
+
baseline_path = blank_to_nil(ENV["SCRYER_BASELINE"])
|
|
59
|
+
fixed_count = 0
|
|
60
|
+
if baseline_path
|
|
61
|
+
dependency_findings, fixed_count = ScryerTasks.apply_baseline(baseline_path, result, dependency_findings)
|
|
62
|
+
end
|
|
63
|
+
|
|
45
64
|
if Scryer.configuration.ai_client
|
|
46
65
|
puts "Scryer: rewriting suggested fixes via the configured AI client..."
|
|
47
|
-
Scryer::AiFixSuggester.enhance_result!(result)
|
|
66
|
+
Scryer::AiFixSuggester.enhance_result!(result, root: root)
|
|
48
67
|
Scryer::AiFixSuggester.enhance_many!(dependency_findings) unless dependency_findings.empty?
|
|
49
68
|
end
|
|
50
69
|
|
|
@@ -70,7 +89,8 @@ namespace :scryer do
|
|
|
70
89
|
File.write(path, content)
|
|
71
90
|
end
|
|
72
91
|
|
|
73
|
-
ScryerTasks.print_summary(result: result, dependency_findings: dependency_findings, ran_deps: run_deps, paths: paths
|
|
92
|
+
ScryerTasks.print_summary(result: result, dependency_findings: dependency_findings, ran_deps: run_deps, paths: paths,
|
|
93
|
+
renderer: renderer, fixed_count: fixed_count, baseline_path: baseline_path)
|
|
74
94
|
|
|
75
95
|
# Same gate as the `scryer` executable and scryer:audit_dependencies
|
|
76
96
|
# below: fail the task (and so the CI job running it) on any security or
|
|
@@ -83,6 +103,158 @@ namespace :scryer do
|
|
|
83
103
|
end
|
|
84
104
|
end
|
|
85
105
|
|
|
106
|
+
desc "Convenience wrapper for CI: runs scryer:report with CI-sensible defaults — JSON + " \
|
|
107
|
+
"SARIF written under tmp/ (SARIF for GitHub Code Scanning / similar dashboards, JSON as " \
|
|
108
|
+
"the machine-readable form other CI steps can parse), dependency audit on, same " \
|
|
109
|
+
"SCRYER_BASELINE support and same fail-the-build-on-findings behavior as scryer:report. " \
|
|
110
|
+
"Equivalent to `rails 'scryer:report[json,sarif]'` under a name that doesn't require " \
|
|
111
|
+
"remembering the bracket-arg syntax. e.g. rails scryer:ci"
|
|
112
|
+
task :ci do
|
|
113
|
+
Rake::Task["scryer:report"].invoke("json", "sarif")
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
desc "Run Scryer and save every finding's fingerprint to PATH, for later comparison via " \
|
|
117
|
+
"SCRYER_BASELINE=PATH rails scryer:report (see that task's SCRYER_BASELINE note). " \
|
|
118
|
+
"Runs the same scan as scryer:report (including the dependency audit) but writes no " \
|
|
119
|
+
"report files. e.g. rails 'scryer:save_baseline[tmp/scryer_baseline.json]'"
|
|
120
|
+
task :save_baseline, [:path] do |_, args|
|
|
121
|
+
path = args[:path] || abort("Scryer: scryer:save_baseline needs a path, " \
|
|
122
|
+
"e.g. rails 'scryer:save_baseline[tmp/scryer_baseline.json]'")
|
|
123
|
+
root = defined?(Rails) ? Rails.root.to_s : Dir.pwd
|
|
124
|
+
dirs = Scryer.configuration.dirs
|
|
125
|
+
|
|
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
|
|
130
|
+
puts "Scryer: querying OSV.dev for known-vulnerable gems (needs network)..."
|
|
131
|
+
dependency_findings = Scryer::DependencyAudit.insecure_sources(root) + Scryer::DependencyAudit.vulnerable_gems(root) +
|
|
132
|
+
Scryer::DependencyAudit.ruby_eol_check(root) + Scryer::DependencyAudit.credentials_exposure_check(root)
|
|
133
|
+
|
|
134
|
+
all_findings = (result.security_findings + result.performance_findings + result.style_findings).map(&:to_h) +
|
|
135
|
+
dependency_findings.map(&:to_h)
|
|
136
|
+
Scryer::Baseline.save(path, all_findings)
|
|
137
|
+
puts "Scryer: saved baseline of #{all_findings.size} finding(s) to #{path}."
|
|
138
|
+
end
|
|
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
|
+
|
|
86
258
|
desc "Check Gemfile.lock for known-vulnerable gem versions (via OSV.dev — needs network) " \
|
|
87
259
|
"and insecure git/http gem sources (offline). Exits non-zero if anything is found, so " \
|
|
88
260
|
"this can gate CI the same way `bundle-audit check` does."
|
|
@@ -98,7 +270,7 @@ namespace :scryer do
|
|
|
98
270
|
credentials_exposure = Scryer::DependencyAudit.credentials_exposure_check(root)
|
|
99
271
|
|
|
100
272
|
(insecure + vulnerable + ruby_eol + credentials_exposure).each do |f|
|
|
101
|
-
puts "[#{f.severity.upcase}] #{ScryerTasks.dependency_label(f)}"
|
|
273
|
+
puts "[#{ScryerTasks.paint_severity(f.severity.upcase, f.severity)}] #{ScryerTasks.dependency_label(f)}"
|
|
102
274
|
puts " fix: #{f.suggested_fix}"
|
|
103
275
|
end
|
|
104
276
|
|
|
@@ -112,16 +284,22 @@ namespace :scryer do
|
|
|
112
284
|
VALID_FORMATS = %w[json html csv sarif].freeze
|
|
113
285
|
EXTENSION_FOR_FORMAT = { "json" => "json", "html" => "html", "csv" => "csv", "sarif" => "sarif" }.freeze
|
|
114
286
|
NO_DEPS_TOKEN = "nodeps".freeze
|
|
287
|
+
NO_DUPLICATES_TOKEN = "noduplicates".freeze
|
|
115
288
|
|
|
116
289
|
# tokens is every bracket arg Rake was given, e.g. %w[json doc/security.json] or
|
|
117
290
|
# %w[json html] or %w[html nodeps] or []. Returns [formats, path_arg,
|
|
118
|
-
# run_deps] — any token matching a known format is a
|
|
119
|
-
# "nodeps" token opts *out* of the dependency audit
|
|
120
|
-
#
|
|
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.
|
|
121
298
|
def parse_report_args(tokens)
|
|
122
299
|
tokens = tokens.map { |t| blank_to_nil(t) }.compact
|
|
123
300
|
run_deps = tokens.none? { |t| t.downcase == NO_DEPS_TOKEN }
|
|
124
|
-
|
|
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) }
|
|
125
303
|
|
|
126
304
|
format_tokens, other_tokens = tokens.partition { |t| VALID_FORMATS.include?(t.downcase) }
|
|
127
305
|
|
|
@@ -132,7 +310,7 @@ namespace :scryer do
|
|
|
132
310
|
formats = format_tokens.map(&:downcase).uniq
|
|
133
311
|
formats = %w[json html] if formats.empty?
|
|
134
312
|
|
|
135
|
-
[formats, other_tokens.first, run_deps]
|
|
313
|
+
[formats, other_tokens.first, run_deps, detect_duplicates]
|
|
136
314
|
end
|
|
137
315
|
|
|
138
316
|
# No path given: default filenames under tmp/. A path given with a single
|
|
@@ -164,6 +342,29 @@ end
|
|
|
164
342
|
module ScryerTasks
|
|
165
343
|
module_function
|
|
166
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
|
+
|
|
167
368
|
def git_commit_sha
|
|
168
369
|
shell_out("git rev-parse HEAD")
|
|
169
370
|
end
|
|
@@ -190,11 +391,40 @@ module ScryerTasks
|
|
|
190
391
|
"#{f.gem_name} #{f.installed_version}: #{f.title}"
|
|
191
392
|
end
|
|
192
393
|
|
|
394
|
+
# Same logic as Scryer::CLI#apply_baseline (see that method's comment for
|
|
395
|
+
# why fixed_count has to be computed once against the combined set of
|
|
396
|
+
# every category's current fingerprints, not once per category summed).
|
|
397
|
+
def apply_baseline(path, result, dependency_findings)
|
|
398
|
+
baseline_fingerprints = Scryer::Baseline.load(path)
|
|
399
|
+
|
|
400
|
+
security_hashes = result.security_findings.map(&:to_h)
|
|
401
|
+
performance_hashes = result.performance_findings.map(&:to_h)
|
|
402
|
+
style_hashes = result.style_findings.map(&:to_h)
|
|
403
|
+
dependency_hashes = dependency_findings.map(&:to_h)
|
|
404
|
+
|
|
405
|
+
all_current_fingerprints = Scryer::Baseline.fingerprints(security_hashes + performance_hashes + style_hashes + dependency_hashes)
|
|
406
|
+
fixed_count = (baseline_fingerprints - all_current_fingerprints.to_set).size
|
|
407
|
+
|
|
408
|
+
result.security_findings = filter_new(result.security_findings, security_hashes, baseline_fingerprints)
|
|
409
|
+
result.performance_findings = filter_new(result.performance_findings, performance_hashes, baseline_fingerprints)
|
|
410
|
+
result.style_findings = filter_new(result.style_findings, style_hashes, baseline_fingerprints)
|
|
411
|
+
filtered_deps = filter_new(dependency_findings, dependency_hashes, baseline_fingerprints)
|
|
412
|
+
|
|
413
|
+
[filtered_deps, fixed_count]
|
|
414
|
+
rescue Scryer::Baseline::LoadError => e
|
|
415
|
+
abort("Scryer: #{e.message}")
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
def filter_new(objects, hashes, baseline_fingerprints)
|
|
419
|
+
fingerprints = Scryer::Baseline.fingerprints(hashes)
|
|
420
|
+
objects.each_with_index.reject { |_, i| baseline_fingerprints.include?(fingerprints[i]) }.map(&:first)
|
|
421
|
+
end
|
|
422
|
+
|
|
193
423
|
# The "one audit command" summary — a single scan's worth of every
|
|
194
424
|
# category Scryer covers (security, performance, duplicate/smelly code,
|
|
195
425
|
# dependencies), the same categories usually split across RuboCop +
|
|
196
426
|
# Brakeman + bundler-audit + Reek, side by side in one box.
|
|
197
|
-
def print_summary(result:, dependency_findings:, ran_deps:, paths:)
|
|
427
|
+
def print_summary(result:, dependency_findings:, ran_deps:, paths:, renderer:, fixed_count: 0, baseline_path: nil)
|
|
198
428
|
# "Code Quality" is the umbrella label for both duplicate-code groups
|
|
199
429
|
# and rule-based style findings (e.g. frozen_string_literal) — two
|
|
200
430
|
# different detectors, same broad concern, one row in the box.
|
|
@@ -209,15 +439,26 @@ module ScryerTasks
|
|
|
209
439
|
["Dependencies", deps_count]
|
|
210
440
|
]
|
|
211
441
|
|
|
212
|
-
divider = "─" * 32
|
|
442
|
+
divider = paint("─" * 32, :gray)
|
|
443
|
+
score = renderer.security_score
|
|
213
444
|
puts ""
|
|
214
|
-
puts "Scryer Audit — #{result.files_scanned} files scanned"
|
|
445
|
+
puts paint("Scryer Audit — #{result.files_scanned} files scanned", :bold)
|
|
215
446
|
puts divider
|
|
216
447
|
puts ""
|
|
448
|
+
if baseline_path
|
|
449
|
+
puts "Baseline: #{baseline_path} — showing new findings only (#{fixed_count} fixed since baseline)."
|
|
450
|
+
puts ""
|
|
451
|
+
end
|
|
452
|
+
clean_rate = renderer.rules_clean_rate
|
|
453
|
+
puts "Security Score: #{score["score"]}/100 (#{paint_grade(score["grade"], score["grade"])})"
|
|
454
|
+
puts "Checks: #{clean_rate["clean"]}/#{clean_rate["total"]} rules clean (#{clean_rate["percent"]}%)"
|
|
455
|
+
puts ""
|
|
217
456
|
rows.each { |label, count| puts summary_row(label, count) }
|
|
218
457
|
puts divider
|
|
219
|
-
puts summary_row("Total", total)
|
|
458
|
+
puts paint(summary_row("Total", total), :bold)
|
|
220
459
|
puts ""
|
|
460
|
+
print_top_priorities(renderer.top_risks)
|
|
461
|
+
print_owasp_coverage(renderer.owasp_coverage)
|
|
221
462
|
paths.each { |format, path| puts "#{format.upcase} report: #{path}" }
|
|
222
463
|
end
|
|
223
464
|
|
|
@@ -225,4 +466,190 @@ module ScryerTasks
|
|
|
225
466
|
value = count.nil? ? "skipped (nodeps)" : "#{count} finding#{"s" unless count == 1}"
|
|
226
467
|
"#{label.ljust(14)}#{value.rjust(20)}"
|
|
227
468
|
end
|
|
469
|
+
|
|
470
|
+
# The categories above are counted separately, but nothing else ranks
|
|
471
|
+
# across them — this is what actually backs "tells you what to fix
|
|
472
|
+
# first" rather than just splitting findings into four buckets. Same
|
|
473
|
+
# data ReportRenderer#top_risks already sorts for the HTML report.
|
|
474
|
+
def print_top_priorities(risks)
|
|
475
|
+
return if risks.empty?
|
|
476
|
+
|
|
477
|
+
puts paint("Top priorities:", :bold)
|
|
478
|
+
risks.each_with_index do |r, i|
|
|
479
|
+
puts " #{i + 1}. [#{paint_severity(r[:severity], r[:severity])}] #{r[:category]} — #{r[:label]} (#{r[:location]})"
|
|
480
|
+
end
|
|
481
|
+
puts ""
|
|
482
|
+
end
|
|
483
|
+
|
|
484
|
+
# Byproduct of every security rule carrying an owasp_category — see
|
|
485
|
+
# ReportRenderer#owasp_coverage. Scryer's own best-effort tagging, not an
|
|
486
|
+
# OWASP-audited mapping (documented in full in the README).
|
|
487
|
+
def print_owasp_coverage(coverage)
|
|
488
|
+
return if coverage.empty?
|
|
489
|
+
|
|
490
|
+
puts "OWASP Top 10 (2021) coverage:"
|
|
491
|
+
coverage.each { |category, count| puts " #{category}: #{count} finding#{"s" unless count == 1}" }
|
|
492
|
+
puts ""
|
|
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
|
|
228
655
|
end
|