scryer 1.0.0 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +253 -0
- data/README.md +459 -41
- data/lib/generators/scryer/USAGE +10 -2
- data/lib/generators/scryer/templates/scryer_initializer.rb +15 -0
- data/lib/scryer/ai_fix_suggester.rb +29 -9
- data/lib/scryer/ast.rb +26 -0
- data/lib/scryer/authorization_watcher.rb +156 -0
- data/lib/scryer/baseline.rb +75 -0
- data/lib/scryer/cli.rb +189 -3
- data/lib/scryer/finding.rb +6 -0
- data/lib/scryer/fix_verifier.rb +82 -0
- data/lib/scryer/minitest.rb +48 -0
- data/lib/scryer/performance_rules/inefficient_save_loop_rule.rb +32 -0
- data/lib/scryer/performance_rules/missing_pagination_rule.rb +1 -0
- data/lib/scryer/performance_rules/n_plus_one_query_rule.rb +1 -0
- data/lib/scryer/performance_rules/unbounded_table_scan_rule.rb +1 -0
- data/lib/scryer/report_renderer.rb +539 -46
- data/lib/scryer/rspec.rb +55 -0
- data/lib/scryer/rule.rb +22 -2
- data/lib/scryer/rules/action_cable_forgery_protection_rule.rb +3 -0
- data/lib/scryer/rules/active_storage_inline_disposition_rule.rb +3 -0
- data/lib/scryer/rules/active_storage_missing_content_type_validation_rule.rb +3 -0
- data/lib/scryer/rules/authentication_bypass_rule.rb +30 -7
- data/lib/scryer/rules/command_injection_rule.rb +3 -0
- data/lib/scryer/rules/consider_all_requests_local_rule.rb +51 -0
- data/lib/scryer/rules/cors_misconfiguration_rule.rb +51 -20
- data/lib/scryer/rules/csrf_protection_rule.rb +60 -11
- data/lib/scryer/rules/force_ssl_rule.rb +3 -0
- data/lib/scryer/rules/graphql_missing_query_limits_rule.rb +31 -0
- data/lib/scryer/rules/hardcoded_basic_auth_rule.rb +3 -0
- data/lib/scryer/rules/hardcoded_secret_key_base_rule.rb +3 -0
- data/lib/scryer/rules/hardcoded_secret_rule.rb +3 -0
- data/lib/scryer/rules/host_authorization_disabled_rule.rb +50 -0
- data/lib/scryer/rules/idor_rule.rb +63 -9
- data/lib/scryer/rules/insecure_cookie_serializer_rule.rb +3 -0
- data/lib/scryer/rules/job_raw_params_rule.rb +40 -7
- data/lib/scryer/rules/jwt_insecure_rule.rb +3 -0
- data/lib/scryer/rules/mass_assignment_rule.rb +32 -5
- data/lib/scryer/rules/missing_authorization_rule.rb +103 -0
- data/lib/scryer/rules/missing_policy_scope_rule.rb +134 -0
- data/lib/scryer/rules/open_redirect_rule.rb +3 -0
- data/lib/scryer/rules/path_traversal_rule.rb +22 -1
- data/lib/scryer/rules/security_headers_rule.rb +3 -0
- data/lib/scryer/rules/sql_injection_rule.rb +3 -0
- data/lib/scryer/rules/ssrf_rule.rb +67 -13
- data/lib/scryer/rules/unsafe_deserialization_rule.rb +3 -0
- data/lib/scryer/rules/verbose_production_log_level_rule.rb +53 -0
- data/lib/scryer/rules/weak_crypto_rule.rb +37 -2
- data/lib/scryer/rules/weak_session_cookie_rule.rb +3 -0
- data/lib/scryer/rules/xss_unsafe_html_rule.rb +41 -0
- data/lib/scryer/style_rules/frozen_string_literal_rule.rb +1 -0
- data/lib/scryer/version.rb +1 -1
- data/lib/scryer.rb +15 -0
- data/lib/tasks/scryer.rake +117 -4
- metadata +24 -12
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
111
|
-
|
|
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 " \
|
data/lib/scryer/finding.rb
CHANGED
|
@@ -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
|