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
@@ -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
@@ -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
data/lib/scryer/cli.rb CHANGED
@@ -21,6 +21,12 @@ module Scryer
21
21
  # found (so `scryer -o report.json` can gate CI the way `brakeman -o
22
22
  # report.json` does), 2 on a usage error.
23
23
  def run
24
+ # `scryer verify` is a distinct subcommand, not a flag on the normal
25
+ # scan — it takes its own small option set (--rule/--file/--path) that
26
+ # would collide with the main parser's -p/-o meanings, so it's
27
+ # dispatched before the main OptionParser ever sees the rest of argv.
28
+ return run_verify(@argv[1..]) if @argv.first == "verify"
29
+
24
30
  options = parse(@argv)
25
31
  return 0 if options[:exit_early]
26
32
 
@@ -54,9 +60,16 @@ module Scryer
54
60
  DependencyAudit.ruby_eol_check(root) + DependencyAudit.credentials_exposure_check(root)
55
61
  end
56
62
 
63
+ return save_baseline(options[:save_baseline], result, dependency_findings) if options[:save_baseline]
64
+
65
+ fixed_count = 0
66
+ if options[:baseline]
67
+ dependency_findings, fixed_count = apply_baseline(options[:baseline], result, dependency_findings)
68
+ end
69
+
57
70
  if Scryer.configuration.ai_client
58
71
  @stdout.puts "Scryer: rewriting suggested fixes via the configured AI client..."
59
- AiFixSuggester.enhance_result!(result)
72
+ AiFixSuggester.enhance_result!(result, root: root)
60
73
  AiFixSuggester.enhance_many!(dependency_findings) unless dependency_findings.empty?
61
74
  end
62
75
 
@@ -72,7 +85,8 @@ module Scryer
72
85
  outputs = options[:outputs].empty? ? default_outputs(root) : options[:outputs]
73
86
  outputs.each { |path| write_report(renderer, path) }
74
87
 
75
- print_summary(result: result, dependency_findings: dependency_findings, ran_deps: ran_deps, outputs: outputs)
88
+ print_summary(result: result, dependency_findings: dependency_findings, ran_deps: ran_deps, outputs: outputs,
89
+ renderer: renderer, fixed_count: fixed_count, baseline_path: options[:baseline])
76
90
 
77
91
  result.security_findings.empty? && dependency_findings.empty? ? 0 : 1
78
92
  rescue UsageError => e
@@ -118,7 +132,7 @@ module Scryer
118
132
  # category Scryer covers (security, performance, duplicate/smelly code,
119
133
  # dependencies), the same categories usually split across RuboCop +
120
134
  # Brakeman + bundler-audit + Reek, side by side in one box.
121
- def print_summary(result:, dependency_findings:, ran_deps:, outputs:)
135
+ def print_summary(result:, dependency_findings:, ran_deps:, outputs:, renderer:, fixed_count: 0, baseline_path: nil)
122
136
  # "Code Quality" is the umbrella label for both duplicate-code groups
123
137
  # and rule-based style findings (e.g. frozen_string_literal) — two
124
138
  # different detectors, same broad concern, one row in the box.
@@ -134,14 +148,26 @@ module Scryer
134
148
  ]
135
149
 
136
150
  divider = "─" * 32
151
+ score = renderer.security_score
137
152
  @stdout.puts ""
138
153
  @stdout.puts "Scryer Audit — #{result.files_scanned} files scanned"
139
154
  @stdout.puts divider
140
155
  @stdout.puts ""
156
+ if baseline_path
157
+ @stdout.puts "Baseline: #{baseline_path} — showing new findings only " \
158
+ "(#{fixed_count} fixed since baseline)."
159
+ @stdout.puts ""
160
+ end
161
+ clean_rate = renderer.rules_clean_rate
162
+ @stdout.puts "Security Score: #{score["score"]}/100 (#{score["grade"]})"
163
+ @stdout.puts "Checks: #{clean_rate["clean"]}/#{clean_rate["total"]} rules clean (#{clean_rate["percent"]}%)"
164
+ @stdout.puts ""
141
165
  rows.each { |label, count| @stdout.puts summary_row(label, count) }
142
166
  @stdout.puts divider
143
167
  @stdout.puts summary_row("Total", total)
144
168
  @stdout.puts ""
169
+ print_top_priorities(renderer.top_risks)
170
+ print_owasp_coverage(renderer.owasp_coverage)
145
171
  outputs.each { |path| @stdout.puts "#{format_for(path).upcase} report: #{path}" }
146
172
  end
147
173
 
@@ -150,6 +176,99 @@ module Scryer
150
176
  "#{label.ljust(14)}#{value.rjust(20)}"
151
177
  end
152
178
 
