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
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
module Scryer
|
|
2
|
+
# Hand-rolled ANSI color/style helper for console output — deliberately
|
|
3
|
+
# not a gem dependency (`pastel`, `colorize`, ...), consistent with
|
|
4
|
+
# Scryer's zero-runtime-dependency design (see the gemspec).
|
|
5
|
+
#
|
|
6
|
+
# Off by default whenever it would be wrong to color: piped/redirected
|
|
7
|
+
# output, `NO_COLOR` set (https://no-color.org), or a dumb terminal
|
|
8
|
+
# (`TERM=dumb`). On by default at a real terminal otherwise. An explicit
|
|
9
|
+
# `--no-color`/`--color` flag (CLI) or `SCRYER_NO_COLOR`/`SCRYER_COLOR` env
|
|
10
|
+
# var (rake) always wins over all of that, in either direction — a flag
|
|
11
|
+
# typed for this one invocation is more specific than a session-wide env
|
|
12
|
+
# var, the same precedence ripgrep/eslint use for their own --color flags.
|
|
13
|
+
module Colorizer
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
CODES = { red: 31, green: 32, yellow: 33, cyan: 36, gray: 90, bold: 1 }.freeze
|
|
17
|
+
|
|
18
|
+
# `override`: true/false forces color on/off regardless of TTY/NO_COLOR
|
|
19
|
+
# (an explicit --color/--no-color flag or SCRYER_COLOR/SCRYER_NO_COLOR
|
|
20
|
+
# env var); nil (the default) means "decide automatically."
|
|
21
|
+
def enabled?(stream = $stdout, override: nil)
|
|
22
|
+
return override unless override.nil?
|
|
23
|
+
return false unless ENV["NO_COLOR"].to_s.empty?
|
|
24
|
+
return false if ENV["TERM"] == "dumb"
|
|
25
|
+
|
|
26
|
+
stream.respond_to?(:tty?) && stream.tty?
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def paint(text, *styles, stream: $stdout, override: nil)
|
|
30
|
+
return text.to_s unless enabled?(stream, override: override)
|
|
31
|
+
|
|
32
|
+
codes = styles.map { |s| CODES.fetch(s) }.join(";")
|
|
33
|
+
"\e[#{codes}m#{text}\e[0m"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def severity(text, severity, **opts)
|
|
37
|
+
case severity.to_s
|
|
38
|
+
when "critical" then paint(text, :red, :bold, **opts)
|
|
39
|
+
when "warning" then paint(text, :yellow, **opts)
|
|
40
|
+
when "info" then paint(text, :cyan, **opts)
|
|
41
|
+
else text.to_s
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# A/B/C/D/F — the security score's letter grade (see
|
|
46
|
+
# ReportRenderer#security_score).
|
|
47
|
+
def grade(text, letter, **opts)
|
|
48
|
+
case letter.to_s
|
|
49
|
+
when "A", "B" then paint(text, :green, :bold, **opts)
|
|
50
|
+
when "C" then paint(text, :yellow, :bold, **opts)
|
|
51
|
+
when "D", "F" then paint(text, :red, :bold, **opts)
|
|
52
|
+
else text.to_s
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
require "open3"
|
|
2
|
+
|
|
3
|
+
module Scryer
|
|
4
|
+
# `scryer fix --deps` — the dependency-audit analog of the rule-based
|
|
5
|
+
# `scryer fix`, and a separate small pipeline from it: a
|
|
6
|
+
# `DependencyAudit::Finding` has no `.line`/`.file`/`.rule_id` (see its
|
|
7
|
+
# Struct in dependency_audit.rb), so none of FixRunner/MechanicalFixer/
|
|
8
|
+
# FixVerifier — all built around rewriting a single source line and
|
|
9
|
+
# re-running one Ripper-based rule against it — apply here at all. This
|
|
10
|
+
# module exists for the one kind of dependency finding that has an
|
|
11
|
+
# unambiguous, mechanically-applicable fix: a gem with a published patched
|
|
12
|
+
# version to upgrade to.
|
|
13
|
+
#
|
|
14
|
+
# Only acts on `kind: "vulnerable_dependency"` findings that have at least
|
|
15
|
+
# one published patched version — DependencyAudit already leaves
|
|
16
|
+
# `patched_versions` empty when OSV has no fix yet, so there's nothing to
|
|
17
|
+
# bump to; those, along with `insecure_source` / `ruby_eol` /
|
|
18
|
+
# `credentials_exposure` findings (none of which "bundle update" can
|
|
19
|
+
# address — a source URL, a Ruby interpreter version, and a gitignore rule
|
|
20
|
+
# aren't gem versions), are always left for manual review.
|
|
21
|
+
module DependencyFixer
|
|
22
|
+
module_function
|
|
23
|
+
|
|
24
|
+
# Groups findings by gem_name (a gem can have more than one open
|
|
25
|
+
# advisory) and runs `bundle update GEM --conservative` once per gem,
|
|
26
|
+
# then re-queries OSV.dev for that one gem to confirm the bump actually
|
|
27
|
+
# cleared every advisory it had — `--conservative` tries to avoid moving
|
|
28
|
+
# anything beyond what's needed, but a version constraint elsewhere in
|
|
29
|
+
# the Gemfile can still leave a gem on an old, still-vulnerable version.
|
|
30
|
+
# This re-check is what turns "the command exited 0" into "verified,"
|
|
31
|
+
# the same verify-before-trusting discipline FixRunner/FixVerifier use
|
|
32
|
+
# for rule-based fixes — a real command actually ran and actually
|
|
33
|
+
# changed Gemfile.lock, so trusting its exit code alone isn't enough.
|
|
34
|
+
#
|
|
35
|
+
# Returns [fixed, skipped] (plain arrays of DependencyAudit::Finding).
|
|
36
|
+
# Yields (finding, status, error) to the given block as each gem is
|
|
37
|
+
# resolved — status is :fixed, :would_fix (dry_run), :error (the bundle
|
|
38
|
+
# command itself failed — `error` is its captured output), or :skipped
|
|
39
|
+
# (no patched version published, or the re-check still shows it
|
|
40
|
+
# vulnerable). `runner` and `recheck` are injectable for testing, so this
|
|
41
|
+
# is exercisable without a real Bundler process or network call.
|
|
42
|
+
def apply(findings, root:, dry_run: false, runner: method(:run_bundle_update),
|
|
43
|
+
recheck: method(:default_recheck), &on_result)
|
|
44
|
+
fixed = []
|
|
45
|
+
skipped = []
|
|
46
|
+
|
|
47
|
+
actionable, unfixable = findings.select { |f| f.kind == "vulnerable_dependency" }
|
|
48
|
+
.partition { |f| Array(f.patched_versions).any? }
|
|
49
|
+
|
|
50
|
+
unfixable.each do |finding|
|
|
51
|
+
skipped << finding
|
|
52
|
+
on_result&.call(finding, :skipped, nil)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
actionable.group_by(&:gem_name).each_value do |gem_findings|
|
|
56
|
+
gem_name = gem_findings.first.gem_name
|
|
57
|
+
|
|
58
|
+
if dry_run
|
|
59
|
+
fixed.concat(gem_findings)
|
|
60
|
+
gem_findings.each { |f| on_result&.call(f, :would_fix, nil) }
|
|
61
|
+
next
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
success, output = runner.call(gem_name, root)
|
|
65
|
+
|
|
66
|
+
if !success
|
|
67
|
+
skipped.concat(gem_findings)
|
|
68
|
+
gem_findings.each { |f| on_result&.call(f, :error, output) }
|
|
69
|
+
next
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
if recheck.call(root, gem_name)
|
|
73
|
+
skipped.concat(gem_findings)
|
|
74
|
+
message = "bundle update #{gem_name} --conservative ran, but #{gem_name} is still flagged " \
|
|
75
|
+
"(no resolvable fixed version given the current Gemfile constraints, or a " \
|
|
76
|
+
"different advisory applies to the version it landed on) — check manually."
|
|
77
|
+
gem_findings.each { |f| on_result&.call(f, :skipped, message) }
|
|
78
|
+
else
|
|
79
|
+
fixed.concat(gem_findings)
|
|
80
|
+
gem_findings.each { |f| on_result&.call(f, :fixed, nil) }
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
[fixed, skipped]
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def run_bundle_update(gem_name, root)
|
|
88
|
+
stdout, stderr, status = Open3.capture3("bundle", "update", gem_name, "--conservative", chdir: root)
|
|
89
|
+
[status.success?, status.success? ? stdout : "#{stdout}\n#{stderr}".strip]
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def default_recheck(root, gem_name)
|
|
93
|
+
DependencyAudit.vulnerable_gems(root).any? { |f| f.gem_name == gem_name }
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
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,161 @@
|
|
|
1
|
+
require "set"
|
|
2
|
+
|
|
3
|
+
module Scryer
|
|
4
|
+
# The "apply verified AI fixes to real files" loop, shared behind both
|
|
5
|
+
# `scryer fix` (CLI) and `rails scryer:fix` (rake task) — the actual
|
|
6
|
+
# file-writing logic lives here exactly once; each caller formats its own
|
|
7
|
+
# console output around it (same house style as `ScryerTasks`/`CLI`
|
|
8
|
+
# already duplicating summary-printing methods rather than sharing them —
|
|
9
|
+
# see lib/tasks/scryer.rake).
|
|
10
|
+
module FixRunner
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
# Groups by file and processes each file's findings from the highest
|
|
14
|
+
# line number down — replacing a later line first means every not-yet-
|
|
15
|
+
# processed line number earlier in the same file stays valid even when a
|
|
16
|
+
# fix's replacement spans a different number of lines than the original
|
|
17
|
+
# one. (Two distinct findings landing on the exact same line in the same
|
|
18
|
+
# file is a narrow, unhandled-further edge case — FixVerifier.apply!
|
|
19
|
+
# always re-verifies against the file's current on-disk content right
|
|
20
|
+
# before writing, so it can't silently apply a fix that no longer
|
|
21
|
+
# actually matches what's there, but the second finding on that line may
|
|
22
|
+
# end up needing a second fix pass to resolve.)
|
|
23
|
+
#
|
|
24
|
+
# Returns [fixed, skipped] — both plain arrays of Finding (a
|
|
25
|
+
# user-declined finding, when `confirm` is given, lands in `skipped`
|
|
26
|
+
# too — it was verified but deliberately not written, same bucket as
|
|
27
|
+
# "not written" for any other reason). Yields (finding, status, error)
|
|
28
|
+
# to the given block as each candidate is resolved — status is :fixed,
|
|
29
|
+
# :would_fix (dry_run), :declined (confirm said no), :ai_error (the
|
|
30
|
+
# ai_client raised — `error` is that exception; nil for every other
|
|
31
|
+
# status), :cancelled (confirm said stop — see below), or :skipped — so
|
|
32
|
+
# a caller (see CLI#print_fix_progress / ScryerTasks#print_fix_progress)
|
|
33
|
+
# can print progress as it happens instead of only a summary once
|
|
34
|
+
# everything's done. Blocks in Ruby don't enforce arity, so an existing
|
|
35
|
+
# `{ |finding, status| ... }` callback still works fine and just never
|
|
36
|
+
# sees the third arg. The block is entirely optional; omitting it
|
|
37
|
+
# changes nothing else.
|
|
38
|
+
#
|
|
39
|
+
# `confirm`, when given, is called with each independently-verified
|
|
40
|
+
# finding right before it would be written (or counted as "would fix",
|
|
41
|
+
# under dry_run) — returning false skips it without writing, and
|
|
42
|
+
# returning the symbol `:cancel` stops entirely: every remaining
|
|
43
|
+
# candidate (including the current one) is marked :cancelled without
|
|
44
|
+
# `confirm` being asked about any of them again — and, critically,
|
|
45
|
+
# without spending an AiFixSuggester call or a MechanicalFixer/
|
|
46
|
+
# FixVerifier pass on any of them either, since "cancel" means stop
|
|
47
|
+
# working, not just "don't write what's already been verified." This is
|
|
48
|
+
# the model-layer half of `scryer fix`'s per-finding "yes / skip / yes
|
|
49
|
+
# to all remaining / cancel" review; the actual prompt (reading stdin,
|
|
50
|
+
# tracking a latched "yes to all" choice across calls) lives in the
|
|
51
|
+
# caller (CLI#build_fix_confirmer / ScryerTasks) so this stays testable
|
|
52
|
+
# without a real terminal. Omitting `confirm` (the default) applies
|
|
53
|
+
# every verified fix automatically, same as before this option existed.
|
|
54
|
+
def apply(candidates, client:, root:, dry_run: false, confirm: nil, &on_result)
|
|
55
|
+
fixed = []
|
|
56
|
+
skipped = []
|
|
57
|
+
cancelled = false
|
|
58
|
+
|
|
59
|
+
candidates.group_by(&:file).each_value do |findings_in_file|
|
|
60
|
+
findings_in_file.sort_by { |f| -(f.line || 0) }.each do |finding|
|
|
61
|
+
if cancelled
|
|
62
|
+
skipped << finding
|
|
63
|
+
on_result&.call(finding, :cancelled, nil)
|
|
64
|
+
next
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# An ai_client, when configured, is tried first for every rule —
|
|
68
|
+
# including the ones a mechanical fixer could also handle — so a
|
|
69
|
+
# "real developer" fix (context-aware, not just the one
|
|
70
|
+
# mechanically-derivable rewrite) is what gets written whenever
|
|
71
|
+
# AI is actually available. The deterministic mechanical fixer is
|
|
72
|
+
# the fallback: it runs whenever AI isn't configured at all,
|
|
73
|
+
# declined to produce anything usable, raised, or its rewrite
|
|
74
|
+
# didn't independently verify — same safety gate either way, so
|
|
75
|
+
# neither path is ever trusted more than the other.
|
|
76
|
+
ai_error = nil
|
|
77
|
+
AiFixSuggester.enhance!(finding, client: client, root: root, on_error: ->(_f, e) { ai_error = e }) if client
|
|
78
|
+
|
|
79
|
+
if finding.fix_verified != true
|
|
80
|
+
mechanical_fix = MechanicalFixer.suggest(finding, root: root)
|
|
81
|
+
if mechanical_fix
|
|
82
|
+
finding.suggested_fix = mechanical_fix
|
|
83
|
+
finding.fix_verified = FixVerifier.verify(finding: finding, root: root)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
if finding.fix_verified != true
|
|
88
|
+
skipped << finding
|
|
89
|
+
on_result&.call(finding, ai_error ? :ai_error : :skipped, ai_error)
|
|
90
|
+
next
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
if confirm
|
|
94
|
+
decision = confirm.call(finding)
|
|
95
|
+
if decision == :cancel
|
|
96
|
+
cancelled = true
|
|
97
|
+
skipped << finding
|
|
98
|
+
on_result&.call(finding, :cancelled, nil)
|
|
99
|
+
next
|
|
100
|
+
elsif !decision
|
|
101
|
+
skipped << finding
|
|
102
|
+
on_result&.call(finding, :declined)
|
|
103
|
+
next
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
if dry_run
|
|
108
|
+
fixed << finding
|
|
109
|
+
on_result&.call(finding, :would_fix)
|
|
110
|
+
elsif FixVerifier.apply!(finding: finding, root: root)
|
|
111
|
+
fixed << finding
|
|
112
|
+
on_result&.call(finding, :fixed)
|
|
113
|
+
else
|
|
114
|
+
skipped << finding
|
|
115
|
+
on_result&.call(finding, :skipped)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
[fixed, skipped]
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Best-effort short human explanation of a fix — the first couple of
|
|
124
|
+
# sentences of the AI's own reply, before its code block(s)/AFTER:
|
|
125
|
+
# marker. AiFixSuggester's prompt asks the model to lead with 1-3
|
|
126
|
+
# sentences of plain-English explanation before any code, so this is
|
|
127
|
+
# just trimming that reply down to something that reads well as a single
|
|
128
|
+
# console line, not a new source of information. Returns "" (never nil)
|
|
129
|
+
# when there's nothing usable to show.
|
|
130
|
+
def explain(suggested_fix, max_sentences: 2)
|
|
131
|
+
text = suggested_fix.to_s.strip
|
|
132
|
+
return "" if text.empty?
|
|
133
|
+
|
|
134
|
+
cut_at = [text.index("```"), text.index(/^AFTER:/m)].compact.min
|
|
135
|
+
text = text[0...cut_at] if cut_at
|
|
136
|
+
text.strip.split(/(?<=[.!?])\s+/).first(max_sentences).join(" ").strip
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Re-scans `root` and returns the subset of `fixed` that still shows up
|
|
140
|
+
# (matched by fingerprint — rule + file + offending code, not line
|
|
141
|
+
# number, since every fixed line's line number just changed) — normally
|
|
142
|
+
# empty. FixVerifier.apply! only re-checks the single rule against the
|
|
143
|
+
# single file it just edited in isolation; this is the broader,
|
|
144
|
+
# whole-project confirmation that no interaction between edits (two
|
|
145
|
+
# fixes in the same file, one fix's line-count change shifting another
|
|
146
|
+
# finding's line number) left anything still firing.
|
|
147
|
+
def verify(fixed, root:, dirs:, skip_rules:)
|
|
148
|
+
# detect_duplicates: false — only security/performance/style findings
|
|
149
|
+
# are read below; duplicate_groups are never part of `fixed`
|
|
150
|
+
# (duplicate-code groups aren't Findings — see Scanner#initialize's
|
|
151
|
+
# comment on detect_duplicates), so there's nothing to gain from that
|
|
152
|
+
# pass here.
|
|
153
|
+
rescanned = Scanner.new(root: root, dirs: dirs, skip_rules: skip_rules, detect_duplicates: false).call
|
|
154
|
+
still_present = Baseline.fingerprints(
|
|
155
|
+
(rescanned.security_findings + rescanned.performance_findings + rescanned.style_findings).map(&:to_h)
|
|
156
|
+
).to_set
|
|
157
|
+
|
|
158
|
+
fixed.select { |f| still_present.include?(Baseline.fingerprint(f.to_h)) }
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
@@ -0,0 +1,169 @@
|
|
|
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
|
+
#
|
|
32
|
+
# `apply!` below is the one place in this whole gem that ever writes an
|
|
33
|
+
# AI-generated fix to a real file — and even there, only after this exact
|
|
34
|
+
# same in-memory check says the fix genuinely clears the finding. See
|
|
35
|
+
# `scryer fix` (lib/scryer/cli.rb) for the only caller; nothing else in
|
|
36
|
+
# this gem calls it, including the ai_client-enhancement path a normal
|
|
37
|
+
# scan/report run uses (AiFixSuggester#enhance!), which only ever rewrites
|
|
38
|
+
# `finding.suggested_fix` text, never touches disk.
|
|
39
|
+
module FixVerifier
|
|
40
|
+
module_function
|
|
41
|
+
|
|
42
|
+
AFTER_BLOCK = /AFTER:\s*```\w*\n(.*?)\n?```/m.freeze
|
|
43
|
+
|
|
44
|
+
def verify(finding:, root:)
|
|
45
|
+
verify_with_source(finding: finding, root: root).first
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Writes the verified fix to the real file and returns true, or returns
|
|
49
|
+
# the same false/nil `verify` would return without writing anything.
|
|
50
|
+
# Re-derives the verification (rather than trusting a `fix_verified`
|
|
51
|
+
# value computed earlier) so there's no window between "we checked" and
|
|
52
|
+
# "we wrote" where the file could have changed out from under it.
|
|
53
|
+
def apply!(finding:, root:)
|
|
54
|
+
verified, modified_source, abs_path = verify_with_source(finding: finding, root: root)
|
|
55
|
+
return verified unless verified == true
|
|
56
|
+
|
|
57
|
+
File.write(abs_path, modified_source)
|
|
58
|
+
true
|
|
59
|
+
rescue StandardError
|
|
60
|
+
nil
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# [verified, modified_source, abs_path] — modified_source/abs_path are
|
|
64
|
+
# nil whenever verified isn't true (nothing for a caller to write in
|
|
65
|
+
# that case anyway).
|
|
66
|
+
def verify_with_source(finding:, root:)
|
|
67
|
+
return [nil, nil, nil] unless finding.is_a?(Scryer::Finding)
|
|
68
|
+
return [nil, nil, nil] unless finding.line && finding.rule_id
|
|
69
|
+
|
|
70
|
+
after_snippet = extract_after_snippet(finding.suggested_fix)
|
|
71
|
+
return [nil, nil, nil] unless after_snippet
|
|
72
|
+
|
|
73
|
+
abs_path = File.join(root, finding.file.to_s)
|
|
74
|
+
return [nil, nil, nil] unless File.file?(abs_path)
|
|
75
|
+
|
|
76
|
+
lines = File.read(abs_path).lines
|
|
77
|
+
return [nil, nil, nil] unless finding.line.between?(1, lines.size)
|
|
78
|
+
|
|
79
|
+
modified_source = apply_line_replacement(lines, finding.line, after_snippet)
|
|
80
|
+
|
|
81
|
+
sexp = begin
|
|
82
|
+
Ripper.sexp(modified_source)
|
|
83
|
+
rescue StandardError
|
|
84
|
+
nil
|
|
85
|
+
end
|
|
86
|
+
return [false, nil, nil] if sexp.nil? # the rewritten line doesn't even parse — not a usable fix
|
|
87
|
+
|
|
88
|
+
rule_class = Scryer::RuleSet.all.find { |r| r.rule_id == finding.rule_id }
|
|
89
|
+
return [nil, nil, nil] unless rule_class
|
|
90
|
+
|
|
91
|
+
remaining = rule_class.new(file: finding.file, source: modified_source, sexp: sexp).scan
|
|
92
|
+
|
|
93
|
+
# Matched by rule_id + code_snippet (same identity Baseline fingerprints
|
|
94
|
+
# use), NOT just rule_id — a file with two separate sql_injection
|
|
95
|
+
# findings must let each be verified independently. Checking only
|
|
96
|
+
# rule_id here would mean neither ever verifies, since fixing one line
|
|
97
|
+
# in isolation always leaves the other (still-unfixed) occurrence
|
|
98
|
+
# showing up in `remaining`.
|
|
99
|
+
target_snippet = finding.code_snippet.to_s.strip
|
|
100
|
+
rule_cleared = remaining.none? { |f| f.rule_id == finding.rule_id && f.code_snippet.to_s.strip == target_snippet }
|
|
101
|
+
verified = rule_cleared && !introduces_undefined_params_helper?(finding.code_snippet, after_snippet, modified_source)
|
|
102
|
+
verified ? [true, modified_source, abs_path] : [false, nil, nil]
|
|
103
|
+
rescue StandardError
|
|
104
|
+
[nil, nil, nil]
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
PARAMS_HELPER_NAME = /\b([a-z_][a-zA-Z0-9_]*_params)\b/.freeze
|
|
108
|
+
|
|
109
|
+
# A common, real failure mode found in production: mass_assignment's own
|
|
110
|
+
# suggested_fix (and the AI prompt built from it) recommends extracting a
|
|
111
|
+
# strong-parameters helper — "wrap in `order_params`, with `def
|
|
112
|
+
# order_params; params.require(...).permit(...); end`" — but `AFTER:` can
|
|
113
|
+
# only ever replace the single flagged line, never add a method
|
|
114
|
+
# definition elsewhere in the file. An AI reply that takes this approach
|
|
115
|
+
# ends up calling a helper (e.g. `create_charge_params[:account_id]`)
|
|
116
|
+
# that was never actually defined anywhere — syntactically valid Ruby, so
|
|
117
|
+
# it parses fine, and the mass_assignment rule stops firing (the line no
|
|
118
|
+
# longer references `params` directly), so this "verifies" clean by
|
|
119
|
+
# every check above... and then raises NoMethodError the moment it
|
|
120
|
+
# actually runs. Only flags a name that's *new* in this rewrite (already
|
|
121
|
+
# present in the original flagged line means it's not this fix's doing,
|
|
122
|
+
# and may well be defined in a parent class/concern this per-file check
|
|
123
|
+
# can't see) and has no matching `def` anywhere in the file. Like every
|
|
124
|
+
# other heuristic here, this can false-positive (a legitimately
|
|
125
|
+
# inherited helper looks identical to a hallucinated one from a single
|
|
126
|
+
# file's contents) — declining a fix that would have been fine is the
|
|
127
|
+
# safe direction to err in; writing one that crashes at runtime is not.
|
|
128
|
+
def introduces_undefined_params_helper?(original_line, after_snippet, full_source)
|
|
129
|
+
original_names = original_line.to_s.scan(PARAMS_HELPER_NAME).flatten
|
|
130
|
+
new_names = after_snippet.to_s.scan(PARAMS_HELPER_NAME).flatten.uniq - original_names
|
|
131
|
+
|
|
132
|
+
new_names.any? { |name| !full_source.match?(/\bdef\s+#{Regexp.escape(name)}\b/) }
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def extract_after_snippet(suggested_fix)
|
|
136
|
+
match = AFTER_BLOCK.match(suggested_fix.to_s)
|
|
137
|
+
return nil unless match
|
|
138
|
+
|
|
139
|
+
content = match[1].to_s
|
|
140
|
+
content.strip.empty? ? nil : content
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# An AI reply's AFTER: block is asked for "a drop-in replacement for the
|
|
144
|
+
# single offending line" — in practice, models frequently reply with
|
|
145
|
+
# that replacement flush against the left margin, dropping the original
|
|
146
|
+
# line's indentation entirely (confirmed against a real AI-generated fix
|
|
147
|
+
# in production). Ruby doesn't care, so this was never a *correctness*
|
|
148
|
+
# bug, but a fix that silently de-indents a line looks nothing like what
|
|
149
|
+
# a developer would actually commit. Restores the original line's
|
|
150
|
+
# leading whitespace onto the replacement's first line specifically —
|
|
151
|
+
# only when the replacement doesn't already start with any indentation
|
|
152
|
+
# of its own, so a reply that already got it right (or a multi-line
|
|
153
|
+
# reply whose later lines carry their own deliberate relative indent)
|
|
154
|
+
# is left alone.
|
|
155
|
+
def apply_line_replacement(lines, line_number, replacement)
|
|
156
|
+
original_indent = lines[line_number - 1].to_s[/\A[ \t]*/]
|
|
157
|
+
replacement_lines = replacement.lines
|
|
158
|
+
if replacement_lines.first && replacement_lines.first !~ /\A[ \t]/
|
|
159
|
+
replacement_lines[0] = "#{original_indent}#{replacement_lines[0]}"
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
replacement_text = replacement_lines.join
|
|
163
|
+
replacement_text = "#{replacement_text}\n" unless replacement_text.end_with?("\n")
|
|
164
|
+
modified = lines.dup
|
|
165
|
+
modified[line_number - 1] = replacement_text
|
|
166
|
+
modified.join
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|