scryer 1.1.1 → 1.2.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 +196 -4
- data/README.md +74 -1027
- data/docs/architecture.md +273 -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_audit.rb +16 -1
- 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/report_renderer.rb +60 -21
- 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 +36 -12
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
require "set"
|
|
2
|
+
require "ripper"
|
|
3
|
+
|
|
4
|
+
module Scryer
|
|
5
|
+
# Deterministic, no-AI fixes for the narrow set of rules where the
|
|
6
|
+
# correct rewrite doesn't require judgment — there's exactly one sane
|
|
7
|
+
# answer every time, so there's nothing for an LLM to decide. Everything
|
|
8
|
+
# else (mass_assignment, idor, missing_authorization, csrf, ...) still
|
|
9
|
+
# needs an `ai_client` or a human, because the correct fix depends on
|
|
10
|
+
# things Scryer can't know statically (which params to permit, which
|
|
11
|
+
# policy to call).
|
|
12
|
+
#
|
|
13
|
+
# Produces the exact same "explanation + AFTER: fenced block" shape
|
|
14
|
+
# AiFixSuggester's prompt asks a real model for (see FixVerifier's
|
|
15
|
+
# AFTER_BLOCK regex) — so a mechanical fix flows through the *identical*
|
|
16
|
+
# verify/apply pipeline as an AI one; nothing here is trusted more than an
|
|
17
|
+
# LLM's guess would be. If a specific line doesn't match the exact shape a
|
|
18
|
+
# fixer here knows how to rewrite, `suggest` returns nil and the finding
|
|
19
|
+
# falls through to the ai_client (if configured) or manual review, same as
|
|
20
|
+
# any other unsupported case — this never guesses.
|
|
21
|
+
module MechanicalFixer
|
|
22
|
+
module_function
|
|
23
|
+
|
|
24
|
+
SUPPORTED_RULES = %w[
|
|
25
|
+
frozen_string_literal
|
|
26
|
+
sql_injection
|
|
27
|
+
force_ssl_disabled
|
|
28
|
+
insecure_cookie_serializer
|
|
29
|
+
weak_session_cookie
|
|
30
|
+
security_headers_disabled
|
|
31
|
+
].freeze
|
|
32
|
+
|
|
33
|
+
# frozen_string_literal is mechanically fixable but, unlike the others,
|
|
34
|
+
# deliberately opt-in — a project-wide `scryer fix` sweep would
|
|
35
|
+
# otherwise touch nearly every file for a cosmetic, `info`-severity
|
|
36
|
+
# finding. See CLI#run_fix / ScryerTasks — this list is what those
|
|
37
|
+
# callers use to decide whether to ask before including a rule in an
|
|
38
|
+
# unscoped run, not something `suggest` itself gates on (explicitly
|
|
39
|
+
# requesting the rule, e.g. `--rule frozen_string_literal`, is already
|
|
40
|
+
# informed consent, so `suggest` always tries it).
|
|
41
|
+
OPT_IN_RULES = %w[frozen_string_literal].freeze
|
|
42
|
+
|
|
43
|
+
# Bang-methods and operators that mutate their receiver in place — used
|
|
44
|
+
# to detect whether freezing a file's string literals could actually
|
|
45
|
+
# break it (see fix_frozen_string_literal). Not exhaustive (this is a
|
|
46
|
+
# heuristic, not data-flow analysis), but covers the realistic cases.
|
|
47
|
+
MUTATING_METHODS = %w[
|
|
48
|
+
concat replace insert clear prepend
|
|
49
|
+
upcase! downcase! capitalize! swapcase!
|
|
50
|
+
strip! lstrip! rstrip! chomp! chop! squeeze!
|
|
51
|
+
gsub! sub! slice! delete! tr! tr_s! succ! next!
|
|
52
|
+
reverse! encode! force_encoding
|
|
53
|
+
].freeze
|
|
54
|
+
|
|
55
|
+
def supported?(rule_id)
|
|
56
|
+
SUPPORTED_RULES.include?(rule_id)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def opt_in?(rule_id)
|
|
60
|
+
OPT_IN_RULES.include?(rule_id)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def suggest(finding, root: nil)
|
|
64
|
+
return nil unless finding.is_a?(Scryer::Finding)
|
|
65
|
+
|
|
66
|
+
case finding.rule_id
|
|
67
|
+
when "frozen_string_literal" then fix_frozen_string_literal(finding, root: root)
|
|
68
|
+
when "sql_injection" then fix_sql_injection(finding, root: root)
|
|
69
|
+
when "force_ssl_disabled" then fix_boolean_flip(finding, root, /(\bforce_ssl\s*=\s*)false\b/, "Flips `force_ssl` to `true`, restoring Rails' HTTPS enforcement.")
|
|
70
|
+
when "insecure_cookie_serializer" then fix_cookie_serializer(finding, root: root)
|
|
71
|
+
when "weak_session_cookie" then fix_weak_session_cookie(finding, root: root)
|
|
72
|
+
when "security_headers_disabled" then fix_security_headers_disabled(finding, root: root)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# finding.code_snippet is deliberately `.strip`ped by Ast.source_line
|
|
77
|
+
# (it's meant for display in a report, not for rewriting) — every fixer
|
|
78
|
+
# below needs the actual on-disk line, indentation included, or the
|
|
79
|
+
# rewritten line silently loses its original indentation. Falls back to
|
|
80
|
+
# code_snippet only when the real file can't be read (e.g. a unit test
|
|
81
|
+
# constructing a bare Finding with no root/real file on disk).
|
|
82
|
+
def raw_line(finding, root)
|
|
83
|
+
return finding.code_snippet.to_s unless root && finding.file && finding.line
|
|
84
|
+
|
|
85
|
+
abs_path = File.join(root.to_s, finding.file.to_s)
|
|
86
|
+
return finding.code_snippet.to_s unless File.file?(abs_path)
|
|
87
|
+
|
|
88
|
+
lines = File.read(abs_path).lines
|
|
89
|
+
return finding.code_snippet.to_s unless finding.line.between?(1, lines.size)
|
|
90
|
+
|
|
91
|
+
lines[finding.line - 1].to_s.chomp
|
|
92
|
+
rescue StandardError
|
|
93
|
+
finding.code_snippet.to_s
|
|
94
|
+
end
|
|
95
|
+
private_class_method :raw_line
|
|
96
|
+
|
|
97
|
+
# A magic comment is recognized by Ruby only on the very first source
|
|
98
|
+
# line, or the second if the first is a shebang — so prepending it (or
|
|
99
|
+
# inserting it right after a shebang) is always the *correct* rewrite.
|
|
100
|
+
# But "correct" isn't the same as "safe": freezing every string literal
|
|
101
|
+
# in the file breaks anything that mutates one in place (`str << x`,
|
|
102
|
+
# `str.gsub!(...)`, ...) at runtime with a FrozenError — something the
|
|
103
|
+
# frozen_string_literal rule itself has no way to see (it only checks
|
|
104
|
+
# for the magic comment's absence). Declines (nil) whenever the file
|
|
105
|
+
# can't be read/analyzed, or analysis finds a plausible in-place
|
|
106
|
+
# mutation — "analyse and fix only if no issue will arise from it".
|
|
107
|
+
def fix_frozen_string_literal(finding, root:)
|
|
108
|
+
source = read_source(finding, root)
|
|
109
|
+
return nil if source.nil? || mutates_a_string_literal?(source)
|
|
110
|
+
|
|
111
|
+
first_line = source.lines.first.to_s.chomp
|
|
112
|
+
code = if first_line.start_with?("#!")
|
|
113
|
+
"#{first_line}\n# frozen_string_literal: true\n"
|
|
114
|
+
else
|
|
115
|
+
"# frozen_string_literal: true\n\n#{first_line}"
|
|
116
|
+
end
|
|
117
|
+
wrap_after("Adds the `# frozen_string_literal: true` magic comment as the first line of the file — no in-place string mutation was found, so freezing literals here is safe.", code)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def read_source(finding, root)
|
|
121
|
+
return nil unless root && finding.file
|
|
122
|
+
|
|
123
|
+
abs_path = File.join(root.to_s, finding.file.to_s)
|
|
124
|
+
File.file?(abs_path) ? File.read(abs_path) : nil
|
|
125
|
+
rescue StandardError
|
|
126
|
+
nil
|
|
127
|
+
end
|
|
128
|
+
private_class_method :read_source
|
|
129
|
+
|
|
130
|
+
# True if `source` plausibly mutates a string literal in place, either
|
|
131
|
+
# directly (`"foo" << x`, `"foo".gsub!(...)`) or via a local variable
|
|
132
|
+
# that was assigned a string literal earlier in the file (`s = "foo"`
|
|
133
|
+
# ... `s << x`). Heuristic, not scope-aware — a variable name reused for
|
|
134
|
+
# a different value in a different method can cause a false positive
|
|
135
|
+
# (declining a fix that would actually have been fine), which is the
|
|
136
|
+
# safe direction to err in; a real Ripper parse failure is treated the
|
|
137
|
+
# same way (unable to analyze -> decline).
|
|
138
|
+
def mutates_a_string_literal?(source)
|
|
139
|
+
sexp = begin
|
|
140
|
+
Ripper.sexp(source)
|
|
141
|
+
rescue StandardError
|
|
142
|
+
nil
|
|
143
|
+
end
|
|
144
|
+
return true if sexp.nil?
|
|
145
|
+
|
|
146
|
+
literal_vars = string_literal_assigned_vars(sexp)
|
|
147
|
+
|
|
148
|
+
Ast.each_node(sexp).any? do |node|
|
|
149
|
+
shovel_onto_tracked_receiver?(node, literal_vars) ||
|
|
150
|
+
index_assign_onto_tracked_receiver?(node, literal_vars) ||
|
|
151
|
+
mutating_call_on_tracked_receiver?(node, literal_vars)
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
private_class_method :mutates_a_string_literal?
|
|
155
|
+
|
|
156
|
+
def string_literal_assigned_vars(sexp)
|
|
157
|
+
Ast.each_node(sexp).each_with_object(Set.new) do |node, vars|
|
|
158
|
+
next unless Ast.tagged?(node, :assign)
|
|
159
|
+
|
|
160
|
+
target = node[1]
|
|
161
|
+
value = node[2]
|
|
162
|
+
next unless Ast.tagged?(target, :var_field)
|
|
163
|
+
next unless Ast.tagged?(value, :string_literal)
|
|
164
|
+
|
|
165
|
+
name = Ast.ident_text(target[1])
|
|
166
|
+
vars << name if name
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
private_class_method :string_literal_assigned_vars
|
|
170
|
+
|
|
171
|
+
def shovel_onto_tracked_receiver?(node, literal_vars)
|
|
172
|
+
return false unless Ast.tagged?(node, :binary) && node[2] == :<<
|
|
173
|
+
|
|
174
|
+
tracked_receiver?(node[1], literal_vars)
|
|
175
|
+
end
|
|
176
|
+
private_class_method :shovel_onto_tracked_receiver?
|
|
177
|
+
|
|
178
|
+
def index_assign_onto_tracked_receiver?(node, literal_vars)
|
|
179
|
+
return false unless Ast.tagged?(node, :assign) && Ast.tagged?(node[1], :aref_field)
|
|
180
|
+
|
|
181
|
+
tracked_receiver?(node[1][1], literal_vars)
|
|
182
|
+
end
|
|
183
|
+
private_class_method :index_assign_onto_tracked_receiver?
|
|
184
|
+
|
|
185
|
+
def mutating_call_on_tracked_receiver?(node, literal_vars)
|
|
186
|
+
return false unless Ast.tagged?(node, :call, :command_call)
|
|
187
|
+
|
|
188
|
+
receiver_and_name = Ast.call_name(node)
|
|
189
|
+
return false unless receiver_and_name
|
|
190
|
+
|
|
191
|
+
receiver, method_name = receiver_and_name
|
|
192
|
+
return false unless MUTATING_METHODS.include?(method_name)
|
|
193
|
+
|
|
194
|
+
tracked_receiver?(receiver, literal_vars)
|
|
195
|
+
end
|
|
196
|
+
private_class_method :mutating_call_on_tracked_receiver?
|
|
197
|
+
|
|
198
|
+
def tracked_receiver?(receiver, literal_vars)
|
|
199
|
+
return true if Ast.tagged?(receiver, :string_literal)
|
|
200
|
+
return false unless Ast.tagged?(receiver, :var_ref, :vcall)
|
|
201
|
+
|
|
202
|
+
literal_vars.include?(Ast.ident_text(receiver[1]))
|
|
203
|
+
end
|
|
204
|
+
private_class_method :tracked_receiver?
|
|
205
|
+
|
|
206
|
+
# Only handles the unambiguous case: the interpolated string is the
|
|
207
|
+
# SOLE argument to the flagged call (immediately preceded by `(` and
|
|
208
|
+
# immediately followed by `)` on the same physical line) — anything
|
|
209
|
+
# else (an existing second argument, a multi-line call) is left alone
|
|
210
|
+
# rather than guessed at, since inserting a new bind parameter at the
|
|
211
|
+
# right spot in an arbitrary chained/multi-arg call isn't a one-answer
|
|
212
|
+
# problem. Quote characters directly hugging a `#{...}` (the common
|
|
213
|
+
# `"id = '#{x}'"` manual-SQL-quoting style) are consumed along with it
|
|
214
|
+
# — leaving them in place would produce `'?'`, which double-quotes the
|
|
215
|
+
# bound value and silently breaks the query while still looking
|
|
216
|
+
# "verified" (Scryer's own check only looks for interpolation, not
|
|
217
|
+
# query correctness).
|
|
218
|
+
def fix_sql_injection(finding, root:)
|
|
219
|
+
method = finding.message.to_s[/\A`(\w+)`/, 1]
|
|
220
|
+
return nil unless method
|
|
221
|
+
|
|
222
|
+
line = raw_line(finding, root)
|
|
223
|
+
m = line.match(/\A(?<pre>.*\b#{Regexp.escape(method)}\s*\(\s*)"(?<body>(?:[^"\\]|\\.)*)"\s*\)(?<rest>.*)\z/)
|
|
224
|
+
return nil unless m
|
|
225
|
+
|
|
226
|
+
exprs = []
|
|
227
|
+
new_body = m[:body].gsub(/(\\"|')?#\{([^{}]*)\}(\\"|')?/) do
|
|
228
|
+
exprs << Regexp.last_match(2).strip
|
|
229
|
+
"?"
|
|
230
|
+
end
|
|
231
|
+
return nil if exprs.empty? || new_body.include?("\#{")
|
|
232
|
+
|
|
233
|
+
code = "#{m[:pre]}\"#{new_body}\", #{exprs.join(", ")})#{m[:rest]}"
|
|
234
|
+
explanation = "Replaces the string interpolation inside the SQL string with #{exprs.size > 1 ? 'bind parameters' : 'a `?` bind parameter'}, " \
|
|
235
|
+
"so the value#{exprs.size > 1 ? 's are' : ' is'} always sent as a query parameter rather than parsed as SQL text."
|
|
236
|
+
wrap_after(explanation, code)
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def fix_boolean_flip(finding, root, pattern, explanation)
|
|
240
|
+
line = raw_line(finding, root)
|
|
241
|
+
return nil unless pattern.match?(line)
|
|
242
|
+
|
|
243
|
+
wrap_after(explanation, line.sub(pattern, '\1true'))
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def fix_cookie_serializer(finding, root:)
|
|
247
|
+
line = raw_line(finding, root)
|
|
248
|
+
pattern = /(cookies_serializer\s*=\s*)(:marshal|["']marshal["'])/
|
|
249
|
+
return nil unless pattern.match?(line)
|
|
250
|
+
|
|
251
|
+
code = line.sub(pattern, '\1:json')
|
|
252
|
+
wrap_after("Switches the cookie serializer from `:marshal` to Rails' safe default, `:json`.", code)
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# Appends `secure: true` (production-only) to the end of the
|
|
256
|
+
# `session_store` line — safe because this is a single command-call
|
|
257
|
+
# statement with no parens to worry about closing correctly.
|
|
258
|
+
def fix_weak_session_cookie(finding, root:)
|
|
259
|
+
line = raw_line(finding, root)
|
|
260
|
+
return nil if line.strip.empty?
|
|
261
|
+
|
|
262
|
+
code = "#{line.chomp}, secure: Rails.env.production?"
|
|
263
|
+
wrap_after("Adds `secure: true` (production only) to the session cookie options, so it's never sent over plain HTTP.", code)
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# Only the plain single-header `= value` assignment shape (see
|
|
267
|
+
# SecurityHeadersRule) — a `.merge!(...)` call can disable several
|
|
268
|
+
# headers in one statement, only one of which may be the actual
|
|
269
|
+
# finding, so removing the whole line there could silently take out an
|
|
270
|
+
# unrelated, legitimate header too. Comments the line out (rather than
|
|
271
|
+
# deleting it outright) so there's a visible trace of what changed —
|
|
272
|
+
# keeping the original leading indentation so the comment lines up with
|
|
273
|
+
# its surrounding code instead of jumping to column 0.
|
|
274
|
+
def fix_security_headers_disabled(finding, root:)
|
|
275
|
+
line = raw_line(finding, root)
|
|
276
|
+
return nil if line.include?("merge!") || !line.include?("=")
|
|
277
|
+
|
|
278
|
+
indent = line[/\A[ \t]*/]
|
|
279
|
+
code = "#{indent}# #{line.strip} # removed by `scryer fix` — restores Rails' default security header"
|
|
280
|
+
wrap_after("Comments out the line disabling this security header, restoring Rails' safe default.", code)
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def wrap_after(explanation, code)
|
|
284
|
+
"#{explanation}\n\nAFTER:\n```ruby\n#{code}\n```\n"
|
|
285
|
+
end
|
|
286
|
+
private_class_method :wrap_after
|
|
287
|
+
end
|
|
288
|
+
end
|
|
@@ -106,16 +106,21 @@ module Scryer
|
|
|
106
106
|
end
|
|
107
107
|
|
|
108
108
|
# A single 0-100 number (plus a letter grade) summarizing this scan's
|
|
109
|
-
# security risk exposure — security findings and dependency findings
|
|
110
|
-
#
|
|
111
|
-
#
|
|
112
|
-
#
|
|
113
|
-
#
|
|
114
|
-
#
|
|
115
|
-
#
|
|
116
|
-
#
|
|
117
|
-
#
|
|
118
|
-
#
|
|
109
|
+
# security risk exposure — security findings and dependency findings at
|
|
110
|
+
# full weight, plus performance findings at a small fraction of that
|
|
111
|
+
# weight (a slow app is a real cost, just not a *security* one, so it
|
|
112
|
+
# nudges the score rather than driving it — see SCORE_CATEGORY_WEIGHT).
|
|
113
|
+
# Code-quality findings (duplicate-code groups, frozen_string_literal)
|
|
114
|
+
# are never part of this on purpose: a `DuplicateDetector::DuplicateGroup`
|
|
115
|
+
# isn't even a `Finding` (no severity/confidence to weigh in the first
|
|
116
|
+
# place — see duplicate_detector.rb), and style findings are cosmetic,
|
|
117
|
+
# not risk. Deliberately NOT normalized by files-scanned or lines of
|
|
118
|
+
# code: it reflects this scan's absolute finding exposure, so it's
|
|
119
|
+
# meaningful for tracking one project's trend over time (does the next
|
|
120
|
+
# scan score higher or lower), not for comparing two differently-sized
|
|
121
|
+
# codebases against each other — a bigger app with the same finding
|
|
122
|
+
# *density* will naturally score lower here, and that's a documented
|
|
123
|
+
# limitation, not a bug.
|
|
119
124
|
#
|
|
120
125
|
# Exponential decay rather than linear subtraction from 100: a single
|
|
121
126
|
# critical/high finding should visibly move the score (100 -> ~86) without
|
|
@@ -123,11 +128,24 @@ module Scryer
|
|
|
123
128
|
# which would make the score useless for comparing "bad" against "worse."
|
|
124
129
|
# weighted_penalty combines severity (the dominant factor) with
|
|
125
130
|
# confidence (a low-confidence rule's finding shouldn't hurt the score as
|
|
126
|
-
# much as a high-confidence one saying the same severity) —
|
|
127
|
-
# philosophy as SARIF's `rank` (see sarif_rank above), computed once
|
|
128
|
-
# for a single scan-level number instead of per-result.
|
|
129
|
-
|
|
131
|
+
# much as a high-confidence one saying the same severity) and category —
|
|
132
|
+
# same philosophy as SARIF's `rank` (see sarif_rank above), computed once
|
|
133
|
+
# here for a single scan-level number instead of per-result.
|
|
134
|
+
# "info" is deliberately far below critical/warning, not just a step
|
|
135
|
+
# down from them — an info-severity finding is closer to a cosmetic
|
|
136
|
+
# note than a real risk (see e.g. csrf_protection_disabled's
|
|
137
|
+
# narrowly-scoped-skip case), so a codebase with only info findings
|
|
138
|
+
# should barely move off 100 even with a fair number of them, rather
|
|
139
|
+
# than accumulating like a smaller warning would.
|
|
140
|
+
SCORE_SEVERITY_WEIGHT = { "critical" => 15, "warning" => 6, "info" => 0.25 }.freeze
|
|
130
141
|
SCORE_CONFIDENCE_WEIGHT = { "high" => 1.0, "medium" => 0.7, "low" => 0.4 }.freeze
|
|
142
|
+
# `category` is "security" for every Scryer::Finding in security_findings
|
|
143
|
+
# and every DependencyAudit::Finding (which has no `category` field at
|
|
144
|
+
# all — `f["category"]` is nil for those, so they fall through to the
|
|
145
|
+
# 1.0 default below, same full weight as security). "performance" is the
|
|
146
|
+
# only other category ever passed into this method's `findings` — style
|
|
147
|
+
# findings and duplicate-code groups never reach here at all (see above).
|
|
148
|
+
SCORE_CATEGORY_WEIGHT = { "security" => 1.0, "performance" => 0.2 }.freeze
|
|
131
149
|
SCORE_DECAY_CONSTANT = 100.0
|
|
132
150
|
|
|
133
151
|
# Deliberately reads @result/@dependency_findings directly rather than
|
|
@@ -140,10 +158,14 @@ module Scryer
|
|
|
140
158
|
# (has "confidence") and DependencyAudit::Finding (doesn't — a plain
|
|
141
159
|
# Hash returns nil for a missing key rather than raising, unlike
|
|
142
160
|
# calling #confidence directly on a struct that has no such member).
|
|
143
|
-
|
|
161
|
+
# style_findings/duplicate_groups are never included — see this
|
|
162
|
+
# method's doc comment above for why.
|
|
163
|
+
findings = (@result.security_findings + @result.performance_findings + @dependency_findings).map(&:to_h)
|
|
144
164
|
|
|
145
165
|
weighted_penalty = findings.sum do |f|
|
|
146
|
-
(SCORE_SEVERITY_WEIGHT[f["severity"]] || 3) *
|
|
166
|
+
(SCORE_SEVERITY_WEIGHT[f["severity"]] || 3) *
|
|
167
|
+
(SCORE_CONFIDENCE_WEIGHT[f["confidence"]] || 0.7) *
|
|
168
|
+
(SCORE_CATEGORY_WEIGHT[f["category"]] || 1.0)
|
|
147
169
|
end
|
|
148
170
|
|
|
149
171
|
score = (100 * Math.exp(-weighted_penalty / SCORE_DECAY_CONSTANT)).round
|
|
@@ -368,7 +390,8 @@ module Scryer
|
|
|
368
390
|
</div>
|
|
369
391
|
<div class="score-details">
|
|
370
392
|
<p class="score-label">Security Score — #{score["finding_count"]} security + dependency
|
|
371
|
-
finding(s)
|
|
393
|
+
+ performance finding(s) (performance weighted far lighter than security), weighted
|
|
394
|
+
by severity and this rule's confidence. Not normalized by app
|
|
372
395
|
size — see the README for what this number does and doesn't mean.
|
|
373
396
|
<strong>#{clean_rate["clean"]}/#{clean_rate["total"]}</strong> rules clean
|
|
374
397
|
(#{clean_rate["percent"]}%) — a rule-level pass rate, a different (and not always
|
|
@@ -399,7 +422,16 @@ module Scryer
|
|
|
399
422
|
sec_counts = count_by_severity(security)
|
|
400
423
|
perf_counts = count_by_severity(performance)
|
|
401
424
|
style_counts = count_by_severity(style)
|
|
402
|
-
|
|
425
|
+
# Dependency findings carry a real severity (see DependencyAudit::
|
|
426
|
+
# Finding#severity) same as any other finding — previously left out of
|
|
427
|
+
# this row (rendered as a "—" placeholder) and out of the Total row's
|
|
428
|
+
# sum below, which made the Total row silently undercount relative to
|
|
429
|
+
# what a reader would expect from a row literally labeled "Total" at
|
|
430
|
+
# the bottom of this same table, and inconsistent with the executive
|
|
431
|
+
# summary's severity bars above (which do include dependency findings
|
|
432
|
+
# in their own count).
|
|
433
|
+
deps_counts = count_by_severity(dependency_findings)
|
|
434
|
+
total_counts = SEVERITY_ORDER.each_with_object({}) { |s, acc| acc[s] = sec_counts[s] + perf_counts[s] + style_counts[s] + deps_counts[s] }
|
|
403
435
|
|
|
404
436
|
header = "<tr><th>Category</th>" + SEVERITY_ORDER.map { |s| "<th>#{SEVERITY_LABELS[s]}</th>" }.join + "<th>Total</th></tr>"
|
|
405
437
|
# Category rows link via the same search-filter mechanism as the OWASP
|
|
@@ -411,13 +443,20 @@ module Scryer
|
|
|
411
443
|
perf_row = summary_row("Performance", perf_counts, filter_term: "performance")
|
|
412
444
|
style_row = summary_row("Style", style_counts, filter_term: "style")
|
|
413
445
|
# The Total row has no single category tag to filter by (it's the sum
|
|
414
|
-
# across all
|
|
446
|
+
# across all four), so its per-severity cells link straight to the
|
|
415
447
|
# matching #sev-* heading instead — same anchors, same precision, as
|
|
416
|
-
# the severity distribution chart at the top of the report.
|
|
448
|
+
# the severity distribution chart at the top of the report. Same
|
|
449
|
+
# caveat that chart's own comment already notes: #sev-* only groups
|
|
450
|
+
# security/performance/style findings, so a reader following this link
|
|
451
|
+
# for a severity that's only present via a dependency finding won't
|
|
452
|
+
# see it highlighted there — still the right destination for the bulk
|
|
453
|
+
# of what's counted, and dependency findings are one section away via
|
|
454
|
+
# the Dependency audit row's own link just below.
|
|
417
455
|
total_row = summary_row("Total", total_counts, css_class: "total", severity_anchors: true)
|
|
418
456
|
dup_row = "<tr><th>Duplicate code</th><td colspan=\"#{SEVERITY_ORDER.size}\">—</td>" \
|
|
419
457
|
"<td><a class=\"jump-link\" href=\"#duplicates\">#{duplicate_groups.size} group(s)</a></td></tr>"
|
|
420
|
-
|
|
458
|
+
deps_cells = SEVERITY_ORDER.map { |s| "<td>#{deps_counts[s]}</td>" }.join
|
|
459
|
+
deps_row = "<tr><th>Dependency audit</th>#{deps_cells}" \
|
|
421
460
|
"<td><a class=\"jump-link\" href=\"#dependency-audit\">#{dependency_findings.size} finding(s)</a></td></tr>"
|
|
422
461
|
|
|
423
462
|
"<table class=\"summary\">#{header}#{sec_row}#{perf_row}#{style_row}#{dup_row}#{deps_row}#{total_row}</table>"
|
data/lib/scryer/scanner.rb
CHANGED
|
@@ -31,10 +31,19 @@ module Scryer
|
|
|
31
31
|
# `skip_rules` silences specific checks by rule_id (e.g. a known false
|
|
32
32
|
# positive on this codebase) without editing/removing the rule itself —
|
|
33
33
|
# accepts strings or symbols, matched against Rule.rule_id.
|
|
34
|
-
|
|
34
|
+
#
|
|
35
|
+
# `detect_duplicates: false` skips duplicate-code detection entirely
|
|
36
|
+
# (method/query/cache-key extraction and the DuplicateDetector passes
|
|
37
|
+
# below) — unlike the security/performance/style rules, duplicate
|
|
38
|
+
# detection isn't a `Scryer::Rule` with its own rule_id, so `skip_rules`
|
|
39
|
+
# has no way to address it; this is its equivalent off switch. See
|
|
40
|
+
# `Scryer::Configuration#detect_duplicates` for the config-driven default
|
|
41
|
+
# every CLI/rake entry point reads before constructing a Scanner.
|
|
42
|
+
def initialize(root:, dirs: DEFAULT_GLOB_DIRS, skip_rules: [], detect_duplicates: true)
|
|
35
43
|
@root = File.expand_path(root)
|
|
36
44
|
@dirs = dirs
|
|
37
45
|
@skip_rules = Set.new(skip_rules.map(&:to_s))
|
|
46
|
+
@detect_duplicates = detect_duplicates
|
|
38
47
|
end
|
|
39
48
|
|
|
40
49
|
def call
|
|
@@ -77,24 +86,28 @@ module Scryer
|
|
|
77
86
|
bucket.concat(rule_class.new(file: rel_path, source: source, sexp: sexp).scan)
|
|
78
87
|
end
|
|
79
88
|
|
|
80
|
-
if duplicate_detection_target?(rel_path)
|
|
89
|
+
if @detect_duplicates && duplicate_detection_target?(rel_path)
|
|
81
90
|
all_methods.concat(MethodExtractor.extract(file: rel_path, source: source, sexp: sexp))
|
|
82
91
|
all_queries.concat(QueryExtractor.extract(file: rel_path, source: source, sexp: sexp))
|
|
83
92
|
all_cache_calls.concat(CacheExtractor.extract(file: rel_path, source: source, sexp: sexp))
|
|
84
93
|
end
|
|
85
94
|
end
|
|
86
95
|
|
|
87
|
-
# Same computed value cached under the same key from multiple call
|
|
88
|
-
# sites is normal (just reusing the cache). Only flag it when the
|
|
89
|
-
# *keys* differ too — that's either a redundant cache entry or a key
|
|
90
|
-
# that drifted out of sync with a copy-pasted sibling.
|
|
91
|
-
cache_groups = DuplicateDetector.call(all_cache_calls, threshold: CACHE_SIMILARITY_THRESHOLD, kind: "cache_duplicate")
|
|
92
|
-
.select { |g| g.members.map(&:cache_key).uniq.size > 1 }
|
|
93
|
-
|
|
94
96
|
duplicate_groups =
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
97
|
+
if @detect_duplicates
|
|
98
|
+
# Same computed value cached under the same key from multiple call
|
|
99
|
+
# sites is normal (just reusing the cache). Only flag it when the
|
|
100
|
+
# *keys* differ too — that's either a redundant cache entry or a
|
|
101
|
+
# key that drifted out of sync with a copy-pasted sibling.
|
|
102
|
+
cache_groups = DuplicateDetector.call(all_cache_calls, threshold: CACHE_SIMILARITY_THRESHOLD, kind: "cache_duplicate")
|
|
103
|
+
.select { |g| g.members.map(&:cache_key).uniq.size > 1 }
|
|
104
|
+
|
|
105
|
+
DuplicateDetector.call(all_methods, kind: "method_duplicate") +
|
|
106
|
+
DuplicateDetector.call(all_queries, threshold: QUERY_SIMILARITY_THRESHOLD, kind: "query_duplicate") +
|
|
107
|
+
cache_groups
|
|
108
|
+
else
|
|
109
|
+
[]
|
|
110
|
+
end
|
|
98
111
|
|
|
99
112
|
Result.new(
|
|
100
113
|
security_findings: security_findings,
|
data/lib/scryer/version.rb
CHANGED
data/lib/scryer.rb
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
require "scryer/version"
|
|
2
|
+
require "scryer/colorizer"
|
|
2
3
|
require "scryer/ast"
|
|
3
4
|
require "scryer/finding"
|
|
4
5
|
require "scryer/rule_set"
|
|
@@ -13,7 +14,10 @@ require "scryer/dependency_audit"
|
|
|
13
14
|
require "scryer/baseline"
|
|
14
15
|
require "scryer/ai_client"
|
|
15
16
|
require "scryer/fix_verifier"
|
|
17
|
+
require "scryer/mechanical_fixer"
|
|
16
18
|
require "scryer/ai_fix_suggester"
|
|
19
|
+
require "scryer/fix_runner"
|
|
20
|
+
require "scryer/dependency_fixer"
|
|
17
21
|
|
|
18
22
|
Dir[File.join(__dir__, "scryer", "rules", "*.rb")].sort.each { |f| require f }
|
|
19
23
|
Dir[File.join(__dir__, "scryer", "performance_rules", "*.rb")].sort.each { |f| require f }
|
|
@@ -39,11 +43,21 @@ module Scryer
|
|
|
39
43
|
# default: every registered rule runs. The `scryer` executable's
|
|
40
44
|
# `--skip RULE_ID` flag adds to this list for a single run rather than
|
|
41
45
|
# replacing it.
|
|
42
|
-
|
|
46
|
+
#
|
|
47
|
+
# `detect_duplicates` toggles duplicate-code detection (method/query/
|
|
48
|
+
# cache-key similarity across models, controllers, helpers, and
|
|
49
|
+
# concerns — see Scryer::DuplicateDetector) on or off. `true` by
|
|
50
|
+
# default, matching this gem's existing behavior. Duplicate detection
|
|
51
|
+
# isn't a `Scryer::Rule`, so it has no `rule_id` and `skip_rules` can't
|
|
52
|
+
# address it — set this to `false` instead (or pass `--no-duplicates` /
|
|
53
|
+
# `SCRYER_NO_DUPLICATES=1` for a single run without changing the
|
|
54
|
+
# configured default) if it's too noisy or too slow for a given project.
|
|
55
|
+
attr_accessor :project_name, :dirs, :branch, :ai_client, :skip_rules, :detect_duplicates
|
|
43
56
|
|
|
44
57
|
def initialize
|
|
45
58
|
@dirs = Scryer::Scanner::DEFAULT_GLOB_DIRS
|
|
46
59
|
@skip_rules = []
|
|
60
|
+
@detect_duplicates = true
|
|
47
61
|
end
|
|
48
62
|
end
|
|
49
63
|
|
|
@@ -65,8 +79,8 @@ module Scryer
|
|
|
65
79
|
# task aren't changed to use this (they also handle dependency auditing,
|
|
66
80
|
# baselines, and report writing inline) — this is for callers that only
|
|
67
81
|
# need the static-scan Result itself.
|
|
68
|
-
def scan(root:, dirs: configuration.dirs, skip_rules: configuration.skip_rules)
|
|
69
|
-
Scryer::Scanner.new(root: root, dirs: dirs, skip_rules: skip_rules).call
|
|
82
|
+
def scan(root:, dirs: configuration.dirs, skip_rules: configuration.skip_rules, detect_duplicates: configuration.detect_duplicates)
|
|
83
|
+
Scryer::Scanner.new(root: root, dirs: dirs, skip_rules: skip_rules, detect_duplicates: detect_duplicates).call
|
|
70
84
|
end
|
|
71
85
|
end
|
|
72
86
|
end
|