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.
Files changed (67) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +414 -0
  3. data/README.md +114 -649
  4. data/docs/architecture.md +268 -0
  5. data/docs/contributing.md +42 -0
  6. data/docs/fix-mode.md +364 -0
  7. data/docs/rails-integration.md +162 -0
  8. data/docs/rules.md +310 -0
  9. data/docs/usage.md +301 -0
  10. data/lib/generators/scryer/USAGE +10 -2
  11. data/lib/generators/scryer/templates/scryer_initializer.rb +15 -0
  12. data/lib/scryer/ai_fix_suggester.rb +37 -11
  13. data/lib/scryer/ast.rb +26 -0
  14. data/lib/scryer/authorization_watcher.rb +156 -0
  15. data/lib/scryer/baseline.rb +75 -0
  16. data/lib/scryer/cli.rb +688 -14
  17. data/lib/scryer/colorizer.rb +56 -0
  18. data/lib/scryer/dependency_fixer.rb +96 -0
  19. data/lib/scryer/finding.rb +6 -0
  20. data/lib/scryer/fix_runner.rb +161 -0
  21. data/lib/scryer/fix_verifier.rb +169 -0
  22. data/lib/scryer/mechanical_fixer.rb +288 -0
  23. data/lib/scryer/minitest.rb +48 -0
  24. data/lib/scryer/performance_rules/inefficient_save_loop_rule.rb +32 -0
  25. data/lib/scryer/performance_rules/missing_pagination_rule.rb +1 -0
  26. data/lib/scryer/performance_rules/n_plus_one_query_rule.rb +1 -0
  27. data/lib/scryer/performance_rules/unbounded_table_scan_rule.rb +1 -0
  28. data/lib/scryer/report_renderer.rb +539 -46
  29. data/lib/scryer/rspec.rb +55 -0
  30. data/lib/scryer/rule.rb +22 -2
  31. data/lib/scryer/rules/action_cable_forgery_protection_rule.rb +3 -0
  32. data/lib/scryer/rules/active_storage_inline_disposition_rule.rb +3 -0
  33. data/lib/scryer/rules/active_storage_missing_content_type_validation_rule.rb +3 -0
  34. data/lib/scryer/rules/authentication_bypass_rule.rb +30 -7
  35. data/lib/scryer/rules/command_injection_rule.rb +3 -0
  36. data/lib/scryer/rules/consider_all_requests_local_rule.rb +51 -0
  37. data/lib/scryer/rules/cors_misconfiguration_rule.rb +51 -20
  38. data/lib/scryer/rules/csrf_protection_rule.rb +60 -11
  39. data/lib/scryer/rules/force_ssl_rule.rb +3 -0
  40. data/lib/scryer/rules/graphql_missing_query_limits_rule.rb +31 -0
  41. data/lib/scryer/rules/hardcoded_basic_auth_rule.rb +3 -0
  42. data/lib/scryer/rules/hardcoded_secret_key_base_rule.rb +3 -0
  43. data/lib/scryer/rules/hardcoded_secret_rule.rb +3 -0
  44. data/lib/scryer/rules/host_authorization_disabled_rule.rb +50 -0
  45. data/lib/scryer/rules/idor_rule.rb +63 -9
  46. data/lib/scryer/rules/insecure_cookie_serializer_rule.rb +3 -0
  47. data/lib/scryer/rules/job_raw_params_rule.rb +40 -7
  48. data/lib/scryer/rules/jwt_insecure_rule.rb +3 -0
  49. data/lib/scryer/rules/mass_assignment_rule.rb +32 -5
  50. data/lib/scryer/rules/missing_authorization_rule.rb +103 -0
  51. data/lib/scryer/rules/missing_policy_scope_rule.rb +134 -0
  52. data/lib/scryer/rules/open_redirect_rule.rb +3 -0
  53. data/lib/scryer/rules/path_traversal_rule.rb +22 -1
  54. data/lib/scryer/rules/security_headers_rule.rb +3 -0
  55. data/lib/scryer/rules/sql_injection_rule.rb +3 -0
  56. data/lib/scryer/rules/ssrf_rule.rb +67 -13
  57. data/lib/scryer/rules/unsafe_deserialization_rule.rb +3 -0
  58. data/lib/scryer/rules/verbose_production_log_level_rule.rb +53 -0
  59. data/lib/scryer/rules/weak_crypto_rule.rb +37 -2
  60. data/lib/scryer/rules/weak_session_cookie_rule.rb +3 -0
  61. data/lib/scryer/rules/xss_unsafe_html_rule.rb +41 -0
  62. data/lib/scryer/scanner.rb +25 -12
  63. data/lib/scryer/style_rules/frozen_string_literal_rule.rb +1 -0
  64. data/lib/scryer/version.rb +1 -1
  65. data/lib/scryer.rb +30 -1
  66. data/lib/tasks/scryer.rake +447 -20
  67. metadata +52 -12