179
+ # The categories above are counted separately, but nothing else ranks
180
+ # across them — this is what actually backs "tells you what to fix
181
+ # first" rather than just splitting findings into four buckets. Same
182
+ # data ReportRenderer#top_risks already sorts for the HTML report.
183
+ def print_top_priorities(risks)
184
+ return if risks.empty?
185
+
186
+ @stdout.puts "Top priorities:"
187
+ risks.each_with_index do |r, i|
188
+ @stdout.puts " #{i + 1}. [#{r[:severity]}] #{r[:category]} — #{r[:label]} (#{r[:location]})"
189
+ end
190
+ @stdout.puts ""
191
+ end
192
+
193
+ # Byproduct of every security rule carrying an owasp_category — see
194
+ # ReportRenderer#owasp_coverage. Scryer's own best-effort tagging, not an
195
+ # OWASP-audited mapping (documented in full in the README).
196
+ def print_owasp_coverage(coverage)
197
+ return if coverage.empty?
198
+
199
+ @stdout.puts "OWASP Top 10 (2021) coverage:"
200
+ coverage.each { |category, count| @stdout.puts " #{category}: #{count} finding#{"s" unless count == 1}" }
201
+ @stdout.puts ""
202
+ end
203
+
204
+ # `scryer verify --rule RULE_ID --file PATH` — re-parses just that one
205
+ # file and re-runs just that one rule against it, independent of a full
206
+ # scan. Meant to run right after applying a fix (by hand or via an LLM)
207
+ # to confirm the specific finding it targeted is actually gone, without
208
+ # waiting on/paying for a full project scan. Deliberately narrower than
209
+ # "did this fix introduce a NEW finding elsewhere" — that's what a normal
210
+ # `scryer` run (or `--baseline`) already answers; this only answers "does
211
+ # the one thing I just tried to fix still fire."
212
+ def run_verify(argv)
213
+ options = {}
214
+
215
+ parser = OptionParser.new do |opts|
216
+ opts.banner = "Usage: scryer verify --rule RULE_ID --file PATH [--path ROOT]"
217
+ opts.on("--rule RULE_ID", "The rule_id to re-check (required) — see `scryer verify --list-rules`.") { |v| options[:rule] = v }
218
+ opts.on("--file PATH", "File to re-scan (required) — relative to --path, or absolute.") { |v| options[:file] = v }
219
+ opts.on("--path ROOT", "Project root PATH is relative to (default: current directory).") { |v| options[:root] = v }
220
+ opts.on("--list-rules", "List every known rule_id and exit.") { options[:list_rules] = true }
221
+ opts.on("-h", "--help", "Show this help.") { options[:exit_early] = true; @stdout.puts opts }
222
+ end
223
+
224
+ begin
225
+ parser.parse!(argv)
226
+ rescue OptionParser::ParseError => e
227
+ raise UsageError, "#{e.message}\n#{parser}"
228
+ end
229
+ return 0 if options[:exit_early]
230
+
231
+ if options[:list_rules]
232
+ RuleSet.all.map(&:rule_id).sort.each { |id| @stdout.puts id }
233
+ return 0
234
+ end
235
+
236
+ raise UsageError, "scryer verify needs --rule RULE_ID and --file PATH\n#{parser}" unless options[:rule] && options[:file]
237
+
238
+ rule_class = RuleSet.all.find { |r| r.rule_id == options[:rule] }
239
+ unless rule_class
240
+ raise UsageError, "unknown rule_id #{options[:rule].inspect} — run `scryer verify --list-rules` to see valid ids."
241
+ end
242
+
243
+ root = File.expand_path(options[:root] || Dir.pwd)
244
+ abs_path = File.expand_path(options[:file], root)
245
+ raise UsageError, "no such file: #{abs_path}" unless File.file?(abs_path)
246
+
247
+ rel_path = abs_path.sub(/\A#{Regexp.escape(root)}\/?/, "")
248
+ source = File.read(abs_path)
249
+
250
+ sexp = begin
251
+ Ripper.sexp(source)
252
+ rescue StandardError => e
253
+ raise UsageError, "#{rel_path} failed to parse: #{e.message}"
254
+ end
255
+ if sexp.nil?
256
+ raise UsageError, "#{rel_path} could not be parsed (a syntax error, or Ruby syntax newer " \
257
+ "than this gem's Ruby runtime supports)."
258
+ end
259
+
260
+ findings = rule_class.new(file: rel_path, source: source, sexp: sexp).scan
261
+
262
+ if findings.empty?
263
+ @stdout.puts "scryer verify: #{options[:rule]} no longer fires on #{rel_path} — fix verified."
264
+ 0
265
+ else
266
+ @stdout.puts "scryer verify: #{options[:rule]} still fires on #{rel_path} (#{findings.size} finding(s)):"
267
+ findings.each { |f| @stdout.puts " line #{f.line}: #{f.message}" }
268
+ 1
269
+ end
270
+ end
271
+
153
272
  # One-off OSV.dev lookup for a single gem — no Gemfile.lock, no scan,
154
273
  # no other network calls. `spec` is "name" or "name:version" (colon
155
274
  # rather than a second CLI arg, so this stays a single -o-style flag).
@@ -169,6 +288,62 @@ module Scryer
169
288
  findings.empty? ? 0 : 1
170
289
  end
171
290
 
291
+ # `--save-baseline PATH` is a distinct mode, same as --audit-deps/
292
+ # --check-gem: it captures every finding across every category (not
293
+ # just security — a legacy app's existing performance/style debt is
294
+ # just as much "not what I'm here to re-litigate today" as its security
295
+ # debt), writes the fingerprints, and exits without writing the normal
296
+ # -o reports. See Scryer::Baseline for why fingerprints, not file:line.
297
+ def save_baseline(path, result, dependency_findings)
298
+ all_findings = (result.security_findings + result.performance_findings + result.style_findings)
299
+ .map(&:to_h) + dependency_findings.map(&:to_h)
300
+ Baseline.save(path, all_findings)
301
+ @stdout.puts "Scryer: saved baseline of #{all_findings.size} finding(s) to #{path}."
302
+ 0
303
+ end
304
+
305
+ # Filters `result`'s finding arrays (mutated in place — Result is a
306
+ # plain Struct, this is the same object the caller already holds) and
307
+ # returns [new_dependency_findings, fixed_count] since dependency_findings
308
+ # is a local array in the caller, not a field this method can mutate by
309
+ # reference the way it can Struct fields.
310
+ # `fixed_count` has to be computed ONCE against the union of every
311
+ # category's current fingerprints, not once per category summed
312
+ # together — Baseline.diff's fixed_count is "baseline fingerprints not
313
+ # present in *this* call's findings," so calling it separately per
314
+ # category and summing would count every other category's
315
+ # still-present findings as "fixed" too (verified: this exact bug
316
+ # produced a nonsensical "762 fixed" on a rescan with zero changes,
317
+ # against a 255-finding baseline — fixed by computing fixed_count from
318
+ # the combined set once, while still filtering "new" per category since
319
+ # that part only checks baseline membership, which is fine to do
320
+ # separately).
321
+ def apply_baseline(path, result, dependency_findings)
322
+ baseline_fingerprints = Baseline.load(path)
323
+
324
+ security_hashes = result.security_findings.map(&:to_h)
325
+ performance_hashes = result.performance_findings.map(&:to_h)
326
+ style_hashes = result.style_findings.map(&:to_h)
327
+ dependency_hashes = dependency_findings.map(&:to_h)
328
+
329
+ all_current_fingerprints = Baseline.fingerprints(security_hashes + performance_hashes + style_hashes + dependency_hashes)
330
+ fixed_count = (baseline_fingerprints - all_current_fingerprints.to_set).size
331
+
332
+ result.security_findings = filter_new(result.security_findings, security_hashes, baseline_fingerprints)
333
+ result.performance_findings = filter_new(result.performance_findings, performance_hashes, baseline_fingerprints)
334
+ result.style_findings = filter_new(result.style_findings, style_hashes, baseline_fingerprints)
335
+ filtered_deps = filter_new(dependency_findings, dependency_hashes, baseline_fingerprints)
336
+
337
+ [filtered_deps, fixed_count]
338
+ rescue Baseline::LoadError => e
339
+ raise UsageError, e.message
340
+ end
341
+
342
+ def filter_new(objects, hashes, baseline_fingerprints)
343
+ fingerprints = Baseline.fingerprints(hashes)
344
+ objects.each_with_index.reject { |_, i| baseline_fingerprints.include?(fingerprints[i]) }.map(&:first)
345
+ end
346
+
172
347
  def parse(argv)
173
348
  options = { outputs: [] }
174
349
 
@@ -189,6 +364,17 @@ module Scryer
189
364
  "insecure git/http sources (offline), instead of running the normal static scan. " \
190
365
  "Exits non-zero if anything is found, so this can gate CI the same way " \
191
366
  "`bundle-audit check` does.") { options[:audit_deps] = true }
367
+ opts.on("--save-baseline PATH",
368
+ "Run the normal scan, save every finding's fingerprint to PATH, then exit — no " \
369
+ "reports written. A later `scryer --baseline PATH` scan reports only findings " \
370
+ "new since this snapshot, so an app with existing security debt can gate CI on " \
371
+ "new issues without being forced to fix everything on day one.") { |v| options[:save_baseline] = v }
372
+ opts.on("--baseline PATH",
373
+ "Compare this scan against a baseline saved by --save-baseline: every report " \
374
+ "(-o files, console summary, exit code) reflects only findings new since PATH " \
375
+ "was saved. Fingerprints ignore line number (rule + file + offending code), so " \
376
+ "an unrelated edit elsewhere in the file won't make an existing finding look " \
377
+ "new.") { |v| options[:baseline] = v }
192
378
  opts.on("--no-deps",
193
379
  "Skip the dependency audit (OSV.dev vulnerable gems + insecure git/http sources) " \
194
380
  "that otherwise runs as part of every normal scan. Use this for a fast, fully " \
@@ -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