scryer 0.3.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 +69 -0
- data/README.md +104 -19
- data/lib/scryer/ast.rb +69 -6
- data/lib/scryer/cli.rb +20 -8
- data/lib/scryer/dependency_audit.rb +108 -5
- data/lib/scryer/report_renderer.rb +102 -2
- 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
|
@@ -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
|