@@ -24,14 +24,29 @@ module Scryer
24
24
  # Enhances a single Finding in place and returns it. Any failure
25
25
  # (client raises, times out, returns nothing usable) is swallowed and
26
26
  # the finding's original suggested_fix is left as-is — an LLM call
27
- # failing should never break a scan.
28
- def enhance!(finding, client: Scryer.configuration.ai_client)
27
+ # failing should never break a scan. `on_error`, when given, is called
28
+ # with (finding, exception) right before it's swallowed — the failure
29
+ # still never propagates, but a caller that wants to surface *why* a
30
+ # finding fell through to manual review (a bad API key, a timeout, a
31
+ # malformed response) can. Without it, a failing client and "no
32
+ # ai_client configured at all" look identical from the outside.
33
+ #
34
+ # `root`, when given (and only for a Scryer::Finding — see
35
+ # FixVerifier), triggers a follow-up verification pass: re-read the
36
+ # actual file from disk, substitute the AI's suggested replacement for
37
+ # the one offending line, and re-run just this finding's own rule
38
+ # against the result. Sets finding.fix_verified to true/false/nil (see
39
+ # Finding#fix_verified) — never raises, same failure-swallowing
40
+ # philosophy as the AI call itself.
41
+ def enhance!(finding, client: Scryer.configuration.ai_client, root: nil, on_error: nil)
29
42
  return finding unless client
30
43
 
31
44
  reply = call_client(client, prompt_for(finding))
32
45
  finding.suggested_fix = reply.strip unless blank?(reply)
46
+ finding.fix_verified = FixVerifier.verify(finding: finding, root: root) if root
33
47
  finding
34
- rescue StandardError
48
+ rescue StandardError => e
49
+ on_error&.call(finding, e)
35
50
  finding
36
51
  end
37
52
 
@@ -40,10 +55,14 @@ module Scryer
40
55
  # (network-bound work, same pattern as
41
56
  # DependencyAudit.vulnerable_gems) so a large finding count doesn't
42
57
  # mean one-request-at-a-time. No-op if no client is configured —
43
- # callers don't need to check first.
44
- def enhance_result!(result, client: Scryer.configuration.ai_client, concurrency: 4)
58
+ # callers don't need to check first. Pass `root` (the project root
59
+ # `finding.file` is relative to) to also run fix verification — see
60
+ # `enhance!`; omit it to skip verification entirely (e.g. when the
61
+ # caller has no meaningful root, or doesn't want the extra re-parse
62
+ # work).
63
+ def enhance_result!(result, client: Scryer.configuration.ai_client, concurrency: 4, root: nil)
45
64
  enhance_many!(result.security_findings + result.performance_findings + result.style_findings,
46
- client: client, concurrency: concurrency)
65
+ client: client, concurrency: concurrency, root: root)
47
66
  result
48
67
  end
49
68
 
@@ -51,8 +70,9 @@ module Scryer
51
70
  # Scryer::DependencyAudit::Finding objects, which aren't attached to a
52
71
  # Scanner::Result. Works on any mix of Finding/DependencyAudit::Finding
53
72
  # (prompt_for below dispatches on which one it got). No-op if no client
54
- # is configured.
55
- def enhance_many!(findings, client: Scryer.configuration.ai_client, concurrency: 4)
73
+ # is configured. `root` is ignored for DependencyAudit::Finding objects
74
+ # (FixVerifier only handles rule-backed Finding — see its guard clause).
75
+ def enhance_many!(findings, client: Scryer.configuration.ai_client, concurrency: 4, root: nil)
56
76
  return findings unless client
57
77
 
58
78
  queue = Queue.new
@@ -68,7 +88,7 @@ module Scryer
68
88
  end
69
89
  break unless finding
70
90
 
71
- enhance!(finding, client: client)
91
+ enhance!(finding, client: client, root: root)
72
92
  end
73
93
  end
74
94
  end
@@ -107,8 +127,14 @@ module Scryer
107
127
 
108
128
  Generic guidance for this rule: #{finding.suggested_fix}
109
129
 
