scryer 0.3.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 (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +322 -0
  3. data/README.md +546 -43
  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 +95 -6
  8. data/lib/scryer/authorization_watcher.rb +156 -0
  9. data/lib/scryer/baseline.rb +75 -0
  10. data/lib/scryer/cli.rb +209 -11
  11. data/lib/scryer/dependency_audit.rb +108 -5
  12. data/lib/scryer/finding.rb +6 -0
  13. data/lib/scryer/fix_verifier.rb +82 -0
  14. data/lib/scryer/minitest.rb +48 -0
  15. data/lib/scryer/performance_rules/inefficient_save_loop_rule.rb +32 -0
  16. data/lib/scryer/performance_rules/missing_pagination_rule.rb +1 -0
  17. data/lib/scryer/performance_rules/n_plus_one_query_rule.rb +1 -0
  18. data/lib/scryer/performance_rules/unbounded_table_scan_rule.rb +1 -0
  19. data/lib/scryer/report_renderer.rb +637 -44
  20. data/lib/scryer/rspec.rb +55 -0
  21. data/lib/scryer/rule.rb +22 -2
  22. data/lib/scryer/rules/action_cable_forgery_protection_rule.rb +50 -0
  23. data/lib/scryer/rules/active_storage_inline_disposition_rule.rb +50 -0
  24. data/lib/scryer/rules/active_storage_missing_content_type_validation_rule.rb +79 -0
  25. data/lib/scryer/rules/authentication_bypass_rule.rb +95 -0
  26. data/lib/scryer/rules/command_injection_rule.rb +3 -0
  27. data/lib/scryer/rules/consider_all_requests_local_rule.rb +51 -0
  28. data/lib/scryer/rules/cors_misconfiguration_rule.rb +100 -0
  29. data/lib/scryer/rules/csrf_protection_rule.rb +60 -11
  30. data/lib/scryer/rules/force_ssl_rule.rb +48 -0
  31. data/lib/scryer/rules/graphql_missing_query_limits_rule.rb +106 -0
  32. data/lib/scryer/rules/hardcoded_basic_auth_rule.rb +51 -0
  33. data/lib/scryer/rules/hardcoded_secret_key_base_rule.rb +58 -0
  34. data/lib/scryer/rules/hardcoded_secret_rule.rb +3 -0
  35. data/lib/scryer/rules/host_authorization_disabled_rule.rb +50 -0
  36. data/lib/scryer/rules/idor_rule.rb +165 -0
  37. data/lib/scryer/rules/insecure_cookie_serializer_rule.rb +47 -0
  38. data/lib/scryer/rules/job_raw_params_rule.rb +131 -0
  39. data/lib/scryer/rules/jwt_insecure_rule.rb +123 -0
  40. data/lib/scryer/rules/mass_assignment_rule.rb +34 -10
  41. data/lib/scryer/rules/missing_authorization_rule.rb +103 -0
  42. data/lib/scryer/rules/missing_policy_scope_rule.rb +134 -0
  43. data/lib/scryer/rules/open_redirect_rule.rb +3 -0
  44. data/lib/scryer/rules/path_traversal_rule.rb +110 -0
  45. data/lib/scryer/rules/security_headers_rule.rb +133 -0
  46. data/lib/scryer/rules/sql_injection_rule.rb +3 -0
  47. data/lib/scryer/rules/ssrf_rule.rb +139 -0
  48. data/lib/scryer/rules/unsafe_deserialization_rule.rb +3 -0
  49. data/lib/scryer/rules/verbose_production_log_level_rule.rb +53 -0
  50. data/lib/scryer/rules/weak_crypto_rule.rb +37 -2
  51. data/lib/scryer/rules/weak_session_cookie_rule.rb +51 -0
  52. data/lib/scryer/rules/xss_unsafe_html_rule.rb +41 -0
  53. data/lib/scryer/style_rules/frozen_string_literal_rule.rb +1 -0
  54. data/lib/scryer/version.rb +1 -1
  55. data/lib/scryer.rb +15 -0
  56. data/lib/tasks/scryer.rake +138 -13
  57. metadata +41 -12
@@ -28,13 +28,21 @@ Description:
28
28
  useful. Leave unset to use the actual checked-out
29
29
  branch.
30
30
 
31
- Two more things ship in this gem but aren't controlled by the
32
- initializer — see the README for both:
31
+ A few more things ship in this gem but aren't controlled by the
32
+ initializer — see the README for each:
33
33
 
34
34
  - Scryer::QueryWatcher: an opt-in runtime instrumentation for N+1
35
35
  queries and unused eager loading (README: "Runtime query watcher").
36
36
  - `bin/rails scryer:audit_dependencies`: a Gemfile.lock vulnerability
37
37
  + insecure-source audit against OSV.dev (README: "Dependency audit").
38
+ - `bin/rails scryer:ci`: a CI-friendly shortcut for
39
+ `scryer:report[json,sarif]` — JSON + SARIF output, dependency audit
40
+ on, same SCRYER_BASELINE support (README: "CI/CD integration").
41
+ - `require "scryer/rspec"` / `require "scryer/minitest"`: opt-in
42
+ matchers/assertions (`have_no_critical_findings`,
43
+ `assert_no_critical_scryer_findings`, ...) for asserting this app's
44
+ own scan stays clean as part of its normal test suite (README:
45
+ "Testing Scryer results in your own test suite").
38
46
 
39
47
  Example:
40
48
  bin/rails generate scryer:install
@@ -14,6 +14,21 @@ Scryer.configure do |c|
14
14
  # where checkouts often land in detached-HEAD state). Leave blank to use
15
15
  # the actual checked-out branch:
16
16
  # c.branch = ENV["CI_COMMIT_BRANCH"]
17
+
18
+ # Silence a specific rule by rule_id (repeatable) — a known false positive
19
+ # on this codebase, or a check that doesn't apply here. Prefer this over
20
+ # deleting/editing the rule itself; run `bin/rails scryer:report` once and
21
+ # check tmp/scryer_report.html's "Checks performed" section for the full
22
+ # list of rule_ids:
23
+ # c.skip_rules = %w[idor]
24
+
25
+ # Any object/Proc responding to #call(prompt) (or #complete(prompt)) that
26
+ # returns a String — opts into rewriting every finding's suggested_fix
27
+ # against its actual code via that LLM. Off by default: no network calls,
28
+ # no provider assumed. See the README's "AI-assisted fix suggestions" and
29
+ # "AI-verified remediation" sections for what this does and how the
30
+ # (optional) re-scan-to-confirm-the-fix-works verification works:
31
+ # c.ai_client = ->(prompt) { MyLlmClient.complete(prompt) }
17
32
  end
18
33
 
19
34
  # Runtime query watcher (N+1 / unused eager loading — see README's "Runtime
@@ -25,11 +25,20 @@ module Scryer
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
27
  # failing should never break a scan.
28
- def enhance!(finding, client: Scryer.configuration.ai_client)
28
+ #
29
+ # `root`, when given (and only for a Scryer::Finding — see
30
+ # FixVerifier), triggers a follow-up verification pass: re-read the
31
+ # actual file from disk, substitute the AI's suggested replacement for
32
+ # the one offending line, and re-run just this finding's own rule
33
+ # against the result. Sets finding.fix_verified to true/false/nil (see
34
+ # Finding#fix_verified) — never raises, same failure-swallowing
35
+ # philosophy as the AI call itself.
36
+ def enhance!(finding, client: Scryer.configuration.ai_client, root: nil)
29
37
  return finding unless client
30
38
 
31
39
  reply = call_client(client, prompt_for(finding))
32
40
  finding.suggested_fix = reply.strip unless blank?(reply)
41
+ finding.fix_verified = FixVerifier.verify(finding: finding, root: root) if root
33
42
  finding
34
43
  rescue StandardError
35
44
  finding
@@ -40,10 +49,14 @@ module Scryer
40
49
  # (network-bound work, same pattern as
41
50
  # DependencyAudit.vulnerable_gems) so a large finding count doesn't
42
51
  # 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)
52
+ # callers don't need to check first. Pass `root` (the project root
53
+ # `finding.file` is relative to) to also run fix verification — see
54
+ # `enhance!`; omit it to skip verification entirely (e.g. when the
55
+ # caller has no meaningful root, or doesn't want the extra re-parse
56
+ # work).
57
+ def enhance_result!(result, client: Scryer.configuration.ai_client, concurrency: 4, root: nil)
45
58
  enhance_many!(result.security_findings + result.performance_findings + result.style_findings,
46
- client: client, concurrency: concurrency)
59
+ client: client, concurrency: concurrency, root: root)
47
60
  result
