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
|
@@ -11,6 +11,9 @@ module Scryer
|
|
|
11
11
|
self.category = "security"
|
|
12
12
|
self.default_severity = "critical"
|
|
13
13
|
self.title = "Possible server-side request forgery (SSRF)"
|
|
14
|
+
self.cwe = "CWE-918"
|
|
15
|
+
self.owasp_category = "A10:2021-Server-Side Request Forgery"
|
|
16
|
+
self.confidence = "medium"
|
|
14
17
|
|
|
15
18
|
DANGEROUS_CALLS = {
|
|
16
19
|
"Net::HTTP" => %w[get get_response post post_form],
|
|
@@ -34,21 +37,40 @@ module Scryer
|
|
|
34
37
|
next unless dangerous_call?(receiver, method_name)
|
|
35
38
|
|
|
36
39
|
args = Ast.call_arguments(node)
|
|
37
|
-
|
|
40
|
+
matched_arg = args.find { |a| Ast.references_params?(a) }
|
|
41
|
+
next unless matched_arg
|
|
38
42
|
|
|
39
43
|
line = Ast.line_of(node)
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
44
|
+
if (fixed_host = fixed_host_with_dynamic_path(matched_arg))
|
|
45
|
+
findings << finding(
|
|
46
|
+
line: line,
|
|
47
|
+
severity: "warning",
|
|
48
|
+
message: "`#{describe_call(receiver, method_name)}` is called with a URL whose host " \
|
|
49
|
+
"(`#{fixed_host}`) is a fixed literal — only a path/query segment is " \
|
|
50
|
+
"`params`-derived, so this can't redirect the request to an arbitrary " \
|
|
51
|
+
"attacker-chosen host the way a fully dynamic URL could. Still worth a " \
|
|
52
|
+
"look: a params-derived path segment can enumerate/probe endpoints on " \
|
|
53
|
+
"that fixed host, or (if the target app doesn't validate it either) reach " \
|
|
54
|
+
"unintended paths there.",
|
|
55
|
+
suggested_fix: "If the path segment is meant to be one of a known set of values " \
|
|
56
|
+
"(an id, an enum), validate/allowlist it rather than interpolating " \
|
|
57
|
+
"`params` straight into the URL — a stray `/` or `..` segment can " \
|
|
58
|
+
"still change which path on #{fixed_host} gets requested."
|
|
59
|
+
)
|
|
60
|
+
else
|
|
61
|
+
findings << finding(
|
|
62
|
+
line: line,
|
|
63
|
+
message: "`#{describe_call(receiver, method_name)}` is called with a URL/argument " \
|
|
64
|
+
"that references `params` — the server can be made to issue a request to " \
|
|
65
|
+
"any host an attacker chooses, including internal services and cloud " \
|
|
66
|
+
"metadata endpoints (e.g. `169.254.169.254`) that aren't meant to be " \
|
|
67
|
+
"reachable from outside.",
|
|
68
|
+
suggested_fix: "Validate the destination against an allowlist of known-safe hosts " \
|
|
69
|
+
"before making the request, rather than passing the user-supplied " \
|
|
70
|
+
"value straight through — and reject internal/link-local IP ranges " \
|
|
71
|
+
"explicitly if the allowlist is host-based (DNS can still resolve to one)."
|
|
72
|
+
)
|
|
73
|
+
end
|
|
52
74
|
end
|
|
53
75
|
|
|
54
76
|
findings
|
|
@@ -67,6 +89,38 @@ module Scryer
|
|
|
67
89
|
name ? "#{name}.#{method_name}" : method_name
|
|
68
90
|
end
|
|
69
91
|
|
|
92
|
+
# A URL's host is fully attacker-controlled the moment `params` reaches
|
|
93
|
+
# any part of it — but a `params` reference confined to the *path* of
|
|
94
|
+
# an otherwise-literal URL (`"https://api.example.com/users/#{params[:id]}"`)
|
|
95
|
+
# can't redirect the request to a different host at all, which is the
|
|
96
|
+
# actual SSRF risk (reaching internal services / cloud metadata
|
|
97
|
+
# endpoints). Distinguishing the two isn't possible in general — a URL
|
|
98
|
+
# built via string concatenation, a helper method, `URI.join`, or a
|
|
99
|
+
# variable can put `params` anywhere including the host, and this rule
|
|
100
|
+
# has no way to trace that. This only recognizes the one shape where
|
|
101
|
+
# it's unambiguous: a direct string_literal argument whose *first*
|
|
102
|
+
# literal chunk (everything before the first interpolation) already
|
|
103
|
+
# spells out a complete `scheme://host/` — meaning every `params`
|
|
104
|
+
# reference in the string necessarily lands after that fixed host, in
|
|
105
|
+
# the path/query. Returns the literal host text for the message, or
|
|
106
|
+
# nil if this argument doesn't match that exact shape (in which case
|
|
107
|
+
# the call is treated with full SSRF severity, same as before this
|
|
108
|
+
# existed — this only *adds* a more precise, lower-severity message
|
|
109
|
+
# for the case that's provably narrower, it never suppresses the
|
|
110
|
+
# finding).
|
|
111
|
+
def fixed_host_with_dynamic_path(node)
|
|
112
|
+
return nil unless Ast.tagged?(node, :string_literal)
|
|
113
|
+
|
|
114
|
+
content = node[1]
|
|
115
|
+
return nil unless Ast.tagged?(content, :string_content)
|
|
116
|
+
|
|
117
|
+
first_chunk = content[1]
|
|
118
|
+
return nil unless Ast.tagged?(first_chunk, :@tstring_content)
|
|
119
|
+
|
|
120
|
+
match = /\A(https?:\/\/[^\/\s{}]+)\//.match(first_chunk[1])
|
|
121
|
+
match && match[1]
|
|
122
|
+
end
|
|
123
|
+
|
|
70
124
|
# Textual name of a (possibly namespaced) constant receiver —
|
|
71
125
|
# "URI" for `[:var_ref, [:@const, "URI", pos]]`, "Net::HTTP" for the
|
|
72
126
|
# `[:const_path_ref, ...]` chain `Net::HTTP` parses into. nil for
|
|
@@ -9,6 +9,9 @@ module Scryer
|
|
|
9
9
|
self.category = "security"
|
|
10
10
|
self.default_severity = "critical"
|
|
11
11
|
self.title = "Unsafe deserialization of untrusted data"
|
|
12
|
+
self.cwe = "CWE-502"
|
|
13
|
+
self.owasp_category = "A08:2021-Software and Data Integrity Failures"
|
|
14
|
+
self.confidence = "high"
|
|
12
15
|
|
|
13
16
|
UNSAFE_CALLS = {
|
|
14
17
|
%w[Marshal load] => "Marshal.load can instantiate arbitrary Ruby objects, including ones " \
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
module Scryer
|
|
2
|
+
module Rules
|
|
3
|
+
# Flags `config.log_level = :debug` specifically in
|
|
4
|
+
# config/environments/production.rb. Debug-level Rails logging can write
|
|
5
|
+
# full request parameters (including anything not filtered by
|
|
6
|
+
# `config.filter_parameters`) and raw SQL bind values to the production
|
|
7
|
+
# log — wherever that log ends up (a shared file, a log-aggregation
|
|
8
|
+
# service), that's a broader-than-intended audience for potentially
|
|
9
|
+
# sensitive data. `:debug` is Rails' own default in development.rb, so
|
|
10
|
+
# it's only flagged in production.rb; other levels (`:info`, `:warn`)
|
|
11
|
+
# aren't flagged at all — those don't carry this risk.
|
|
12
|
+
class VerboseProductionLogLevelRule < Rule
|
|
13
|
+
self.rule_id = "verbose_production_log_level"
|
|
14
|
+
self.category = "security"
|
|
15
|
+
self.default_severity = "warning"
|
|
16
|
+
self.title = "Debug-level logging enabled in production"
|
|
17
|
+
self.cwe = "CWE-532"
|
|
18
|
+
self.owasp_category = "A09:2021-Security Logging and Monitoring Failures"
|
|
19
|
+
self.confidence = "medium"
|
|
20
|
+
|
|
21
|
+
PRODUCTION_ENV_FILE = "config/environments/production.rb"
|
|
22
|
+
|
|
23
|
+
def scan
|
|
24
|
+
return [] unless file.to_s.end_with?(PRODUCTION_ENV_FILE)
|
|
25
|
+
|
|
26
|
+
findings = []
|
|
27
|
+
|
|
28
|
+
Ast.each_node(sexp) do |node|
|
|
29
|
+
next unless Ast.tagged?(node, :assign)
|
|
30
|
+
|
|
31
|
+
target = node[1]
|
|
32
|
+
next unless Ast.tagged?(target, :field)
|
|
33
|
+
next unless Ast.ident_text(target[3]) == "log_level"
|
|
34
|
+
next unless Ast.literal_text(node[2]) == "debug"
|
|
35
|
+
|
|
36
|
+
findings << finding(
|
|
37
|
+
line: Ast.line_of(node),
|
|
38
|
+
message: "`config.log_level = :debug` in #{PRODUCTION_ENV_FILE} logs full request " \
|
|
39
|
+
"parameters and raw SQL bind values in production — anything not covered " \
|
|
40
|
+
"by `config.filter_parameters` (e.g. a param name added after that list was " \
|
|
41
|
+
"last updated) ends up in the log verbatim.",
|
|
42
|
+
suggested_fix: "Use `:info` (Rails' own production default) or higher in production, " \
|
|
43
|
+
"and confirm `config.filter_parameters` covers every sensitive param " \
|
|
44
|
+
"name this app actually receives if verbose logging is genuinely needed " \
|
|
45
|
+
"for debugging."
|
|
46
|
+
)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
findings
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -11,9 +11,21 @@ module Scryer
|
|
|
11
11
|
self.category = "security"
|
|
12
12
|
self.default_severity = "warning"
|
|
13
13
|
self.title = "Weak hash algorithm used for password/credential hashing"
|
|
14
|
+
self.cwe = "CWE-327"
|
|
15
|
+
self.owasp_category = "A02:2021-Cryptographic Failures"
|
|
16
|
+
self.confidence = "medium"
|
|
14
17
|
|
|
15
18
|
WEAK_DIGESTS = %w[MD5 SHA1].freeze
|
|
16
|
-
|
|
19
|
+
|
|
20
|
+
# `(?!less)` after "password" excludes "passwordless" (and
|
|
21
|
+
# "passwordlessly") specifically — a real identifier that means the
|
|
22
|
+
# *opposite* of what this heuristic is looking for (e.g. a magic-link
|
|
23
|
+
# or passwordless-auth token, `Digest::SHA1.hexdigest(passwordless_token)`),
|
|
24
|
+
# not a credential. It doesn't affect any legitimate match: every other
|
|
25
|
+
# "password"-containing identifier this heuristic cares about
|
|
26
|
+
# (`password`, `user_password`, `password_reset_token`, `hashed_password`,
|
|
27
|
+
# ...) is never immediately followed by the literal substring "less".
|
|
28
|
+
PASSWORD_HINT = /password(?!less)|passwd|credential/i.freeze
|
|
17
29
|
|
|
18
30
|
def scan
|
|
19
31
|
findings = []
|
|
@@ -29,7 +41,7 @@ module Scryer
|
|
|
29
41
|
# non-credential uses (cache keys, ETags, checksums) that shouldn't
|
|
30
42
|
# be flagged as a crypto weakness.
|
|
31
43
|
line = Ast.line_of(node)
|
|
32
|
-
context_line = Ast.source_line(source, line).to_s
|
|
44
|
+
context_line = strip_trailing_comment(Ast.source_line(source, line).to_s)
|
|
33
45
|
next unless PASSWORD_HINT.match?(context_line)
|
|
34
46
|
|
|
35
47
|
findings << finding(
|
|
@@ -49,6 +61,29 @@ module Scryer
|
|
|
49
61
|
|
|
50
62
|
private
|
|
51
63
|
|
|
64
|
+
# Strips a trailing `# ...` line comment before running PASSWORD_HINT
|
|
65
|
+
# against the raw source line. Without this, a defensive comment that
|
|
66
|
+
# *disclaims* password use (`Digest::SHA1.hexdigest(file_content) #
|
|
67
|
+
# cache key, not a password hash` — a natural thing to write specifically
|
|
68
|
+
# to preempt this exact kind of static-analysis false positive) still
|
|
69
|
+
# contains the substring "password" and would trip the heuristic just
|
|
70
|
+
# as hard as a genuine mention. This does give up the (much rarer) case
|
|
71
|
+
# where a comment is the *only* signal — e.g. an unnamed argument whose
|
|
72
|
+
# only password-ish hint is a comment describing it — but real
|
|
73
|
+
# password-hashing call sites almost always also have a password-ish
|
|
74
|
+
# identifier in the actual code (`password`, `user.password`, ...),
|
|
75
|
+
# which stays visible on the line after stripping the comment.
|
|
76
|
+
#
|
|
77
|
+
# Deliberately simple rather than a full tokenizer: finds the first `#`
|
|
78
|
+
# that isn't the start of a `#{` string interpolation and treats
|
|
79
|
+
# everything from there as comment. This means a literal `#` inside a
|
|
80
|
+
# string argument on the same line (e.g. `hexdigest("score: #1")`) would
|
|
81
|
+
# be mistaken for a comment start too — an accepted limitation of a
|
|
82
|
+
# same-line text heuristic, not a full parse.
|
|
83
|
+
def strip_trailing_comment(line)
|
|
84
|
+
line.sub(/#(?!\{).*\z/, "")
|
|
85
|
+
end
|
|
86
|
+
|
|
52
87
|
# Matches `Digest::MD5` / `Digest::SHA1` referenced as a constant path.
|
|
53
88
|
def digest_algorithm_name(node)
|
|
54
89
|
text = Ast.each_node(node)
|
|
@@ -10,6 +10,9 @@ module Scryer
|
|
|
10
10
|
self.category = "security"
|
|
11
11
|
self.default_severity = "warning"
|
|
12
12
|
self.title = "Session cookie missing the secure flag"
|
|
13
|
+
self.cwe = "CWE-614"
|
|
14
|
+
self.owasp_category = "A05:2021-Security Misconfiguration"
|
|
15
|
+
self.confidence = "high"
|
|
13
16
|
|
|
14
17
|
def scan
|
|
15
18
|
findings = []
|
|
@@ -10,6 +10,33 @@ module Scryer
|
|
|
10
10
|
self.category = "security"
|
|
11
11
|
self.default_severity = "warning"
|
|
12
12
|
self.title = "Unescaped HTML output (possible XSS)"
|
|
13
|
+
self.cwe = "CWE-79"
|
|
14
|
+
self.owasp_category = "A03:2021-Injection"
|
|
15
|
+
self.confidence = "high"
|
|
16
|
+
|
|
17
|
+
# Rails helpers whose whole job is to hand back HTML that's already
|
|
18
|
+
# safe to render unescaped, so a `.html_safe` immediately wrapped
|
|
19
|
+
# around a call to one of these isn't the same risk as calling it on
|
|
20
|
+
# raw user input:
|
|
21
|
+
# - `sanitize(x)` strips to an explicit allow-list of tags/attrs —
|
|
22
|
+
# it's the standard sanitize-then-mark-safe idiom this rule exists
|
|
23
|
+
# to steer people *toward*, so flagging it would contradict our
|
|
24
|
+
# own suggested fix.
|
|
25
|
+
# - `strip_tags(x)` removes all markup, so there's no HTML left to
|
|
26
|
+
# inject.
|
|
27
|
+
# - `simple_format(x)` runs the text through `sanitize` internally by
|
|
28
|
+
# default (it only skips that when called with an explicit
|
|
29
|
+
# `sanitize: false` option, which we don't special-case here).
|
|
30
|
+
# - `t(...)`/`translate(...)` pulls from the app's own locale files,
|
|
31
|
+
# not attacker-controlled request data — translators, not users,
|
|
32
|
+
# write that content, so this is Rails' own common "trusted copy"
|
|
33
|
+
# idiom rather than a raw-input passthrough.
|
|
34
|
+
# This says nothing about the *argument* passed to these methods being
|
|
35
|
+
# safe on its own — it's specifically the combination of "wrapped in
|
|
36
|
+
# one of these calls, then marked html_safe" that's the recognized
|
|
37
|
+
# pattern. A bare `params[:bio].html_safe` or an interpolated string
|
|
38
|
+
# marked safe still flags, since neither goes through any of these.
|
|
39
|
+
SANITIZING_METHODS = %w[sanitize strip_tags simple_format t translate].freeze
|
|
13
40
|
|
|
14
41
|
def scan
|
|
15
42
|
findings = []
|
|
@@ -62,9 +89,23 @@ module Scryer
|
|
|
62
89
|
def safe_literal?(node)
|
|
63
90
|
return true if node.nil?
|
|
64
91
|
return true if Ast.tagged?(node, :string_literal) && !Ast.string_literal_has_interpolation?(node)
|
|
92
|
+
return true if sanitizing_call?(node)
|
|
65
93
|
|
|
66
94
|
false
|
|
67
95
|
end
|
|
96
|
+
|
|
97
|
+
# True if `node` is a call (parenthesized or bare) to one of
|
|
98
|
+
# SANITIZING_METHODS — see that constant's comment for why those
|
|
99
|
+
# specific methods are exempt.
|
|
100
|
+
def sanitizing_call?(node)
|
|
101
|
+
inner = Ast.tagged?(node, :method_add_arg) ? node[1] : node
|
|
102
|
+
return false unless Ast.tagged?(inner, :call, :command_call, :vcall, :fcall, :command)
|
|
103
|
+
|
|
104
|
+
name_pair = Ast.call_name(inner)
|
|
105
|
+
return false unless name_pair
|
|
106
|
+
|
|
107
|
+
SANITIZING_METHODS.include?(name_pair[1])
|
|
108
|
+
end
|
|
68
109
|
end
|
|
69
110
|
end
|
|
70
111
|
end
|
data/lib/scryer/version.rb
CHANGED
data/lib/scryer.rb
CHANGED
|
@@ -10,7 +10,9 @@ require "scryer/duplicate_detector"
|
|
|
10
10
|
require "scryer/scanner"
|
|
11
11
|
require "scryer/report_renderer"
|
|
12
12
|
require "scryer/dependency_audit"
|
|
13
|
+
require "scryer/baseline"
|
|
13
14
|
require "scryer/ai_client"
|
|
15
|
+
require "scryer/fix_verifier"
|
|
14
16
|
require "scryer/ai_fix_suggester"
|
|
15
17
|
|
|
16
18
|
Dir[File.join(__dir__, "scryer", "rules", "*.rb")].sort.each { |f| require f }
|
|
@@ -53,6 +55,19 @@ module Scryer
|
|
|
53
55
|
def configuration
|
|
54
56
|
@configuration ||= Configuration.new
|
|
55
57
|
end
|
|
58
|
+
|
|
59
|
+
# Runs the static scan with the current configuration (or explicit
|
|
60
|
+
# overrides) applied, without needing to know Scanner's own constructor
|
|
61
|
+
# shape. Exists mainly so the RSpec/Minitest test helpers (see
|
|
62
|
+
# lib/scryer/rspec.rb / lib/scryer/minitest.rb) — and any other future
|
|
63
|
+
# caller that just wants "the result of a normal scan" — don't each
|
|
64
|
+
# duplicate `Scanner.new(root:, dirs:, skip_rules:).call`. The CLI/rake
|
|
65
|
+
# task aren't changed to use this (they also handle dependency auditing,
|
|
66
|
+
# baselines, and report writing inline) — this is for callers that only
|
|
67
|
+
# need the static-scan Result itself.
|
|
68
|
+
def scan(root:, dirs: configuration.dirs, skip_rules: configuration.skip_rules)
|
|
69
|
+
Scryer::Scanner.new(root: root, dirs: dirs, skip_rules: skip_rules).call
|
|
70
|
+
end
|
|
56
71
|
end
|
|
57
72
|
end
|
|
58
73
|
|
data/lib/tasks/scryer.rake
CHANGED
|
@@ -16,7 +16,9 @@ namespace :scryer do
|
|
|
16
16
|
"so pass each token as its own item rather than one comma-joined string. " \
|
|
17
17
|
"e.g. rails scryer:report, rails 'scryer:report[html]', " \
|
|
18
18
|
"rails 'scryer:report[json,doc/security.json]', rails 'scryer:report[json,html]', " \
|
|
19
|
-
"rails 'scryer:report[html,nodeps]', rails 'scryer:report[csv]', rails 'scryer:report[sarif]'"
|
|
19
|
+
"rails 'scryer:report[html,nodeps]', rails 'scryer:report[csv]', rails 'scryer:report[sarif]'. " \
|
|
20
|
+
"Set SCRYER_BASELINE=PATH (an env var, not a bracket arg — see scryer:save_baseline) to " \
|
|
21
|
+
"report only findings new since that baseline was saved."
|
|
20
22
|
task :report, [:format] do |_, args|
|
|
21
23
|
root = defined?(Rails) ? Rails.root.to_s : Dir.pwd
|
|
22
24
|
dirs = Scryer.configuration.dirs
|
|
@@ -42,9 +44,23 @@ namespace :scryer do
|
|
|
42
44
|
Scryer::DependencyAudit.ruby_eol_check(root) + Scryer::DependencyAudit.credentials_exposure_check(root)
|
|
43
45
|
end
|
|
44
46
|
|
|
47
|
+
# Rake bracket args are already stretched thin (format tokens + nodeps +
|
|
48
|
+
# one output path) — an ENV var is the more conventional way Rails rake
|
|
49
|
+
# tasks take an extra config value without overloading positional args
|
|
50
|
+
# further. `rails 'scryer:save_baseline[tmp/scryer_baseline.json]'` writes
|
|
51
|
+
# one; `SCRYER_BASELINE=tmp/scryer_baseline.json rails scryer:report`
|
|
52
|
+
# reads it back. See Scryer::Baseline / the CLI's --baseline flag for
|
|
53
|
+
# what this actually does (fingerprint-based new-vs-fixed diffing, not
|
|
54
|
+
# tied to line number).
|
|
55
|
+
baseline_path = blank_to_nil(ENV["SCRYER_BASELINE"])
|
|
56
|
+
fixed_count = 0
|
|
57
|
+
if baseline_path
|
|
58
|
+
dependency_findings, fixed_count = ScryerTasks.apply_baseline(baseline_path, result, dependency_findings)
|
|
59
|
+
end
|
|
60
|
+
|
|
45
61
|
if Scryer.configuration.ai_client
|
|
46
62
|
puts "Scryer: rewriting suggested fixes via the configured AI client..."
|
|
47
|
-
Scryer::AiFixSuggester.enhance_result!(result)
|
|
63
|
+
Scryer::AiFixSuggester.enhance_result!(result, root: root)
|
|
48
64
|
Scryer::AiFixSuggester.enhance_many!(dependency_findings) unless dependency_findings.empty?
|
|
49
65
|
end
|
|
50
66
|
|
|
@@ -70,7 +86,8 @@ namespace :scryer do
|
|
|
70
86
|
File.write(path, content)
|
|
71
87
|
end
|
|
72
88
|
|
|
73
|
-
ScryerTasks.print_summary(result: result, dependency_findings: dependency_findings, ran_deps: run_deps, paths: paths
|
|
89
|
+
ScryerTasks.print_summary(result: result, dependency_findings: dependency_findings, ran_deps: run_deps, paths: paths,
|
|
90
|
+
renderer: renderer, fixed_count: fixed_count, baseline_path: baseline_path)
|
|
74
91
|
|
|
75
92
|
# Same gate as the `scryer` executable and scryer:audit_dependencies
|
|
76
93
|
# below: fail the task (and so the CI job running it) on any security or
|
|
@@ -83,6 +100,37 @@ namespace :scryer do
|
|
|
83
100
|
end
|
|
84
101
|
end
|
|
85
102
|
|
|
103
|
+
desc "Convenience wrapper for CI: runs scryer:report with CI-sensible defaults — JSON + " \
|
|
104
|
+
"SARIF written under tmp/ (SARIF for GitHub Code Scanning / similar dashboards, JSON as " \
|
|
105
|
+
"the machine-readable form other CI steps can parse), dependency audit on, same " \
|
|
106
|
+
"SCRYER_BASELINE support and same fail-the-build-on-findings behavior as scryer:report. " \
|
|
107
|
+
"Equivalent to `rails 'scryer:report[json,sarif]'` under a name that doesn't require " \
|
|
108
|
+
"remembering the bracket-arg syntax. e.g. rails scryer:ci"
|
|
109
|
+
task :ci do
|
|
110
|
+
Rake::Task["scryer:report"].invoke("json", "sarif")
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
desc "Run Scryer and save every finding's fingerprint to PATH, for later comparison via " \
|
|
114
|
+
"SCRYER_BASELINE=PATH rails scryer:report (see that task's SCRYER_BASELINE note). " \
|
|
115
|
+
"Runs the same scan as scryer:report (including the dependency audit) but writes no " \
|
|
116
|
+
"report files. e.g. rails 'scryer:save_baseline[tmp/scryer_baseline.json]'"
|
|
117
|
+
task :save_baseline, [:path] do |_, args|
|
|
118
|
+
path = args[:path] || abort("Scryer: scryer:save_baseline needs a path, " \
|
|
119
|
+
"e.g. rails 'scryer:save_baseline[tmp/scryer_baseline.json]'")
|
|
120
|
+
root = defined?(Rails) ? Rails.root.to_s : Dir.pwd
|
|
121
|
+
dirs = Scryer.configuration.dirs
|
|
122
|
+
|
|
123
|
+
result = Scryer::Scanner.new(root: root, dirs: dirs, skip_rules: Scryer.configuration.skip_rules).call
|
|
124
|
+
puts "Scryer: querying OSV.dev for known-vulnerable gems (needs network)..."
|
|
125
|
+
dependency_findings = Scryer::DependencyAudit.insecure_sources(root) + Scryer::DependencyAudit.vulnerable_gems(root) +
|
|
126
|
+
Scryer::DependencyAudit.ruby_eol_check(root) + Scryer::DependencyAudit.credentials_exposure_check(root)
|
|
127
|
+
|
|
128
|
+
all_findings = (result.security_findings + result.performance_findings + result.style_findings).map(&:to_h) +
|
|
129
|
+
dependency_findings.map(&:to_h)
|
|
130
|
+
Scryer::Baseline.save(path, all_findings)
|
|
131
|
+
puts "Scryer: saved baseline of #{all_findings.size} finding(s) to #{path}."
|
|
132
|
+
end
|
|
133
|
+
|
|
86
134
|
desc "Check Gemfile.lock for known-vulnerable gem versions (via OSV.dev — needs network) " \
|
|
87
135
|
"and insecure git/http gem sources (offline). Exits non-zero if anything is found, so " \
|
|
88
136
|
"this can gate CI the same way `bundle-audit check` does."
|
|
@@ -190,11 +238,40 @@ module ScryerTasks
|
|
|
190
238
|
"#{f.gem_name} #{f.installed_version}: #{f.title}"
|
|
191
239
|
end
|
|
192
240
|
|
|
241
|
+
# Same logic as Scryer::CLI#apply_baseline (see that method's comment for
|
|
242
|
+
# why fixed_count has to be computed once against the combined set of
|
|
243
|
+
# every category's current fingerprints, not once per category summed).
|
|
244
|
+
def apply_baseline(path, result, dependency_findings)
|
|
245
|
+
baseline_fingerprints = Scryer::Baseline.load(path)
|
|
246
|
+
|
|
247
|
+
security_hashes = result.security_findings.map(&:to_h)
|
|
248
|
+
performance_hashes = result.performance_findings.map(&:to_h)
|
|
249
|
+
style_hashes = result.style_findings.map(&:to_h)
|
|
250
|
+
dependency_hashes = dependency_findings.map(&:to_h)
|
|
251
|
+
|
|
252
|
+
all_current_fingerprints = Scryer::Baseline.fingerprints(security_hashes + performance_hashes + style_hashes + dependency_hashes)
|
|
253
|
+
fixed_count = (baseline_fingerprints - all_current_fingerprints.to_set).size
|
|
254
|
+
|
|
255
|
+
result.security_findings = filter_new(result.security_findings, security_hashes, baseline_fingerprints)
|
|
256
|
+
result.performance_findings = filter_new(result.performance_findings, performance_hashes, baseline_fingerprints)
|
|
257
|
+
result.style_findings = filter_new(result.style_findings, style_hashes, baseline_fingerprints)
|
|
258
|
+
filtered_deps = filter_new(dependency_findings, dependency_hashes, baseline_fingerprints)
|
|
259
|
+
|
|
260
|
+
[filtered_deps, fixed_count]
|
|
261
|
+
rescue Scryer::Baseline::LoadError => e
|
|
262
|
+
abort("Scryer: #{e.message}")
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def filter_new(objects, hashes, baseline_fingerprints)
|
|
266
|
+
fingerprints = Scryer::Baseline.fingerprints(hashes)
|
|
267
|
+
objects.each_with_index.reject { |_, i| baseline_fingerprints.include?(fingerprints[i]) }.map(&:first)
|
|
268
|
+
end
|
|
269
|
+
|
|
193
270
|
# The "one audit command" summary — a single scan's worth of every
|
|
194
271
|
# category Scryer covers (security, performance, duplicate/smelly code,
|
|
195
272
|
# dependencies), the same categories usually split across RuboCop +
|
|
196
273
|
# Brakeman + bundler-audit + Reek, side by side in one box.
|
|
197
|
-
def print_summary(result:, dependency_findings:, ran_deps:, paths:)
|
|
274
|
+
def print_summary(result:, dependency_findings:, ran_deps:, paths:, renderer:, fixed_count: 0, baseline_path: nil)
|
|
198
275
|
# "Code Quality" is the umbrella label for both duplicate-code groups
|
|
199
276
|
# and rule-based style findings (e.g. frozen_string_literal) — two
|
|
200
277
|
# different detectors, same broad concern, one row in the box.
|
|
@@ -210,14 +287,25 @@ module ScryerTasks
|
|
|
210
287
|
]
|
|
211
288
|
|
|
212
289
|
divider = "─" * 32
|
|
290
|
+
score = renderer.security_score
|
|
213
291
|
puts ""
|
|
214
292
|
puts "Scryer Audit — #{result.files_scanned} files scanned"
|
|
215
293
|
puts divider
|
|
216
294
|
puts ""
|
|
295
|
+
if baseline_path
|
|
296
|
+
puts "Baseline: #{baseline_path} — showing new findings only (#{fixed_count} fixed since baseline)."
|
|
297
|
+
puts ""
|
|
298
|
+
end
|
|
299
|
+
clean_rate = renderer.rules_clean_rate
|
|
300
|
+
puts "Security Score: #{score["score"]}/100 (#{score["grade"]})"
|
|
301
|
+
puts "Checks: #{clean_rate["clean"]}/#{clean_rate["total"]} rules clean (#{clean_rate["percent"]}%)"
|
|
302
|
+
puts ""
|
|
217
303
|
rows.each { |label, count| puts summary_row(label, count) }
|
|
218
304
|
puts divider
|
|
219
305
|
puts summary_row("Total", total)
|
|
220
306
|
puts ""
|
|
307
|
+
print_top_priorities(renderer.top_risks)
|
|
308
|
+
print_owasp_coverage(renderer.owasp_coverage)
|
|
221
309
|
paths.each { |format, path| puts "#{format.upcase} report: #{path}" }
|
|
222
310
|
end
|
|
223
311
|
|
|
@@ -225,4 +313,29 @@ module ScryerTasks
|
|
|
225
313
|
value = count.nil? ? "skipped (nodeps)" : "#{count} finding#{"s" unless count == 1}"
|
|
226
314
|
"#{label.ljust(14)}#{value.rjust(20)}"
|
|
227
315
|
end
|
|
316
|
+
|
|
317
|
+
# The categories above are counted separately, but nothing else ranks
|
|
318
|
+
# across them — this is what actually backs "tells you what to fix
|
|
319
|
+
# first" rather than just splitting findings into four buckets. Same
|
|
320
|
+
# data ReportRenderer#top_risks already sorts for the HTML report.
|
|
321
|
+
def print_top_priorities(risks)
|
|
322
|
+
return if risks.empty?
|
|
323
|
+
|
|
324
|
+
puts "Top priorities:"
|
|
325
|
+
risks.each_with_index do |r, i|
|
|
326
|
+
puts " #{i + 1}. [#{r[:severity]}] #{r[:category]} — #{r[:label]} (#{r[:location]})"
|
|
327
|
+
end
|
|
328
|
+
puts ""
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
# Byproduct of every security rule carrying an owasp_category — see
|
|
332
|
+
# ReportRenderer#owasp_coverage. Scryer's own best-effort tagging, not an
|
|
333
|
+
# OWASP-audited mapping (documented in full in the README).
|
|
334
|
+
def print_owasp_coverage(coverage)
|
|
335
|
+
return if coverage.empty?
|
|
336
|
+
|
|
337
|
+
puts "OWASP Top 10 (2021) coverage:"
|
|
338
|
+
coverage.each { |category, count| puts " #{category}: #{count} finding#{"s" unless count == 1}" }
|
|
339
|
+
puts ""
|
|
340
|
+
end
|
|
228
341
|
end
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: scryer
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.1.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ram Laxman Yadav
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-08-
|
|
11
|
+
date: 2026-08-14 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rake
|
|
@@ -25,18 +25,21 @@ dependencies:
|
|
|
25
25
|
- !ruby/object:Gem::Version
|
|
26
26
|
version: '13.0'
|
|
27
27
|
description: |
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
doesn't
|
|
28
|
+
Scryer scans a Ruby/Rails codebase for security vulnerabilities, performance problems,
|
|
29
|
+
dependency risk, and code-quality issues, then ranks everything it finds by severity across
|
|
30
|
+
all of those categories — so a scan ends with one answer to "what's most worth fixing," not
|
|
31
|
+
four separate reports to reconcile by hand. (Style/lint is RuboCop's job — Scryer doesn't
|
|
32
|
+
touch that, except one narrow check.)
|
|
32
33
|
|
|
33
|
-
Detects SQL injection, mass assignment,
|
|
34
|
-
|
|
35
|
-
known-vulnerable gems via a live
|
|
34
|
+
Detects SQL injection, mass assignment, SSRF, path traversal, IDOR, insecure JWT/CORS/session
|
|
35
|
+
config, hardcoded secrets, XSS, weak crypto, and more; N+1 queries, missing pagination, and
|
|
36
|
+
other performance heuristics; near-duplicate code; and known-vulnerable gems via a live
|
|
37
|
+
OSV.dev dependency audit — on by default, every run.
|
|
36
38
|
|
|
37
39
|
Every finding includes a human-reviewable suggested fix — never auto-applied, optionally
|
|
38
40
|
rewritten against your actual code by any LLM you configure. Reports in JSON, self-contained
|
|
39
|
-
HTML, or
|
|
41
|
+
HTML, CSV, or SARIF (for GitHub Code Scanning). Zero runtime dependencies beyond Ruby's own
|
|
42
|
+
stdlib.
|
|
40
43
|
email:
|
|
41
44
|
executables:
|
|
42
45
|
- scryer
|
|
@@ -54,12 +57,16 @@ files:
|
|
|
54
57
|
- lib/scryer/ai_client.rb
|
|
55
58
|
- lib/scryer/ai_fix_suggester.rb
|
|
56
59
|
- lib/scryer/ast.rb
|
|
60
|
+
- lib/scryer/authorization_watcher.rb
|
|
61
|
+
- lib/scryer/baseline.rb
|
|
57
62
|
- lib/scryer/cache_extractor.rb
|
|
58
63
|
- lib/scryer/cli.rb
|
|
59
64
|
- lib/scryer/dependency_audit.rb
|
|
60
65
|
- lib/scryer/duplicate_detector.rb
|
|
61
66
|
- lib/scryer/finding.rb
|
|
67
|
+
- lib/scryer/fix_verifier.rb
|
|
62
68
|
- lib/scryer/method_extractor.rb
|
|
69
|
+
- lib/scryer/minitest.rb
|
|
63
70
|
- lib/scryer/performance_rules/inefficient_save_loop_rule.rb
|
|
64
71
|
- lib/scryer/performance_rules/missing_pagination_rule.rb
|
|
65
72
|
- lib/scryer/performance_rules/n_plus_one_query_rule.rb
|
|
@@ -68,6 +75,7 @@ files:
|
|
|
68
75
|
- lib/scryer/query_watcher.rb
|
|
69
76
|
- lib/scryer/railtie.rb
|
|
70
77
|
- lib/scryer/report_renderer.rb
|
|
78
|
+
- lib/scryer/rspec.rb
|
|
71
79
|
- lib/scryer/rule.rb
|
|
72
80
|
- lib/scryer/rule_set.rb
|
|
73
81
|
- lib/scryer/rules/action_cable_forgery_protection_rule.rb
|
|
@@ -75,6 +83,7 @@ files:
|
|
|
75
83
|
- lib/scryer/rules/active_storage_missing_content_type_validation_rule.rb
|
|
76
84
|
- lib/scryer/rules/authentication_bypass_rule.rb
|
|
77
85
|
- lib/scryer/rules/command_injection_rule.rb
|
|
86
|
+
- lib/scryer/rules/consider_all_requests_local_rule.rb
|
|
78
87
|
- lib/scryer/rules/cors_misconfiguration_rule.rb
|
|
79
88
|
- lib/scryer/rules/csrf_protection_rule.rb
|
|
80
89
|
- lib/scryer/rules/force_ssl_rule.rb
|
|
@@ -82,17 +91,21 @@ files:
|
|
|
82
91
|
- lib/scryer/rules/hardcoded_basic_auth_rule.rb
|
|
83
92
|
- lib/scryer/rules/hardcoded_secret_key_base_rule.rb
|
|
84
93
|
- lib/scryer/rules/hardcoded_secret_rule.rb
|
|
94
|
+
- lib/scryer/rules/host_authorization_disabled_rule.rb
|
|
85
95
|
- lib/scryer/rules/idor_rule.rb
|
|
86
96
|
- lib/scryer/rules/insecure_cookie_serializer_rule.rb
|
|
87
97
|
- lib/scryer/rules/job_raw_params_rule.rb
|
|
88
98
|
- lib/scryer/rules/jwt_insecure_rule.rb
|
|
89
99
|
- lib/scryer/rules/mass_assignment_rule.rb
|
|
100
|
+
- lib/scryer/rules/missing_authorization_rule.rb
|
|
101
|
+
- lib/scryer/rules/missing_policy_scope_rule.rb
|
|
90
102
|
- lib/scryer/rules/open_redirect_rule.rb
|
|
91
103
|
- lib/scryer/rules/path_traversal_rule.rb
|
|
92
104
|
- lib/scryer/rules/security_headers_rule.rb
|
|
93
105
|
- lib/scryer/rules/sql_injection_rule.rb
|
|
94
106
|
- lib/scryer/rules/ssrf_rule.rb
|
|
95
107
|
- lib/scryer/rules/unsafe_deserialization_rule.rb
|
|
108
|
+
- lib/scryer/rules/verbose_production_log_level_rule.rb
|
|
96
109
|
- lib/scryer/rules/weak_crypto_rule.rb
|
|
97
110
|
- lib/scryer/rules/weak_session_cookie_rule.rb
|
|
98
111
|
- lib/scryer/rules/xss_unsafe_html_rule.rb
|
|
@@ -127,6 +140,5 @@ requirements: []
|
|
|
127
140
|
rubygems_version: 3.5.11
|
|
128
141
|
signing_key:
|
|
129
142
|
specification_version: 4
|
|
130
|
-
summary: Ruby/Rails security &
|
|
131
|
-
bundler-audit + Reek.
|
|
143
|
+
summary: Ruby/Rails application security & risk auditor — tells you what to fix first.
|
|
132
144
|
test_files: []
|