110
- Reply with a short explanation (1-3 sentences) followed by a before/after code
111
- example using the actual snippet above. Do not restate the issue description.
130
+ Reply with a short explanation (1-3 sentences), then a fenced code block showing the
131
+ fix in context if that's useful, and finally as the very last thing in your reply,
132
+ exactly once — a line reading "AFTER:" followed by a fenced code block containing ONLY
133
+ the corrected replacement for the single offending line shown above (line
134
+ #{finding.line}), nothing else in that block (no surrounding context lines, no
135
+ comments about the change). This exact "AFTER:" block is parsed automatically to
136
+ verify the fix actually resolves the finding, so it must be a valid, direct drop-in
137
+ replacement for that one line. Do not restate the issue description.
112
138
  PROMPT
113
139
  end
114
140
 
data/lib/scryer/ast.rb CHANGED
@@ -91,6 +91,32 @@ module Scryer
91
91
  node[1] if node[0].is_a?(Symbol) && %i[@ident @const @kw @op].include?(node[0])
92
92
  end
93
93
 
94
+ # The full dotted name from a class/module's own name node — the second
95
+ # element of a `[:class, name_node, superclass, body]` sexp. A plain
96
+ # `class Foo` parses name_node as `[:const_ref, [:@const, "Foo", pos]]`;
97
+ # a namespaced `class Admin::PostsController` parses it as a
98
+ # `:const_path_ref` chain instead (verified via `Ripper.sexp` —
99
+ # `Api::V1::UsersController` nests two levels deep: `[:const_path_ref,
100
+ # [:const_path_ref, [:var_ref, [:@const,"Api"]], [:@const,"V1"]],
101
+ # [:@const,"UsersController"]]`). Several rules used to check only the
102
+ # `:const_ref` shape (`node[1][1]` via `ident_text`), so a namespaced
103
+ # controller's class name silently came back nil and the whole class was
104
+ # never examined — a real false-negative gap, not a minor edge case,
105
+ # given how common namespacing (admin areas, API versions) is in real
106
+ # Rails apps. Returns e.g. "Admin::PostsController" so a plain
107
+ # `.end_with?("Controller")` check still works the same either way.
108
+ def class_name(node)
109
+ if tagged?(node, :const_ref)
110
+ ident_text(node[1])
111
+ elsif tagged?(node, :var_ref) && node[1].is_a?(Array) && node[1][0] == :@const
112
+ node[1][1]
113
+ elsif tagged?(node, :const_path_ref)
114
+ left = class_name(node[1])
115
+ right = ident_text(node[2])
116
+ [left, right].compact.join("::")
117
+ end
118
+ end
119
+
94
120
  # Given a [:method_add_arg, call_node, args_node] or [:command, ident, args_node]
95
121
  # node, return the flattened list of top-level argument sexp nodes (best effort —
96
122
  # walks through the [:arg_paren, [:args_add_block, [args...], block]] wrapping).
