scryer 1.0.0 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +253 -0
- data/README.md +459 -41
- data/lib/generators/scryer/USAGE +10 -2
- data/lib/generators/scryer/templates/scryer_initializer.rb +15 -0
- data/lib/scryer/ai_fix_suggester.rb +29 -9
- data/lib/scryer/ast.rb +26 -0
- data/lib/scryer/authorization_watcher.rb +156 -0
- data/lib/scryer/baseline.rb +75 -0
- data/lib/scryer/cli.rb +189 -3
- data/lib/scryer/finding.rb +6 -0
- data/lib/scryer/fix_verifier.rb +82 -0
- data/lib/scryer/minitest.rb +48 -0
- data/lib/scryer/performance_rules/inefficient_save_loop_rule.rb +32 -0
- data/lib/scryer/performance_rules/missing_pagination_rule.rb +1 -0
- data/lib/scryer/performance_rules/n_plus_one_query_rule.rb +1 -0
- data/lib/scryer/performance_rules/unbounded_table_scan_rule.rb +1 -0
- data/lib/scryer/report_renderer.rb +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/style_rules/frozen_string_literal_rule.rb +1 -0
- data/lib/scryer/version.rb +1 -1
- data/lib/scryer.rb +15 -0
- data/lib/tasks/scryer.rake +117 -4
- metadata +24 -12
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
require "ripper"
|
|
2
|
+
|
|
3
|
+
module Scryer
|
|
4
|
+
# Best-effort verification that an AI-rewritten suggested_fix actually
|
|
5
|
+
# clears the finding it was generated for — "AI-verified remediation" in
|
|
6
|
+
# the sense of "Scryer independently re-checked this," not in the sense of
|
|
7
|
+
# a human review being unnecessary (suggested_fix is still never
|
|
8
|
+
# auto-applied to a real file; see AiFixSuggester's own header comment).
|
|
9
|
+
#
|
|
10
|
+
# How: AiFixSuggester's prompt (see static_prompt_for) asks the model to
|
|
11
|
+
# end its reply with a fenced "AFTER:" code block containing a drop-in
|
|
12
|
+
# replacement for the single offending source line. This class extracts
|
|
13
|
+
# that block, substitutes it for that one line in an in-memory copy of the
|
|
14
|
+
# file (nothing is ever written to disk), re-parses the result, and
|
|
15
|
+
# re-runs *only the one rule that flagged this finding* against it. If
|
|
16
|
+
# that rule no longer fires anywhere in the modified file, the fix is
|
|
17
|
+
# marked verified.
|
|
18
|
+
#
|
|
19
|
+
# Deliberately narrow, same spirit as `scryer verify` (the CLI command
|
|
20
|
+
# this shares its core logic with): this confirms the ONE finding it
|
|
21
|
+
# targeted is gone, not that the fix is otherwise correct, idiomatic, or
|
|
22
|
+
# free of introducing a different problem — a full rescan (or `scryer
|
|
23
|
+
# verify` again with a different --rule) answers that.
|
|
24
|
+
#
|
|
25
|
+
# Returns true (rule no longer fires), false (attempted but the rule still
|
|
26
|
+
# fires, or the rewritten line doesn't even parse), or nil (verification
|
|
27
|
+
# wasn't attempted at all — no AFTER: block in the AI's reply, the
|
|
28
|
+
# original file isn't readable, or this isn't a rule-backed Finding to
|
|
29
|
+
# begin with). nil is deliberately distinct from false: it means "we don't
|
|
30
|
+
# know," not "we checked and it's still broken."
|
|
31
|
+
module FixVerifier
|
|
32
|
+
module_function
|
|
33
|
+
|
|
34
|
+
AFTER_BLOCK = /AFTER:\s*```\w*\n(.*?)\n?```/m.freeze
|
|
35
|
+
|
|
36
|
+
def verify(finding:, root:)
|
|
37
|
+
return nil unless finding.is_a?(Scryer::Finding)
|
|
38
|
+
return nil unless finding.line && finding.rule_id
|
|
39
|
+
|
|
40
|
+
after_snippet = extract_after_snippet(finding.suggested_fix)
|
|
41
|
+
return nil unless after_snippet
|
|
42
|
+
|
|
43
|
+
abs_path = File.join(root, finding.file.to_s)
|
|
44
|
+
return nil unless File.file?(abs_path)
|
|
45
|
+
|
|
46
|
+
lines = File.read(abs_path).lines
|
|
47
|
+
return nil unless finding.line.between?(1, lines.size)
|
|
48
|
+
|
|
49
|
+
modified_source = apply_line_replacement(lines, finding.line, after_snippet)
|
|
50
|
+
|
|
51
|
+
sexp = begin
|
|
52
|
+
Ripper.sexp(modified_source)
|
|
53
|
+
rescue StandardError
|
|
54
|
+
nil
|
|
55
|
+
end
|
|
56
|
+
return false if sexp.nil? # the rewritten line doesn't even parse — not a usable fix
|
|
57
|
+
|
|
58
|
+
rule_class = Scryer::RuleSet.all.find { |r| r.rule_id == finding.rule_id }
|
|
59
|
+
return nil unless rule_class
|
|
60
|
+
|
|
61
|
+
remaining = rule_class.new(file: finding.file, source: modified_source, sexp: sexp).scan
|
|
62
|
+
remaining.none? { |f| f.rule_id == finding.rule_id }
|
|
63
|
+
rescue StandardError
|
|
64
|
+
nil
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def extract_after_snippet(suggested_fix)
|
|
68
|
+
match = AFTER_BLOCK.match(suggested_fix.to_s)
|
|
69
|
+
return nil unless match
|
|
70
|
+
|
|
71
|
+
content = match[1].to_s
|
|
72
|
+
content.strip.empty? ? nil : content
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def apply_line_replacement(lines, line_number, replacement)
|
|
76
|
+
replacement_text = replacement.end_with?("\n") ? replacement : "#{replacement}\n"
|
|
77
|
+
modified = lines.dup
|
|
78
|
+
modified[line_number - 1] = replacement_text
|
|
79
|
+
modified.join
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# Opt-in Minitest integration — require this file yourself (e.g. `require
|
|
2
|
+
# "scryer/minitest"` in test_helper.rb) rather than it loading automatically
|
|
3
|
+
# with the gem; same reasoning as lib/scryer/rspec.rb (Minitest is never a
|
|
4
|
+
# Scryer runtime dependency, and this file's assertions only make sense once
|
|
5
|
+
# the host app's own test framework is already loaded).
|
|
6
|
+
#
|
|
7
|
+
# Mix Scryer::MinitestAssertions into a Minitest::Test (or
|
|
8
|
+
# ActiveSupport::TestCase, which is one) to assert this app's own security
|
|
9
|
+
# scan stays clean as part of its normal test suite:
|
|
10
|
+
#
|
|
11
|
+
# class SecurityTest < ActiveSupport::TestCase
|
|
12
|
+
# include Scryer::MinitestAssertions
|
|
13
|
+
#
|
|
14
|
+
# test "no critical findings" do
|
|
15
|
+
# assert_no_critical_scryer_findings(Scryer.scan(root: Rails.root.to_s))
|
|
16
|
+
# end
|
|
17
|
+
#
|
|
18
|
+
# test "the mass-assignment bug fixed in PR #123 doesn't come back" do
|
|
19
|
+
# assert_no_scryer_findings_for(Scryer.scan(root: Rails.root.to_s), "mass_assignment")
|
|
20
|
+
# end
|
|
21
|
+
# end
|
|
22
|
+
module Scryer
|
|
23
|
+
module MinitestAssertions
|
|
24
|
+
# Only security findings — style/performance findings aren't a security
|
|
25
|
+
# regression, same scoping as the RSpec have_no_critical_findings matcher.
|
|
26
|
+
def assert_no_critical_scryer_findings(result, msg = nil)
|
|
27
|
+
criticals = result.security_findings.select { |f| f.severity == "critical" }
|
|
28
|
+
default_msg = -> {
|
|
29
|
+
lines = criticals.map { |f| " - #{f.rule_id} at #{f.file}:#{f.line} — #{f.message}" }
|
|
30
|
+
"expected no critical security findings, but got #{criticals.size}:\n#{lines.join("\n")}"
|
|
31
|
+
}
|
|
32
|
+
assert criticals.empty?, msg || default_msg.call
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Checks all three categories (security/performance/style) — a rule
|
|
36
|
+
# regressing is worth catching regardless of which category it's filed
|
|
37
|
+
# under, unlike assert_no_critical_scryer_findings above.
|
|
38
|
+
def assert_no_scryer_findings_for(result, rule_id, msg = nil)
|
|
39
|
+
matches = (result.security_findings + result.performance_findings + result.style_findings)
|
|
40
|
+
.select { |f| f.rule_id == rule_id.to_s }
|
|
41
|
+
default_msg = -> {
|
|
42
|
+
lines = matches.map { |f| " - #{f.file}:#{f.line} — #{f.message}" }
|
|
43
|
+
"expected no findings for rule #{rule_id.inspect}, but got #{matches.size}:\n#{lines.join("\n")}"
|
|
44
|
+
}
|
|
45
|
+
assert matches.empty?, msg || default_msg.call
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -14,6 +14,7 @@ module Scryer
|
|
|
14
14
|
self.category = "performance"
|
|
15
15
|
self.default_severity = "warning"
|
|
16
16
|
self.title = "Per-record save/update inside a loop"
|
|
17
|
+
self.confidence = "medium"
|
|
17
18
|
|
|
18
19
|
LOOP_METHODS = %w[each each_with_index].freeze
|
|
19
20
|
# Genuinely argless in normal use — a bare `:call` node, never wrapped
|
|
@@ -24,7 +25,25 @@ module Scryer
|
|
|
24
25
|
# avoids double-counting the inner call node these wrap.
|
|
25
26
|
ARG_METHODS = %w[update update! update_attribute update_attributes].freeze
|
|
26
27
|
|
|
28
|
+
# `db/seeds.rb` (and the multi-file `db/seeds/*.rb` convention Rails
|
|
29
|
+
# supports via `Rails.application.load_seed`) is a one-time, manually
|
|
30
|
+
# run setup script, not a request-handling hot path — the whole
|
|
31
|
+
# premise of this rule (per-record round-trips scale badly as load
|
|
32
|
+
# grows) doesn't apply to a script a developer runs once at setup
|
|
33
|
+
# time for a small, fixed reference dataset (roles, plans, countries,
|
|
34
|
+
# ...). Flagging `roles.each { |r| r.save! }` there is just noise, so
|
|
35
|
+
# this rule skips findings whose file is exactly this path or under
|
|
36
|
+
# this directory. Deliberately file-path-based rather than trying to
|
|
37
|
+
# infer "small dataset" from the AST (which isn't reliably knowable —
|
|
38
|
+
# a seed file can still iterate a CSV of arbitrary size), and narrow
|
|
39
|
+
# to this one well-known Rails convention rather than any file that
|
|
40
|
+
# merely "looks like a script" elsewhere in the app.
|
|
41
|
+
SEED_FILE = "db/seeds.rb"
|
|
42
|
+
SEED_DIR_PREFIX = "db/seeds/"
|
|
43
|
+
|
|
27
44
|
def scan
|
|
45
|
+
return [] if seed_file?
|
|
46
|
+
|
|
28
47
|
findings = []
|
|
29
48
|
|
|
30
49
|
Ast.each_node(sexp) do |node|
|
|
@@ -58,6 +77,19 @@ module Scryer
|
|
|
58
77
|
|
|
59
78
|
private
|
|
60
79
|
|
|
80
|
+
# `Scanner` builds `file` by globbing `File.join(root, dir, "**", "*.rb")`
|
|
81
|
+
# with `dir` defaulting to `"."`, so the relative path it hands rules is
|
|
82
|
+
# actually `"./db/seeds.rb"`, not `"db/seeds.rb"` — a plain `==`/
|
|
83
|
+
# `start_with?` against the un-prefixed string silently never matches
|
|
84
|
+
# against a real scan (verified: without stripping the prefix here,
|
|
85
|
+
# `db/seeds.rb` still fired in a real `Scryer::Scanner.new(root:,
|
|
86
|
+
# dirs: ["."]).call` run). Strip a leading `./` before comparing so
|
|
87
|
+
# this matches how the path actually arrives, not an assumed form.
|
|
88
|
+
def seed_file?
|
|
89
|
+
normalized = file.to_s.delete_prefix("./")
|
|
90
|
+
normalized == SEED_FILE || normalized.start_with?(SEED_DIR_PREFIX)
|
|
91
|
+
end
|
|
92
|
+
|
|
61
93
|
def block_param_name(block_node)
|
|
62
94
|
return nil unless Ast.tagged?(block_node, :do_block, :brace_block)
|
|
63
95
|
|
|
@@ -12,6 +12,7 @@ module Scryer
|
|
|
12
12
|
self.category = "performance"
|
|
13
13
|
self.default_severity = "warning"
|
|
14
14
|
self.title = "Possible unbounded result set on an index action"
|
|
15
|
+
self.confidence = "medium"
|
|
15
16
|
|
|
16
17
|
QUERY_METHODS = %w[all where].freeze
|
|
17
18
|
BOUND_METHODS = %w[limit page per paginate find_each find_in_batches first take].freeze
|
|
@@ -21,6 +21,7 @@ module Scryer
|
|
|
21
21
|
self.category = "performance"
|
|
22
22
|
self.default_severity = "warning"
|
|
23
23
|
self.title = "Possible N+1 query inside a loop"
|
|
24
|
+
self.confidence = "medium"
|
|
24
25
|
|
|
25
26
|
QUERY_METHODS = %w[where all find find_by find_by! order limit].freeze
|
|
26
27
|
LOOP_METHODS = %w[each map collect each_with_index].freeze
|