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.
Files changed (56) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +253 -0
  3. data/README.md +459 -41
  4. data/lib/generators/scryer/USAGE +10 -2
  5. data/lib/generators/scryer/templates/scryer_initializer.rb +15 -0
  6. data/lib/scryer/ai_fix_suggester.rb +29 -9
  7. data/lib/scryer/ast.rb +26 -0
  8. data/lib/scryer/authorization_watcher.rb +156 -0
  9. data/lib/scryer/baseline.rb +75 -0
  10. data/lib/scryer/cli.rb +189 -3
  11. data/lib/scryer/finding.rb +6 -0
  12. data/lib/scryer/fix_verifier.rb +82 -0
  13. data/lib/scryer/minitest.rb +48 -0
  14. data/lib/scryer/performance_rules/inefficient_save_loop_rule.rb +32 -0
  15. data/lib/scryer/performance_rules/missing_pagination_rule.rb +1 -0
  16. data/lib/scryer/performance_rules/n_plus_one_query_rule.rb +1 -0
  17. data/lib/scryer/performance_rules/unbounded_table_scan_rule.rb +1 -0
  18. data/lib/scryer/report_renderer.rb +539 -46
  19. data/lib/scryer/rspec.rb +55 -0
  20. data/lib/scryer/rule.rb +22 -2
  21. data/lib/scryer/rules/action_cable_forgery_protection_rule.rb +3 -0
  22. data/lib/scryer/rules/active_storage_inline_disposition_rule.rb +3 -0
  23. data/lib/scryer/rules/active_storage_missing_content_type_validation_rule.rb +3 -0
  24. data/lib/scryer/rules/authentication_bypass_rule.rb +30 -7
  25. data/lib/scryer/rules/command_injection_rule.rb +3 -0
  26. data/lib/scryer/rules/consider_all_requests_local_rule.rb +51 -0
  27. data/lib/scryer/rules/cors_misconfiguration_rule.rb +51 -20
  28. data/lib/scryer/rules/csrf_protection_rule.rb +60 -11
  29. data/lib/scryer/rules/force_ssl_rule.rb +3 -0
  30. data/lib/scryer/rules/graphql_missing_query_limits_rule.rb +31 -0
  31. data/lib/scryer/rules/hardcoded_basic_auth_rule.rb +3 -0
  32. data/lib/scryer/rules/hardcoded_secret_key_base_rule.rb +3 -0
  33. data/lib/scryer/rules/hardcoded_secret_rule.rb +3 -0
  34. data/lib/scryer/rules/host_authorization_disabled_rule.rb +50 -0
  35. data/lib/scryer/rules/idor_rule.rb +63 -9
  36. data/lib/scryer/rules/insecure_cookie_serializer_rule.rb +3 -0
  37. data/lib/scryer/rules/job_raw_params_rule.rb +40 -7
  38. data/lib/scryer/rules/jwt_insecure_rule.rb +3 -0
  39. data/lib/scryer/rules/mass_assignment_rule.rb +32 -5
  40. data/lib/scryer/rules/missing_authorization_rule.rb +103 -0
  41. data/lib/scryer/rules/missing_policy_scope_rule.rb +134 -0
  42. data/lib/scryer/rules/open_redirect_rule.rb +3 -0
  43. data/lib/scryer/rules/path_traversal_rule.rb +22 -1
  44. data/lib/scryer/rules/security_headers_rule.rb +3 -0
  45. data/lib/scryer/rules/sql_injection_rule.rb +3 -0
  46. data/lib/scryer/rules/ssrf_rule.rb +67 -13
  47. data/lib/scryer/rules/unsafe_deserialization_rule.rb +3 -0
  48. data/lib/scryer/rules/verbose_production_log_level_rule.rb +53 -0
  49. data/lib/scryer/rules/weak_crypto_rule.rb +37 -2
  50. data/lib/scryer/rules/weak_session_cookie_rule.rb +3 -0
  51. data/lib/scryer/rules/xss_unsafe_html_rule.rb +41 -0
  52. data/lib/scryer/style_rules/frozen_string_literal_rule.rb +1 -0
  53. data/lib/scryer/version.rb +1 -1
  54. data/lib/scryer.rb +15 -0
  55. data/lib/tasks/scryer.rake +117 -4
  56. 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
- next unless args.any? { |a| Ast.references_params?(a) }
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
- 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
- )
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
- PASSWORD_HINT = /password|passwd|credential/i.freeze
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
@@ -12,6 +12,7 @@ module Scryer
12
12
  self.category = "style"
13
13
  self.default_severity = "info"
14
14
  self.title = "Missing `frozen_string_literal` magic comment"
15
+ self.confidence = "high"
15
16
 
16
17
  MAGIC_COMMENT = /\A#\s*frozen_string_literal:\s*(true|false)\s*\z/i.freeze
17
18
 
@@ -1,3 +1,3 @@
1
1
  module Scryer
2
- VERSION = "1.0.0"
2
+ VERSION = "1.1.1"
3
3
  end
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
 
@@ -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.0.0
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-12 00:00:00.000000000 Z
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
- One scan, one report: security, performance, duplicate-code, and dependency audits for Ruby
29
- and Rails — the ground Brakeman, bundler-audit, Reek, and custom glue scripts usually split
30
- between them, covered by a single `scryer` command. (Style/lint is RuboCop's job — Scryer
31
- doesn't touch that.)
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, hardcoded secrets, XSS, weak crypto, and more; N+1
34
- queries, missing pagination, and other performance heuristics; near-duplicate code; and
35
- known-vulnerable gems via a live OSV.dev dependency audit — on by default, every run.
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 CSV. Zero runtime dependencies beyond Ruby's own stdlib.
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 & quality auditor — one command instead of Brakeman +
131
- bundler-audit + Reek.
143
+ summary: Ruby/Rails application security & risk auditor — tells you what to fix first.
132
144
  test_files: []