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.
Files changed (56) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +253 -0
  3. data/README.md +459 -41
  4. data/lib/generators/scryer/USAGE +10 -2
  5. data/lib/generators/scryer/templates/scryer_initializer.rb +15 -0
  6. data/lib/scryer/ai_fix_suggester.rb +29 -9
  7. data/lib/scryer/ast.rb +26 -0
  8. data/lib/scryer/authorization_watcher.rb +156 -0
  9. data/lib/scryer/baseline.rb +75 -0
  10. data/lib/scryer/cli.rb +189 -3
  11. data/lib/scryer/finding.rb +6 -0
  12. data/lib/scryer/fix_verifier.rb +82 -0
  13. data/lib/scryer/minitest.rb +48 -0
  14. data/lib/scryer/performance_rules/inefficient_save_loop_rule.rb +32 -0
  15. data/lib/scryer/performance_rules/missing_pagination_rule.rb +1 -0
  16. data/lib/scryer/performance_rules/n_plus_one_query_rule.rb +1 -0
  17. data/lib/scryer/performance_rules/unbounded_table_scan_rule.rb +1 -0
  18. data/lib/scryer/report_renderer.rb +539 -46
  19. data/lib/scryer/rspec.rb +55 -0
  20. data/lib/scryer/rule.rb +22 -2
  21. data/lib/scryer/rules/action_cable_forgery_protection_rule.rb +3 -0
  22. data/lib/scryer/rules/active_storage_inline_disposition_rule.rb +3 -0
  23. data/lib/scryer/rules/active_storage_missing_content_type_validation_rule.rb +3 -0
  24. data/lib/scryer/rules/authentication_bypass_rule.rb +30 -7
  25. data/lib/scryer/rules/command_injection_rule.rb +3 -0
  26. data/lib/scryer/rules/consider_all_requests_local_rule.rb +51 -0
  27. data/lib/scryer/rules/cors_misconfiguration_rule.rb +51 -20
  28. data/lib/scryer/rules/csrf_protection_rule.rb +60 -11
  29. data/lib/scryer/rules/force_ssl_rule.rb +3 -0
  30. data/lib/scryer/rules/graphql_missing_query_limits_rule.rb +31 -0
  31. data/lib/scryer/rules/hardcoded_basic_auth_rule.rb +3 -0
  32. data/lib/scryer/rules/hardcoded_secret_key_base_rule.rb +3 -0
  33. data/lib/scryer/rules/hardcoded_secret_rule.rb +3 -0
  34. data/lib/scryer/rules/host_authorization_disabled_rule.rb +50 -0
  35. data/lib/scryer/rules/idor_rule.rb +63 -9
  36. data/lib/scryer/rules/insecure_cookie_serializer_rule.rb +3 -0
  37. data/lib/scryer/rules/job_raw_params_rule.rb +40 -7
  38. data/lib/scryer/rules/jwt_insecure_rule.rb +3 -0
  39. data/lib/scryer/rules/mass_assignment_rule.rb +32 -5
  40. data/lib/scryer/rules/missing_authorization_rule.rb +103 -0
  41. data/lib/scryer/rules/missing_policy_scope_rule.rb +134 -0
  42. data/lib/scryer/rules/open_redirect_rule.rb +3 -0
  43. data/lib/scryer/rules/path_traversal_rule.rb +22 -1
  44. data/lib/scryer/rules/security_headers_rule.rb +3 -0
  45. data/lib/scryer/rules/sql_injection_rule.rb +3 -0
  46. data/lib/scryer/rules/ssrf_rule.rb +67 -13
  47. data/lib/scryer/rules/unsafe_deserialization_rule.rb +3 -0
  48. data/lib/scryer/rules/verbose_production_log_level_rule.rb +53 -0
  49. data/lib/scryer/rules/weak_crypto_rule.rb +37 -2
  50. data/lib/scryer/rules/weak_session_cookie_rule.rb +3 -0
  51. data/lib/scryer/rules/xss_unsafe_html_rule.rb +41 -0
  52. data/lib/scryer/style_rules/frozen_string_literal_rule.rb +1 -0
  53. data/lib/scryer/version.rb +1 -1
  54. data/lib/scryer.rb +15 -0
  55. data/lib/tasks/scryer.rake +117 -4
  56. 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
@@ -14,6 +14,7 @@ module Scryer
14
14
  self.category = "performance"
15
15
  self.default_severity = "warning"
16
16
  self.title = "Full query result loaded into memory before iterating"
17
+ self.confidence = "medium"
17
18
 
18
19
  QUERY_METHODS = %w[all where order].freeze
19
20