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.
@@ -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.3.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.3.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