scryer 0.2.0 → 1.0.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 +87 -0
- data/README.md +148 -32
- data/lib/scryer/ast.rb +69 -6
- data/lib/scryer/cli.rb +33 -8
- data/lib/scryer/dependency_audit.rb +108 -5
- data/lib/scryer/report_renderer.rb +152 -4
- data/lib/scryer/rules/action_cable_forgery_protection_rule.rb +47 -0
- data/lib/scryer/rules/active_storage_inline_disposition_rule.rb +47 -0
- data/lib/scryer/rules/active_storage_missing_content_type_validation_rule.rb +76 -0
- data/lib/scryer/rules/authentication_bypass_rule.rb +72 -0
- data/lib/scryer/rules/cors_misconfiguration_rule.rb +69 -0
- data/lib/scryer/rules/force_ssl_rule.rb +45 -0
- data/lib/scryer/rules/graphql_missing_query_limits_rule.rb +75 -0
- data/lib/scryer/rules/hardcoded_basic_auth_rule.rb +48 -0
- data/lib/scryer/rules/hardcoded_secret_key_base_rule.rb +55 -0
- data/lib/scryer/rules/idor_rule.rb +111 -0
- data/lib/scryer/rules/insecure_cookie_serializer_rule.rb +44 -0
- data/lib/scryer/rules/job_raw_params_rule.rb +98 -0
- data/lib/scryer/rules/jwt_insecure_rule.rb +120 -0
- data/lib/scryer/rules/mass_assignment_rule.rb +2 -5
- data/lib/scryer/rules/path_traversal_rule.rb +89 -0
- data/lib/scryer/rules/security_headers_rule.rb +130 -0
- data/lib/scryer/rules/ssrf_rule.rb +85 -0
- data/lib/scryer/rules/weak_session_cookie_rule.rb +48 -0
- data/lib/scryer/version.rb +1 -1
- data/lib/tasks/scryer.rake +22 -10
- metadata +19 -2
|
@@ -77,16 +77,13 @@ module Scryer
|
|
|
77
77
|
!NON_MODEL_RECEIVERS.include?(const_node[1])
|
|
78
78
|
end
|
|
79
79
|
|
|
80
|
-
# True if `node`
|
|
80
|
+
# True if `node` references `params` (see Ast.references_params?)
|
|
81
81
|
# without a `.permit`/`.permit!` call wrapping it (permit! is itself
|
|
82
82
|
# flagged as unsafe too, so it doesn't count as "safe").
|
|
83
83
|
def references_raw_params?(node)
|
|
84
|
-
return false unless node.is_a?(Array)
|
|
85
84
|
return false if has_permit_call?(node)
|
|
86
85
|
|
|
87
|
-
Ast.
|
|
88
|
-
Ast.tagged?(n, :vcall, :var_ref, :fcall) && Ast.ident_text(n[1]) == "params"
|
|
89
|
-
end
|
|
86
|
+
Ast.references_params?(node)
|
|
90
87
|
end
|
|
91
88
|
|
|
92
89
|
def has_permit_call?(node)
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
module Scryer
|
|
2
|
+
module Rules
|
|
3
|
+
# Flags a filesystem operation (`File.join/read/open/new/write/delete`,
|
|
4
|
+
# `Dir.glob/entries`, `send_file`) where an argument references `params`
|
|
5
|
+
# directly — without sanitization, `../../etc/passwd`-style path
|
|
6
|
+
# segments in the request let an attacker read (or write/delete) files
|
|
7
|
+
# outside whatever directory the code intended to restrict access to.
|
|
8
|
+
class PathTraversalRule < Rule
|
|
9
|
+
self.rule_id = "path_traversal"
|
|
10
|
+
self.category = "security"
|
|
11
|
+
self.default_severity = "critical"
|
|
12
|
+
self.title = "Possible path traversal"
|
|
13
|
+
|
|
14
|
+
DANGEROUS_CALLS = {
|
|
15
|
+
"File" => %w[join read open new write delete binread binwrite],
|
|
16
|
+
"Dir" => %w[glob entries]
|
|
17
|
+
}.freeze
|
|
18
|
+
BARE_METHODS = %w[send_file send_data].freeze
|
|
19
|
+
|
|
20
|
+
def scan
|
|
21
|
+
findings = []
|
|
22
|
+
|
|
23
|
+
Ast.each_node(sexp) do |node|
|
|
24
|
+
next unless Ast.tagged?(node, :method_add_arg, :command, :command_call)
|
|
25
|
+
|
|
26
|
+
inner = Ast.tagged?(node, :method_add_arg) ? node[1] : node
|
|
27
|
+
receiver_and_name = Ast.call_name(inner)
|
|
28
|
+
next unless receiver_and_name
|
|
29
|
+
|
|
30
|
+
receiver, method_name = receiver_and_name
|
|
31
|
+
next unless dangerous_call?(receiver, method_name)
|
|
32
|
+
|
|
33
|
+
args = Ast.call_arguments(node)
|
|
34
|
+
next unless args.any? { |a| Ast.references_params?(a) && !sanitized_via_basename?(a) }
|
|
35
|
+
|
|
36
|
+
line = Ast.line_of(node)
|
|
37
|
+
findings << finding(
|
|
38
|
+
line: line,
|
|
39
|
+
message: "`#{describe_call(receiver, method_name)}` is called with a path/argument " \
|
|
40
|
+
"that references `params` — a value like `../../config/master.key` reaches " \
|
|
41
|
+
"the filesystem unchanged, letting an attacker read or write files outside " \
|
|
42
|
+
"whatever directory this was meant to be scoped to.",
|
|
43
|
+
suggested_fix: "Reduce the input to a safe basename before using it " \
|
|
44
|
+
"(`File.basename(params[:name])`), and/or verify the resolved path " \
|
|
45
|
+
"stays inside the intended directory (compare " \
|
|
46
|
+
"`File.expand_path(...)` against the allowed root) before touching " \
|
|
47
|
+
"the filesystem."
|
|
48
|
+
)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
findings
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def dangerous_call?(receiver, method_name)
|
|
57
|
+
return true if receiver.nil? && BARE_METHODS.include?(method_name)
|
|
58
|
+
|
|
59
|
+
DANGEROUS_CALLS[receiver_name(receiver)]&.include?(method_name) || false
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def describe_call(receiver, method_name)
|
|
63
|
+
name = receiver_name(receiver)
|
|
64
|
+
name ? "#{name}.#{method_name}" : method_name
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def receiver_name(node)
|
|
68
|
+
return nil unless Ast.tagged?(node, :var_ref) && node[1].is_a?(Array) && node[1][0] == :@const
|
|
69
|
+
|
|
70
|
+
node[1][1]
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# True if `File.basename(...)` appears anywhere in the argument's
|
|
74
|
+
# subtree — the standard way to strip directory-traversal segments
|
|
75
|
+
# before using a request-controlled filename. Same "does the guard
|
|
76
|
+
# appear anywhere in this expression" looseness as
|
|
77
|
+
# MassAssignmentRule#has_permit_call? — doesn't verify it wraps
|
|
78
|
+
# *exactly* the params reference, just that it's present.
|
|
79
|
+
def sanitized_via_basename?(node)
|
|
80
|
+
Ast.each_node(node).any? do |n|
|
|
81
|
+
next false unless Ast.tagged?(n, :method_add_arg, :command, :command_call)
|
|
82
|
+
|
|
83
|
+
inner = Ast.tagged?(n, :method_add_arg) ? n[1] : n
|
|
84
|
+
Ast.call_name(inner)&.last == "basename"
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
module Scryer
|
|
2
|
+
module Rules
|
|
3
|
+
# Flags an explicit insecure override of Rails' default security
|
|
4
|
+
# headers/config — NOT absence of a header, which would require knowing
|
|
5
|
+
# every environment file and initializer in the app (too noisy/
|
|
6
|
+
# environment-dependent for a per-file static rule). Only the explicit
|
|
7
|
+
# opt-out is a reliable, low-noise signal on its own — same reasoning as
|
|
8
|
+
# ForceSslRule.
|
|
9
|
+
#
|
|
10
|
+
# config.action_dispatch.default_headers['X-Frame-Options'] = 'ALLOWALL' (or false)
|
|
11
|
+
# config.action_dispatch.default_headers['X-Content-Type-Options'] = false
|
|
12
|
+
# config.action_dispatch.default_headers.merge!('X-Frame-Options' => 'ALLOWALL', ...)
|
|
13
|
+
# config.content_security_policy = nil
|
|
14
|
+
class SecurityHeadersRule < Rule
|
|
15
|
+
self.rule_id = "security_headers_disabled"
|
|
16
|
+
self.category = "security"
|
|
17
|
+
self.default_severity = "critical"
|
|
18
|
+
self.title = "Rails default security header explicitly disabled"
|
|
19
|
+
|
|
20
|
+
HEADERS = ["X-Frame-Options", "X-Content-Type-Options"].freeze
|
|
21
|
+
|
|
22
|
+
def scan
|
|
23
|
+
findings = []
|
|
24
|
+
|
|
25
|
+
Ast.each_node(sexp) do |node|
|
|
26
|
+
if Ast.tagged?(node, :assign)
|
|
27
|
+
scan_assign(findings, node)
|
|
28
|
+
elsif Ast.tagged?(node, :method_add_arg)
|
|
29
|
+
scan_merge_bang(findings, node)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
findings
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def scan_assign(findings, node)
|
|
39
|
+
target = node[1]
|
|
40
|
+
value = node[2]
|
|
41
|
+
|
|
42
|
+
if Ast.tagged?(target, :aref_field) && default_headers_receiver?(target[1])
|
|
43
|
+
key = Ast.unwrap_args(target[2]).first
|
|
44
|
+
header = Ast.plain_string_value(key)
|
|
45
|
+
add_header_finding(findings, header, value, Ast.line_of(node)) if header
|
|
46
|
+
elsif Ast.tagged?(target, :field) && Ast.ident_text(target[3]) == "content_security_policy"
|
|
47
|
+
return unless Ast.kw_literal?(value) == "nil"
|
|
48
|
+
|
|
49
|
+
findings << finding(
|
|
50
|
+
line: Ast.line_of(node),
|
|
51
|
+
message: "`config.content_security_policy = nil` explicitly disables Content-Security-Policy — " \
|
|
52
|
+
"a header that mitigates XSS/data-injection by restricting which sources scripts, " \
|
|
53
|
+
"styles, and other resources can load from.",
|
|
54
|
+
suggested_fix: "Configure an actual policy instead of nil-ing it out — see " \
|
|
55
|
+
"`config.content_security_policy do |policy| ... end` in " \
|
|
56
|
+
"config/initializers/content_security_policy.rb."
|
|
57
|
+
)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def scan_merge_bang(findings, node)
|
|
62
|
+
inner = node[1]
|
|
63
|
+
name_pair = Ast.call_name(inner)
|
|
64
|
+
return unless name_pair && name_pair[1] == "merge!"
|
|
65
|
+
return unless default_headers_receiver?(name_pair[0])
|
|
66
|
+
|
|
67
|
+
each_assoc_pair(node).each do |key_node, value_node|
|
|
68
|
+
header = Ast.plain_string_value(key_node)
|
|
69
|
+
add_header_finding(findings, header, value_node, Ast.line_of(node)) if header
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def add_header_finding(findings, header, value_node, line)
|
|
74
|
+
return unless HEADERS.include?(header)
|
|
75
|
+
return unless disabling_value?(header, value_node)
|
|
76
|
+
|
|
77
|
+
findings << finding(
|
|
78
|
+
line: line,
|
|
79
|
+
message: header_message(header),
|
|
80
|
+
suggested_fix: header_fix(header)
|
|
81
|
+
)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def disabling_value?(header, value_node)
|
|
85
|
+
return true if Ast.false_literal?(value_node) || Ast.kw_literal?(value_node) == "nil"
|
|
86
|
+
|
|
87
|
+
header == "X-Frame-Options" && Ast.plain_string_value(value_node).to_s.upcase == "ALLOWALL"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def header_message(header)
|
|
91
|
+
case header
|
|
92
|
+
when "X-Frame-Options"
|
|
93
|
+
"The `X-Frame-Options` header is explicitly overridden to allow framing (`ALLOWALL`/falsy) — " \
|
|
94
|
+
"this removes Rails' default clickjacking protection, letting this app be embedded in an " \
|
|
95
|
+
"attacker-controlled `<iframe>`."
|
|
96
|
+
when "X-Content-Type-Options"
|
|
97
|
+
"The `X-Content-Type-Options` header is explicitly disabled — this removes Rails' default " \
|
|
98
|
+
"MIME-sniffing protection, letting browsers reinterpret a response's content type (e.g. " \
|
|
99
|
+
"executing an uploaded file as script)."
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def header_fix(header)
|
|
104
|
+
case header
|
|
105
|
+
when "X-Frame-Options"
|
|
106
|
+
"Remove this override (Rails defaults to `SAMEORIGIN`), or set an explicit safe value if " \
|
|
107
|
+
"framing from a specific trusted origin is actually needed."
|
|
108
|
+
when "X-Content-Type-Options"
|
|
109
|
+
"Remove this override — leave `X-Content-Type-Options` at Rails' default (`nosniff`)."
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# True if `node` is a call chain ending in `.default_headers` (any
|
|
114
|
+
# receiver — usually `config.action_dispatch`, but the receiver chain
|
|
115
|
+
# itself isn't verified, same looseness as WeakSessionCookieRule not
|
|
116
|
+
# verifying its receiver is really `config`).
|
|
117
|
+
def default_headers_receiver?(node)
|
|
118
|
+
Ast.call_name(node)&.last == "default_headers"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def each_assoc_pair(call_node)
|
|
122
|
+
Ast.call_arguments(call_node).each_with_object([]) do |arg, acc|
|
|
123
|
+
next unless Ast.tagged?(arg, :bare_assoc_hash) && arg[1].is_a?(Array)
|
|
124
|
+
|
|
125
|
+
arg[1].each { |p| acc << [p[1], p[2]] if Ast.tagged?(p, :assoc_new) }
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
module Scryer
|
|
2
|
+
module Rules
|
|
3
|
+
# Flags an outbound HTTP call (`Net::HTTP`, `URI.open`/bare `open`
|
|
4
|
+
# via open-uri, `HTTParty`, `Faraday`, `RestClient`) where an argument
|
|
5
|
+
# references `params` directly — the request's destination is
|
|
6
|
+
# user-controlled, which lets an attacker make the server issue requests
|
|
7
|
+
# to internal services, cloud metadata endpoints, or anywhere else on
|
|
8
|
+
# its network (server-side request forgery).
|
|
9
|
+
class SsrfRule < Rule
|
|
10
|
+
self.rule_id = "ssrf"
|
|
11
|
+
self.category = "security"
|
|
12
|
+
self.default_severity = "critical"
|
|
13
|
+
self.title = "Possible server-side request forgery (SSRF)"
|
|
14
|
+
|
|
15
|
+
DANGEROUS_CALLS = {
|
|
16
|
+
"Net::HTTP" => %w[get get_response post post_form],
|
|
17
|
+
"URI" => %w[open],
|
|
18
|
+
"HTTParty" => %w[get post put delete],
|
|
19
|
+
"Faraday" => %w[get post put delete],
|
|
20
|
+
"RestClient" => %w[get post put delete]
|
|
21
|
+
}.freeze
|
|
22
|
+
|
|
23
|
+
def scan
|
|
24
|
+
findings = []
|
|
25
|
+
|
|
26
|
+
Ast.each_node(sexp) do |node|
|
|
27
|
+
next unless Ast.tagged?(node, :method_add_arg, :command, :command_call)
|
|
28
|
+
|
|
29
|
+
inner = Ast.tagged?(node, :method_add_arg) ? node[1] : node
|
|
30
|
+
receiver_and_name = Ast.call_name(inner)
|
|
31
|
+
next unless receiver_and_name
|
|
32
|
+
|
|
33
|
+
receiver, method_name = receiver_and_name
|
|
34
|
+
next unless dangerous_call?(receiver, method_name)
|
|
35
|
+
|
|
36
|
+
args = Ast.call_arguments(node)
|
|
37
|
+
next unless args.any? { |a| Ast.references_params?(a) }
|
|
38
|
+
|
|
39
|
+
line = Ast.line_of(node)
|
|
40
|
+
findings << finding(
|
|
41
|
+
line: line,
|
|
42
|
+
message: "`#{describe_call(receiver, method_name)}` is called with a URL/argument " \
|
|
43
|
+
"that references `params` — the server can be made to issue a request to " \
|
|
44
|
+
"any host an attacker chooses, including internal services and cloud " \
|
|
45
|
+
"metadata endpoints (e.g. `169.254.169.254`) that aren't meant to be " \
|
|
46
|
+
"reachable from outside.",
|
|
47
|
+
suggested_fix: "Validate the destination against an allowlist of known-safe hosts " \
|
|
48
|
+
"before making the request, rather than passing the user-supplied " \
|
|
49
|
+
"value straight through — and reject internal/link-local IP ranges " \
|
|
50
|
+
"explicitly if the allowlist is host-based (DNS can still resolve to one)."
|
|
51
|
+
)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
findings
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def dangerous_call?(receiver, method_name)
|
|
60
|
+
return true if receiver.nil? && method_name == "open"
|
|
61
|
+
|
|
62
|
+
DANGEROUS_CALLS[const_receiver_name(receiver)]&.include?(method_name) || false
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def describe_call(receiver, method_name)
|
|
66
|
+
name = const_receiver_name(receiver)
|
|
67
|
+
name ? "#{name}.#{method_name}" : method_name
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Textual name of a (possibly namespaced) constant receiver —
|
|
71
|
+
# "URI" for `[:var_ref, [:@const, "URI", pos]]`, "Net::HTTP" for the
|
|
72
|
+
# `[:const_path_ref, ...]` chain `Net::HTTP` parses into. nil for
|
|
73
|
+
# anything else (a variable, a method call, ...).
|
|
74
|
+
def const_receiver_name(node)
|
|
75
|
+
if Ast.tagged?(node, :var_ref) && node[1].is_a?(Array) && node[1][0] == :@const
|
|
76
|
+
node[1][1]
|
|
77
|
+
elsif Ast.tagged?(node, :const_path_ref)
|
|
78
|
+
left = const_receiver_name(node[1])
|
|
79
|
+
right = node[2].is_a?(Array) && node[2][0] == :@const ? node[2][1] : nil
|
|
80
|
+
[left, right].compact.join("::")
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
module Scryer
|
|
2
|
+
module Rules
|
|
3
|
+
# Flags `config.session_store :cookie_store, ...` with no `secure: true`
|
|
4
|
+
# in its options — without it, the session cookie can be sent over plain
|
|
5
|
+
# HTTP, not just HTTPS. Only `:cookie_store` is checked (the default and
|
|
6
|
+
# most common store); other stores (`:redis_session_store`, ...) don't
|
|
7
|
+
# carry the same cookie-content risk and are out of scope here.
|
|
8
|
+
class WeakSessionCookieRule < Rule
|
|
9
|
+
self.rule_id = "weak_session_cookie"
|
|
10
|
+
self.category = "security"
|
|
11
|
+
self.default_severity = "warning"
|
|
12
|
+
self.title = "Session cookie missing the secure flag"
|
|
13
|
+
|
|
14
|
+
def scan
|
|
15
|
+
findings = []
|
|
16
|
+
|
|
17
|
+
Ast.each_node(sexp) do |node|
|
|
18
|
+
next unless Ast.tagged?(node, :command, :command_call, :method_add_arg)
|
|
19
|
+
|
|
20
|
+
inner = Ast.tagged?(node, :method_add_arg) ? node[1] : node
|
|
21
|
+
receiver_and_name = Ast.call_name(inner)
|
|
22
|
+
next unless receiver_and_name
|
|
23
|
+
|
|
24
|
+
_receiver, method_name = receiver_and_name
|
|
25
|
+
next unless method_name == "session_store"
|
|
26
|
+
|
|
27
|
+
args = Ast.call_arguments(node)
|
|
28
|
+
next unless args.any? { |a| Ast.literal_text(a) == "cookie_store" }
|
|
29
|
+
next if Ast.true_literal?(Ast.keyword_arg(args, "secure"))
|
|
30
|
+
|
|
31
|
+
line = Ast.line_of(node)
|
|
32
|
+
findings << finding(
|
|
33
|
+
line: line,
|
|
34
|
+
message: "`session_store :cookie_store` has no `secure: true` — the session cookie " \
|
|
35
|
+
"can be transmitted over plain HTTP, where it's exposed to network " \
|
|
36
|
+
"eavesdropping (e.g. on shared/untrusted Wi-Fi).",
|
|
37
|
+
suggested_fix: "Add `secure: true` to the session_store options (typically only in " \
|
|
38
|
+
"production, since local HTTP development doesn't have TLS): " \
|
|
39
|
+
"`config.session_store :cookie_store, key: '_app_session', secure: " \
|
|
40
|
+
"Rails.env.production?`."
|
|
41
|
+
)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
findings
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
data/lib/scryer/version.rb
CHANGED
data/lib/tasks/scryer.rake
CHANGED
|
@@ -8,7 +8,7 @@ require "fileutils"
|
|
|
8
8
|
# ActiveSupport methods (`.presence`/`.blank?`) for the same reason — plain
|
|
9
9
|
# Ruby nil-or-empty checks only.
|
|
10
10
|
namespace :scryer do
|
|
11
|
-
desc "Run Scryer and write a report. Args are any mix of json/html/csv (which formats " \
|
|
11
|
+
desc "Run Scryer and write a report. Args are any mix of json/html/csv/sarif (which formats " \
|
|
12
12
|
"to write — default json,html), the token 'nodeps' (skip the dependency audit — OSV.dev " \
|
|
13
13
|
"vulnerable gems + insecure git/http sources — that otherwise runs by default; use for a " \
|
|
14
14
|
"fast, fully offline run), plus at most one path (a directory when writing more than one " \
|
|
@@ -16,7 +16,7 @@ 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]'"
|
|
19
|
+
"rails 'scryer:report[html,nodeps]', rails 'scryer:report[csv]', rails 'scryer:report[sarif]'"
|
|
20
20
|
task :report, [:format] do |_, args|
|
|
21
21
|
root = defined?(Rails) ? Rails.root.to_s : Dir.pwd
|
|
22
22
|
dirs = Scryer.configuration.dirs
|
|
@@ -38,7 +38,8 @@ namespace :scryer do
|
|
|
38
38
|
dependency_findings = []
|
|
39
39
|
if run_deps
|
|
40
40
|
puts "Scryer: querying OSV.dev for known-vulnerable gems (needs network)..."
|
|
41
|
-
dependency_findings = Scryer::DependencyAudit.insecure_sources(root) + Scryer::DependencyAudit.vulnerable_gems(root)
|
|
41
|
+
dependency_findings = Scryer::DependencyAudit.insecure_sources(root) + Scryer::DependencyAudit.vulnerable_gems(root) +
|
|
42
|
+
Scryer::DependencyAudit.ruby_eol_check(root) + Scryer::DependencyAudit.credentials_exposure_check(root)
|
|
42
43
|
end
|
|
43
44
|
|
|
44
45
|
if Scryer.configuration.ai_client
|
|
@@ -63,6 +64,7 @@ namespace :scryer do
|
|
|
63
64
|
content = case format
|
|
64
65
|
when "json" then renderer.as_json
|
|
65
66
|
when "csv" then renderer.as_csv
|
|
67
|
+
when "sarif" then renderer.as_sarif
|
|
66
68
|
else renderer.as_html
|
|
67
69
|
end
|
|
68
70
|
File.write(path, content)
|
|
@@ -92,20 +94,23 @@ namespace :scryer do
|
|
|
92
94
|
|
|
93
95
|
puts "Scryer: querying OSV.dev for known vulnerabilities (needs network)..."
|
|
94
96
|
vulnerable = Scryer::DependencyAudit.vulnerable_gems(root)
|
|
97
|
+
ruby_eol = Scryer::DependencyAudit.ruby_eol_check(root)
|
|
98
|
+
credentials_exposure = Scryer::DependencyAudit.credentials_exposure_check(root)
|
|
95
99
|
|
|
96
|
-
(insecure + vulnerable).each do |f|
|
|
97
|
-
|
|
98
|
-
puts label
|
|
100
|
+
(insecure + vulnerable + ruby_eol + credentials_exposure).each do |f|
|
|
101
|
+
puts "[#{f.severity.upcase}] #{ScryerTasks.dependency_label(f)}"
|
|
99
102
|
puts " fix: #{f.suggested_fix}"
|
|
100
103
|
end
|
|
101
104
|
|
|
102
|
-
total = insecure.size + vulnerable.size
|
|
103
|
-
puts "\nScryer: #{total} dependency finding(s) (#{insecure.size} insecure source,
|
|
105
|
+
total = insecure.size + vulnerable.size + ruby_eol.size + credentials_exposure.size
|
|
106
|
+
puts "\nScryer: #{total} dependency finding(s) (#{insecure.size} insecure source, " \
|
|
107
|
+
"#{vulnerable.size} vulnerable gem, #{ruby_eol.size} Ruby EOL, " \
|
|
108
|
+
"#{credentials_exposure.size} credentials exposure)."
|
|
104
109
|
abort("Scryer: dependency audit failed.") if total.positive?
|
|
105
110
|
end
|
|
106
111
|
|
|
107
|
-
VALID_FORMATS = %w[json html csv].freeze
|
|
108
|
-
EXTENSION_FOR_FORMAT = { "json" => "json", "html" => "html", "csv" => "csv" }.freeze
|
|
112
|
+
VALID_FORMATS = %w[json html csv sarif].freeze
|
|
113
|
+
EXTENSION_FOR_FORMAT = { "json" => "json", "html" => "html", "csv" => "csv", "sarif" => "sarif" }.freeze
|
|
109
114
|
NO_DEPS_TOKEN = "nodeps".freeze
|
|
110
115
|
|
|
111
116
|
# tokens is every bracket arg Rake was given, e.g. %w[json doc/security.json] or
|
|
@@ -178,6 +183,13 @@ module ScryerTasks
|
|
|
178
183
|
nil
|
|
179
184
|
end
|
|
180
185
|
|
|
186
|
+
def dependency_label(f)
|
|
187
|
+
return f.message if f.kind == "insecure_source"
|
|
188
|
+
return "#{f.gem_name} #{f.installed_version} - #{f.advisory_id}: #{f.title}" if f.advisory_id
|
|
189
|
+
|
|
190
|
+
"#{f.gem_name} #{f.installed_version}: #{f.title}"
|
|
191
|
+
end
|
|
192
|
+
|
|
181
193
|
# The "one audit command" summary — a single scan's worth of every
|
|
182
194
|
# category Scryer covers (security, performance, duplicate/smelly code,
|
|
183
195
|
# dependencies), the same categories usually split across RuboCop +
|
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: 0.
|
|
4
|
+
version: 1.0.0
|
|
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-12 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rake
|
|
@@ -70,14 +70,31 @@ files:
|
|
|
70
70
|
- lib/scryer/report_renderer.rb
|
|
71
71
|
- lib/scryer/rule.rb
|
|
72
72
|
- lib/scryer/rule_set.rb
|
|
73
|
+
- lib/scryer/rules/action_cable_forgery_protection_rule.rb
|
|
74
|
+
- lib/scryer/rules/active_storage_inline_disposition_rule.rb
|
|
75
|
+
- lib/scryer/rules/active_storage_missing_content_type_validation_rule.rb
|
|
76
|
+
- lib/scryer/rules/authentication_bypass_rule.rb
|
|
73
77
|
- lib/scryer/rules/command_injection_rule.rb
|
|
78
|
+
- lib/scryer/rules/cors_misconfiguration_rule.rb
|
|
74
79
|
- lib/scryer/rules/csrf_protection_rule.rb
|
|
80
|
+
- lib/scryer/rules/force_ssl_rule.rb
|
|
81
|
+
- lib/scryer/rules/graphql_missing_query_limits_rule.rb
|
|
82
|
+
- lib/scryer/rules/hardcoded_basic_auth_rule.rb
|
|
83
|
+
- lib/scryer/rules/hardcoded_secret_key_base_rule.rb
|
|
75
84
|
- lib/scryer/rules/hardcoded_secret_rule.rb
|
|
85
|
+
- lib/scryer/rules/idor_rule.rb
|
|
86
|
+
- lib/scryer/rules/insecure_cookie_serializer_rule.rb
|
|
87
|
+
- lib/scryer/rules/job_raw_params_rule.rb
|
|
88
|
+
- lib/scryer/rules/jwt_insecure_rule.rb
|
|
76
89
|
- lib/scryer/rules/mass_assignment_rule.rb
|
|
77
90
|
- lib/scryer/rules/open_redirect_rule.rb
|
|
91
|
+
- lib/scryer/rules/path_traversal_rule.rb
|
|
92
|
+
- lib/scryer/rules/security_headers_rule.rb
|
|
78
93
|
- lib/scryer/rules/sql_injection_rule.rb
|
|
94
|
+
- lib/scryer/rules/ssrf_rule.rb
|
|
79
95
|
- lib/scryer/rules/unsafe_deserialization_rule.rb
|
|
80
96
|
- lib/scryer/rules/weak_crypto_rule.rb
|
|
97
|
+
- lib/scryer/rules/weak_session_cookie_rule.rb
|
|
81
98
|
- lib/scryer/rules/xss_unsafe_html_rule.rb
|
|
82
99
|
- lib/scryer/scanner.rb
|
|
83
100
|
- lib/scryer/style_rules/frozen_string_literal_rule.rb
|