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.
@@ -77,16 +77,13 @@ module Scryer
77
77
  !NON_MODEL_RECEIVERS.include?(const_node[1])
78
78
  end
79
79
 
80
- # True if `node` is `params`, `params[:x]`, or contains such a reference
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.each_node(node).any? do |n|
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
@@ -1,3 +1,3 @@
1
1
  module Scryer
2
- VERSION = "0.2.0"
2
+ VERSION = "1.0.0"
3
3
  end
@@ -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
- label = f.kind == "insecure_source" ? "[#{f.severity.upcase}] #{f.message}" : "[#{f.severity.upcase}] #{f.gem_name} #{f.installed_version} - #{f.advisory_id}: #{f.title}"
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, #{vulnerable.size} vulnerable gem)."
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.2.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 00:00:00.000000000 Z
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