@@ -0,0 +1,156 @@
1
+ module Scryer
2
+ # Runtime companion to the static `idor`/`missing_authorization`/
3
+ # `missing_policy_scope` rules — those can only ever say "no call to a
4
+ # known authorization method is visible anywhere in this controller's
5
+ # source," which is exactly as wrong as it sounds whenever the real check
6
+ # happens somewhere the static AST walk can't see (a shared base
7
+ # controller, a concern, a class-level macro whose effect isn't visible by
8
+ # name). This watcher answers a narrower but much more reliable question
9
+ # instead: for *this actual request*, did Pundit's `authorize`/
10
+ # `policy_scope` or CanCanCan's `authorize!` genuinely get called?
11
+ #
12
+ # How: both libraries already track this themselves, for their own
13
+ # `verify_authorized`/`check_authorization` after_action helpers —
14
+ # `Pundit::Authorization#pundit_policy_authorized?`/`#pundit_policy_scoped?`
15
+ # (public API, `@_pundit_policy_authorized`/`@_pundit_policy_scoped` under
16
+ # the hood) and CanCanCan's `@_authorized` ivar (set by `authorize!` and by
17
+ # `skip_authorization_check`; verified by reading both gems' actual source,
18
+ # `pundit-2.5.2/lib/pundit/authorization.rb` and
19
+ # `cancancan-3.6.1/lib/cancan/controller_additions.rb` — not guessed). This
20
+ # class registers one more `after_action`, alongside those, that checks the
21
+ # same flags and reports when a write action completed with neither set.
22
+ #
23
+ # Deliberately Pundit/CanCanCan-only, same scope as the static rules this
24
+ # complements: with neither gem loaded, `enable!` still runs but every
25
+ # request is silently skipped (see `authorization_library_present?`) — an
26
+ # app with fully custom, non-object-level authorization (a single
27
+ # `before_action :require_admin!`, say) gets no findings and no false
28
+ # positives here, rather than a flood of "unauthorized" reports for a
29
+ # pattern this watcher has no way to recognize as intentional.
30
+ #
31
+ # Deliberately narrower than "check every action": only create/update/
32
+ # destroy (or any POST/PUT/PATCH/DELETE), matching MissingAuthorizationRule
33
+ # exactly — and only requests that actually completed (status < 400).
34
+ # Read-scoping gaps (an unscoped `index` — see MissingPolicyScopeRule) are
35
+ # NOT covered here; verifying "was the returned data correctly scoped" at
36
+ # runtime, rather than "was a method called," is a materially different
37
+ # and harder check this class doesn't attempt.
38
+ class AuthorizationWatcher
39
+ Finding = Struct.new(:kind, :message, :controller, :action, :method, :path, :suggested_fix, keyword_init: true) do
40
+ def to_h
41
+ super.transform_keys(&:to_s)
42
+ end
43
+ end
44
+
45
+ WRITE_ACTIONS = %w[create update destroy].freeze
46
+ WRITE_METHODS = %w[POST PUT PATCH DELETE].freeze
47
+
48
+ class << self
49
+ # Turns the watcher on for the life of the process. Idempotent — safe
50
+ # to call more than once (later calls are no-ops). No Rack middleware
51
+ # to install, unlike QueryWatcher: a Rails controller instance is
52
+ # already fresh per request, so there's no shared/leaking state to
53
+ # scope — the `after_action` below just runs once per completed
54
+ # action.
55
+ def enable!(logger: nil)
56
+ return if @enabled
57
+
58
+ @logger = logger || default_logger
59
+ @findings = []
60
+ @enabled = true
61
+
62
+ # Covers both API-only and normal Rails apps without special-casing
63
+ # either: ActionController::Base and ActionController::API are
64
+ # sibling classes (neither inherits from the other — verified via
65
+ # `ActionController::API.ancestors.include?(ActionController::Base)
66
+ # #=> false`), but Rails' own actionpack source calls
67
+ # `ActiveSupport.run_load_hooks(:action_controller, self)` from
68
+ # *both* action_controller/base.rb and action_controller/api.rb, so
69
+ # this block runs once per base class and `install_hook` ends up
70
+ # registering the after_action on both. Confirmed with a real
71
+ # ActionController::API + Pundit integration test, not assumed.
72
+ ActiveSupport.on_load(:action_controller) { Scryer::AuthorizationWatcher.send(:install_hook, self) }
73
+ end
74
+
75
+ def enabled?
76
+ !!@enabled
77
+ end
78
+
79
+ # Every finding recorded so far this process — inspect, log, or feed
80
+ # into your own alerting. Not reset automatically; call `clear!`
81
+ # yourself (e.g. between test examples, or on a timer) if you don't
82
+ # want it growing for the life of a long-running process.
83
+ def findings
84
+ @findings ||= []
85
+ end
86
+
87
+ def clear!
88
+ @findings = []
89
+ end
90
+
91
+ private
92
+
93
+ def default_logger
94
+ if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
95
+ Rails.logger
96
+ else
97
+ require "logger"
98
+ Logger.new($stdout)
99
+ end
100
+ end
101
+
102
+ def install_hook(base)
103
+ base.after_action { |controller| Scryer::AuthorizationWatcher.send(:check, controller) }
104
+ end
105
+
106
+ def check(controller)
107
+ return unless authorization_library_present?
108
+
109
+ action = controller.action_name.to_s
110
+ request = controller.request
111
+ return unless WRITE_ACTIONS.include?(action) || WRITE_METHODS.include?(request.method)
112
+
113
+ status = controller.response&.status
114
+ return unless status && status < 400 # already rejected/errored — nothing to report
115
+ return if authorization_evidence?(controller)
116
+
117
+ finding = Finding.new(
118
+ kind: "runtime_missing_authorization",
119
+ message: "#{controller.class}##{action} completed a #{request.method} request " \
120
+ "(status #{status}) with no authorization check actually invoked during it " \
121
+ "(checked Pundit's authorize/policy_scope and CanCanCan's authorize!/" \
122
+ "skip_authorization_check — neither fired).",
123
+ controller: controller.class.name,
124
+ action: action,
125
+ method: request.method,
126
+ path: request.path,
127
+ suggested_fix: "Add an authorization check to this action — Pundit's `authorize`/" \
128
+ "`policy_scope`, or CanCanCan's `authorize!`/`load_and_authorize_resource` " \
129
+ "— or call `skip_authorization`/`skip_authorization_check` explicitly if " \
130
+ "this action is deliberately open to any authenticated (or anonymous) user."
131
+ )
132
+ findings << finding
133
+ @logger.warn("[Scryer::AuthorizationWatcher] #{finding.message}")
134
+ end
135
+
136
+ def authorization_library_present?
137
+ defined?(::Pundit::Authorization) || defined?(::CanCan::ControllerAdditions)
138
+ end
139
+
140
+ def authorization_evidence?(controller)
141
+ pundit_authorized?(controller) || cancancan_authorized?(controller)
142
+ end
143
+
144
+ def pundit_authorized?(controller)
145
+ return false unless controller.respond_to?(:pundit_policy_authorized?, true)
146
+
147
+ controller.send(:pundit_policy_authorized?) ||
148
+ (controller.respond_to?(:pundit_policy_scoped?, true) && controller.send(:pundit_policy_scoped?))
149
+ end
150
+
151
+ def cancancan_authorized?(controller)
152
+ controller.instance_variable_defined?(:@_authorized)
153
+ end
154
+ end
155
+ end
156
+ end
@@ -0,0 +1,75 @@
1
+ require "digest"
2
+ require "json"
3
+ require "set"
4
+
5
+ module Scryer
6
+ # Baseline mode: `scryer --save-baseline FILE` snapshots the current scan's
7
+ # findings as a set of stable fingerprints; a later `scryer --baseline
8
+ # FILE` scan diffs against that snapshot and reports only *new* findings
9
+ # (plus how many were fixed since), instead of the same full list a legacy
10
+ # codebase would otherwise reproduce on every single run. This is what
11
+ # makes adopting Scryer on an app with real pre-existing security debt
12
+ # practical: gate CI on new issues only, and burn down the rest on its own
13
+ # schedule, instead of being forced to either fix everything on day one or
14
+ # turn the gate off entirely.
15
+ #
16
+ # Fingerprints are deliberately NOT tied to line number — SHA256 of
17
+ # (identifying fields + the offending source text/advisory), not
18
+ # file:line. A finding whose line shifts because of an unrelated edit
19
+ # earlier in the same file would otherwise look simultaneously "new" and
20
+ # "fixed" on every unrelated commit, which would make baseline mode
21
+ # useless noise instead of a real signal.
22
+ module Baseline
23
+ module_function
24
+
25
+ # `f` is a finding hash (Finding#to_h or DependencyAudit::Finding#to_h)
26
+ # — distinguished by "rule_id" (rule-based: security/performance/style)
27
+ # vs. "kind" (dependency findings, which have no rule_id at all).
28
+ def fingerprint(f)
29
+ basis =
30
+ if f["rule_id"]
31
+ [f["rule_id"], f["file"], f["code_snippet"].to_s.strip]
32
+ else
33
+ [f["kind"], f["gem_name"], f["advisory_id"], f["installed_version"]].compact
34
+ end
35
+
36
+ Digest::SHA256.hexdigest(basis.join("|"))[0, 16]
37
+ end
38
+
39
+ def fingerprints(findings)
40
+ findings.map { |f| fingerprint(f) }
41
+ end
42
+
43
+ def save(path, findings)
44
+ data = {
45
+ "scryer_version" => Scryer::VERSION,
46
+ "created_at" => Time.now.utc.iso8601,
47
+ "fingerprints" => fingerprints(findings).uniq
48
+ }
49
+ File.write(path, JSON.pretty_generate(data))
50
+ end
51
+
52
+ LoadError = Class.new(StandardError)
53
+
54
+ def load(path)
55
+ raise LoadError, "baseline file not found: #{path}" unless File.exist?(path)
56
+
57
+ data = JSON.parse(File.read(path))
58
+ Set.new(Array(data["fingerprints"]))
59
+ rescue JSON::ParserError => e
60
+ raise LoadError, "invalid baseline file #{path}: #{e.message}"
61
+ end
62
+
63
+ # Splits `findings` into [new_findings, fixed_count] against a baseline
64
+ # Set of fingerprints. `new_findings` is what the rest of the pipeline
65
+ # should treat as "the" findings from this point on (reports, exit code,
66
+ # top_risks, everything) — `fixed_count` is purely informational
67
+ # (present in the baseline, absent from this scan).
68
+ def diff(findings, baseline_fingerprints)
69
+ current = fingerprints(findings)
70
+ new_findings = findings.each_with_index.reject { |_, i| baseline_fingerprints.include?(current[i]) }.map(&:first)
71
+ fixed_count = (baseline_fingerprints - current.to_set).size
72
+ [new_findings, fixed_count]
73
+ end
74
+ end
75
+ end