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