48
61
  end
49
62
 
@@ -51,8 +64,9 @@ module Scryer
51
64
  # Scryer::DependencyAudit::Finding objects, which aren't attached to a
52
65
  # Scanner::Result. Works on any mix of Finding/DependencyAudit::Finding
53
66
  # (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)
67
+ # is configured. `root` is ignored for DependencyAudit::Finding objects
68
+ # (FixVerifier only handles rule-backed Finding — see its guard clause).
69
+ def enhance_many!(findings, client: Scryer.configuration.ai_client, concurrency: 4, root: nil)
56
70
  return findings unless client
57
71
 
58
72
  queue = Queue.new
@@ -68,7 +82,7 @@ module Scryer
68
82
  end
69
83
  break unless finding
70
84
 
71
- enhance!(finding, client: client)
85
+ enhance!(finding, client: client, root: root)
72
86
  end
73
87
  end
74
88
  end
@@ -107,8 +121,14 @@ module Scryer
107
121
 
108
122
  Generic guidance for this rule: #{finding.suggested_fix}
109
123
 
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.
124
+ Reply with a short explanation (1-3 sentences), then a fenced code block showing the
125
+ fix in context if that's useful, and finally as the very last thing in your reply,
126
+ exactly once — a line reading "AFTER:" followed by a fenced code block containing ONLY
127
+ the corrected replacement for the single offending line shown above (line
128
+ #{finding.line}), nothing else in that block (no surrounding context lines, no
129
+ comments about the change). This exact "AFTER:" block is parsed automatically to
130
+ verify the fix actually resolves the finding, so it must be a valid, direct drop-in
131
+ replacement for that one line. Do not restate the issue description.
112
132
  PROMPT
113
133
  end
114
134
 
data/lib/scryer/ast.rb CHANGED
@@ -64,14 +64,17 @@ module Scryer
64
64
  # Returns the receiver node (nil for a bare/vcall) and the method name
65
65
  # string if `node` is a call to one of `method_names`, else nil.
66
66
  #
67
- # Handles the two shapes Ripper produces for a called method:
68
- # [:call, receiver, [:@period,...]|:"::", [:@ident, "name", pos]] (has a receiver)
69
- # [:vcall, [:@ident, "name", pos]] (bare, no args)
70
- # [:command, [:@ident, "name", pos], args] (bare, with args, no parens)
71
- # [:method_add_arg, call_or_fcall_node, args_node] (receiver/bare + parens)
67
+ # Handles the shapes Ripper produces for a called method:
68
+ # [:call, receiver, [:@period,...]|:"::", [:@ident, "name", pos]] (has a receiver, parens or no args)
69
+ # [:vcall, [:@ident, "name", pos]] (bare, no args)
70
+ # [:fcall, [:@ident, "name", pos]] (bare + parens, no receiver)
71
+ # [:command, [:@ident, "name", pos], args] (bare, with args, no parens)
72
+ # [:command_call, receiver, [:@period,...], [:@ident, "name", pos], args] (receiver + args, no parens —
73
+ # e.g. `config.session_store :x, y: z`)
74
+ # [:method_add_arg, call_or_fcall_node, args_node] (receiver/bare + parens, wraps one of the above)
72
75
  def call_name(node)
73
76
  case node
74
- when ->(n) { tagged?(n, :call) }
77
+ when ->(n) { tagged?(n, :call, :command_call) }
75
78
  [node[1], ident_text(node[3])]
76
79
  when ->(n) { tagged?(n, :vcall) }
77
80
  [nil, ident_text(node[1])]
@@ -88,6 +91,32 @@ module Scryer
88
91
  node[1] if node[0].is_a?(Symbol) && %i[@ident @const @kw @op].include?(node[0])
89
92
  end
90
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
+
91
120
  # Given a [:method_add_arg, call_node, args_node] or [:command, ident, args_node]
92
121
  # node, return the flattened list of top-level argument sexp nodes (best effort —
93
122
  # walks through the [:arg_paren, [:args_add_block, [args...], block]] wrapping).
@@ -247,6 +276,66 @@ module Scryer
247
276
  result
248
277
  end
249
278
 
279
+ # Given a list of top-level argument nodes (from call_arguments), finds
280
+ # the bare_assoc_hash entry whose label matches `label:` and returns its
281
+ # value node, or nil if that keyword argument isn't present. Covers
282
+ # Rails DSL calls like `session_store :cookie_store, secure: true` or
283
+ # `http_basic_authenticate_with password: "x"`, where keyword args
284
+ # arrive as a bare_assoc_hash rather than a real Hash literal.
285
+ def keyword_arg(args, label)
286
+ target = "#{label}:"
287
+
288
+ args.each do |arg|
289
+ next unless tagged?(arg, :bare_assoc_hash) && arg[1].is_a?(Array)
290
+
291
+ pair = arg[1].find { |p| tagged?(p, :assoc_new) && p[1].is_a?(Array) && p[1][0] == :@label && p[1][1] == target }
292
+ return pair[2] if pair
293
+ end
294
+
295
+ nil
296
+ end
297
+
298
+ # The literal text of a symbol_literal (`:inline`) or plain string_literal
299
+ # (`"inline"`) node — the two shapes a Rails DSL keyword argument's value
300
+ # commonly takes. nil for anything else (interpolated string, array,
301
+ # boolean, ...).
302
+ def literal_text(node)
303
+ return plain_string_value(node) if tagged?(node, :string_literal)
304
+ return nil unless tagged?(node, :symbol_literal) && node[1].is_a?(Array)
305
+
306
+ ident_text(node[1][1]) if tagged?(node[1], :symbol)
307
+ end
308
+
309
+ # True if `node` is the literal `true`/`false` keyword
310
+ # (`[:var_ref, [:@kw, "true"|"false", pos]]`).
311
+ def true_literal?(node)
312
+ kw_literal?(node) == "true"
313
+ end
314
+
315
+ def false_literal?(node)
316
+ kw_literal?(node) == "false"
317
+ end
318
+
319
+ def kw_literal?(node)
320
+ return nil unless tagged?(node, :var_ref) && node[1].is_a?(Array) && node[1][0] == :@kw
321
+
322
+ node[1][1]
323
+ end
324
+
325
+ # True if `node` is `params`, `params[:x]`, or contains such a reference
326
+ # anywhere in its subtree — the shared "does this touch raw request data"
327
+ # check behind mass assignment, IDOR, SSRF, and path traversal detection.
328
+ # Says nothing about whether that reference is guarded (`.permit`,
329
+ # sanitization, an allowlist, ...) — callers that care about a specific
330
+ # guard (like MassAssignmentRule's `.permit`) check for it separately.
331
+ def references_params?(node)
332
+ return false unless node.is_a?(Array)
333
+
334
+ each_node(node).any? do |n|
335
+ tagged?(n, :vcall, :var_ref, :fcall) && ident_text(n[1]) == "params"
336
+ end
337
+ end
338
+
250
339
  # Walks every terminal token-bearing node inside `node` and maps it to a
251
340
  # normalized symbol: identifiers/literals become placeholders (so renamed
252
341
  # variables/changed literal values still count as "the same" shape),
@@ -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