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.
@@ -1,4 +1,5 @@
1
1
  require "json"
2
+ require "date"
2
3
 
3
4
  module Scryer
4
5
  # Dependency vulnerability + supply-chain-hygiene checks for Gemfile.lock —
@@ -55,15 +56,35 @@ module Scryer
55
56
  "LOW" => "info"
56
57
  }.freeze
57
58
 
59
+ # Ruby's own published maintenance-branch EOL dates
60
+ # (ruby-lang.org/en/downloads/branches/) — these are announced years in
61
+ # advance and essentially never move, unlike a CVE feed, so this is a
62
+ # one-time/occasional-update cost rather than an ongoing sync burden.
63
+ # Update when a new branch's EOL is announced, or an older branch not
64
+ # listed here needs adding.
65
+ RUBY_EOL_DATES = {
66
+ "2.5" => Date.new(2021, 3, 31),
67
+ "2.6" => Date.new(2022, 3, 31),
68
+ "2.7" => Date.new(2023, 3, 31),
69
+ "3.0" => Date.new(2024, 3, 31),
70
+ "3.1" => Date.new(2025, 3, 31),
71
+ "3.2" => Date.new(2026, 3, 31),
72
+ "3.3" => Date.new(2027, 3, 31),
73
+ "3.4" => Date.new(2028, 3, 31)
74
+ }.freeze
75
+
58
76
  class << self
59
- # Parses a Gemfile.lock into `{ gems: { name => {version:, source:} }, git_or_path_sources: [...] }`.
60
- # `source` is "gem", "git", or "path" taken from which top-level
61
- # block (GEM/GIT/PATH) the spec's `specs:` list appeared under.
77
+ # Parses a Gemfile.lock into `{ gems: { name => {version:, source:} },
78
+ # git_or_path_sources: [...], ruby_version: "3.3.3" | nil }`. `source`
79
+ # is "gem", "git", or "path" taken from which top-level block
80
+ # (GEM/GIT/PATH) the spec's `specs:` list appeared under.
62
81
  def parse_lockfile(path)
63
82
  gems = {}
64
83
  git_or_path_sources = []
84
+ ruby_version = nil
65
85
 
66
- current_block = nil # "gem" | "git" | "path" | other section name
86
+ current_block = nil # "gem" | "git" | "path" | nil (other section)
87
+ current_section = nil # PLATFORMS | DEPENDENCIES | BUNDLED WITH | RUBY VERSION | nil
67
88
  current_remote = nil
68
89
  in_specs = false
69
90
 
@@ -71,10 +92,12 @@ module Scryer
71
92
  case line
72
93
  when /\A(GEM|GIT|PATH)\s*\z/
73
94
  current_block = Regexp.last_match(1).downcase
95
+ current_section = nil
74
96
  current_remote = nil
75
97
  in_specs = false
76
98
  when /\A(PLATFORMS|DEPENDENCIES|BUNDLED WITH|RUBY VERSION)\s*\z/
77
99
  current_block = nil
100
+ current_section = Regexp.last_match(1)
78
101
  in_specs = false
79
102
  when /\A {2}remote:\s*(\S+)\s*\z/
80
103
  current_remote = Regexp.last_match(1)
@@ -90,10 +113,46 @@ module Scryer
90
113
  # in pathological Gemfiles; last one wins, consistent with how
91
114
  # Bundler itself resolves a single spec per gem name.
92
115
  gems[name] = { version: version, source: current_block }
116
+ when /\A\s*ruby\s+(\S+)\s*\z/
117
+ ruby_version = Regexp.last_match(1) if current_section == "RUBY VERSION"
93
118
  end
94
119
  end
95
120
 
96
- { gems: gems, git_or_path_sources: git_or_path_sources }
121
+ { gems: gems, git_or_path_sources: git_or_path_sources, ruby_version: ruby_version }
122
+ end
123
+
124
+ # Offline. Flags the Ruby version pinned in Gemfile.lock's RUBY
125
+ # VERSION section if its minor series is past end-of-life — after that
126
+ # date Ruby publishes no more security patches for it, for any issue,
127
+ # so this is a real gap even with every gem otherwise up to date.
128
+ # Returns [] if no version is pinned, or its series isn't one
129
+ # RUBY_EOL_DATES knows about (never guessed as "fine" by omission).
130
+ def ruby_eol_check(root)
131
+ lockfile = File.join(root, "Gemfile.lock")
132
+ return [] unless File.exist?(lockfile)
133
+
134
+ ruby_version = parse_lockfile(lockfile)[:ruby_version]
135
+ return [] unless ruby_version
136
+
137
+ series = ruby_version[/\A\d+\.\d+/]
138
+ eol_date = series && RUBY_EOL_DATES[series]
139
+ return [] unless eol_date && Date.today > eol_date
140
+
141
+ [
142
+ Finding.new(
143
+ kind: "ruby_eol",
144
+ gem_name: "Ruby",
145
+ installed_version: ruby_version,
146
+ severity: "critical",
147
+ title: "Ruby #{series} is end-of-life",
148
+ url: "https://www.ruby-lang.org/en/downloads/branches/",
149
+ patched_versions: [],
150
+ message: "Ruby #{ruby_version} (#{series} series) reached end-of-life on " \
151
+ "#{eol_date} — no security patches are published for it anymore, for any issue.",
152
+ suggested_fix: "Upgrade to a Ruby version still receiving security maintenance — see " \
153
+ "https://www.ruby-lang.org/en/downloads/branches/ for current status."
154
+ )
155
+ ]
97
156
  end
98
157
 
99
158
  # Offline. Flags GIT/PATH sources recorded with an unencrypted remote
@@ -160,6 +219,41 @@ module Scryer
160
219
  findings
161
220
  end
162
221
 
222
+ # Offline. Flags `config/master.key` (the key that decrypts Rails
223
+ # encrypted credentials, config/credentials.yml.enc) if it exists on
224
+ # disk and .gitignore doesn't exclude it — Rails generates it
225
+ # gitignored by default (`/config/master.key`), but that line is easy
226
+ # to lose (a merge, a from-scratch .gitignore, copying the file into a
227
+ # repo that never had the Rails default). If this file is ever
228
+ # committed, anyone with repo access — including git history, even
229
+ # after a later removal — can decrypt every credential in
230
+ # config/credentials.yml.enc.
231
+ def credentials_exposure_check(root)
232
+ master_key_path = File.join(root, "config", "master.key")
233
+ return [] unless File.exist?(master_key_path)
234
+ return [] if master_key_gitignored?(root)
235
+
236
+ [
237
+ Finding.new(
238
+ kind: "credentials_exposure",
239
+ gem_name: "Rails",
240
+ severity: "critical",
241
+ title: "config/master.key is present and not gitignored",
242
+ url: "https://guides.rubyonrails.org/security.html",
243
+ patched_versions: [],
244
+ message: "config/master.key exists in this app, but .gitignore doesn't exclude it " \
245
+ "(checked for both `/config/master.key` and `config/master.key`) — if this " \
246
+ "file is ever committed, anyone with repository access, including git " \
247
+ "history even after a later removal, can decrypt every credential in " \
248
+ "config/credentials.yml.enc.",
249
+ suggested_fix: "Add `/config/master.key` to .gitignore immediately. If this key was " \
250
+ "ever actually committed to git history, treat every credential in " \
251
+ "config/credentials.yml.enc as compromised: rotate them and regenerate " \
252
+ "the master key (delete both files, then `bin/rails credentials:edit`)."
253
+ )
254
+ ]
255
+ end
256
+
163
257
  # Needs network. One-off OSV.dev lookup for a single gem, independent
164
258
  # of any Gemfile.lock — backs `scryer --check-gem NAME[:VERSION]`.
165
259
  # With a version, only vulnerabilities affecting that exact version
@@ -171,6 +265,15 @@ module Scryer
171
265
 
172
266
  private
173
267
 
268
+ def master_key_gitignored?(root)
269
+ gitignore_path = File.join(root, ".gitignore")
270
+ return false unless File.exist?(gitignore_path)
271
+
272
+ File.foreach(gitignore_path).any? do |line|
273
+ line.strip.match?(%r{\A/?config/master\.key\z})
274
+ end
275
+ end
276
+
174
277
  def query_osv(name, version = nil)
175
278
  require "net/http"
176
279
  require "uri"
@@ -73,6 +73,18 @@ module Scryer
73
73
  rows.map { |row| row.map { |field| csv_field(field) }.join(",") }.join("\n")
74
74
  end
75
75
 
76
+ SARIF_LEVEL_BY_SEVERITY = { "critical" => "error", "warning" => "warning", "info" => "note" }.freeze
77
+
78
+ # SARIF 2.1.0 (docs.oasis-open.org/sarif/sarif/v2.1.0) — the format
79
+ # GitHub Code Scanning (and other CI security dashboards) natively
80
+ # ingest, turning findings into inline PR annotations and Security-tab
81
+ # entries instead of a report file nobody opens. Pure data mapping of
82
+ # what's already in as_hash — no new detection logic, and every finding
83
+ # behaves identically to how it does in the other formats.
84
+ def as_sarif
85
+ JSON.pretty_generate(sarif_hash)
86
+ end
87
+
76
88
  def as_html
77
89
  h = as_hash
78
90
  security = h["security_findings"]
@@ -353,7 +365,7 @@ module Scryer
353
365
  </div>
354
366
  <p>#{escape(f["message"])}</p>
355
367
  #{f["code_snippet"] ? "<pre>#{escape(f["code_snippet"])}</pre>" : ""}
356
- <div class="fix"><strong>Suggested fix:</strong> #{escape(f["suggested_fix"])}</div>
368
+ <div class="fix"><strong>Suggested fix:</strong>#{render_markdown(f["suggested_fix"])}</div>
357
369
  </div>
358
370
  ROW
359
371
  end
@@ -408,7 +420,8 @@ module Scryer
408
420
  def render_dependency_finding(f)
409
421
  severity = f["severity"]
410
422
  heading = f["kind"] == "insecure_source" ? "Insecure gem source" : "#{escape(f["gem_name"])} #{escape(f["installed_version"])}"
411
- advisory = f["advisory_id"] ? "<span class=\"loc\">#{escape(f["advisory_id"])}#{f["title"] ? " — #{escape(f["title"])}" : ""}</span>" : ""
423
+ advisory_text = [f["advisory_id"], f["title"]].compact.map { |t| escape(t) }.join(" ")
424
+ advisory = advisory_text.empty? ? "" : "<span class=\"loc\">#{advisory_text}</span>"
412
425
  link = f["url"] ? " &middot; <a href=\"#{escape(f["url"])}\" target=\"_blank\" rel=\"noopener\">advisory</a>" : ""
413
426
  patched = Array(f["patched_versions"])
414
427
 
@@ -421,7 +434,7 @@ module Scryer
421
434
  </div>
422
435
  <p>#{escape(f["message"])}</p>
423
436
  #{patched.empty? ? "" : "<p class=\"loc\">Patched version(s): #{escape(patched.join(", "))}</p>"}
424
- <div class="fix"><strong>Suggested fix:</strong> #{escape(f["suggested_fix"])}</div>
437
+ <div class="fix"><strong>Suggested fix:</strong>#{render_markdown(f["suggested_fix"])}</div>
425
438
  </div>
426
439
  ROW
427
440
  end
@@ -430,6 +443,48 @@ module Scryer
430
443
  text.to_s.gsub("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;")
431
444
  end
432
445
 
446
+ CODE_FENCE = /```\w*\n?(.*?)```/m
447
+ BOLD = /\*\*(.+?)\*\*/
448
+ INLINE_CODE = /`([^`]+?)`/
449
+
450
+ # Suggested-fix text is always human-written prose, whether it came from
451
+ # a rule's static template (occasional inline `code`) or an LLM rewrite
452
+ # (often full Markdown: **bold**, ```fenced code```, paragraphs) — see
453
+ # AiFixSuggester's prompts, which explicitly ask for a
454
+ # "before/after code example". Rendering it as one plain escaped string
455
+ # left literal ** and ``` visible in the HTML report instead of actual
456
+ # formatting. This renders the handful of constructs those two sources
457
+ # actually produce; anything unrecognized just passes through as escaped
458
+ # text, same as before.
459
+ #
460
+ # Every code path here escapes raw text *before* adding any HTML tags of
461
+ # its own, so nothing in `text` (however untrusted — this may be verbatim
462
+ # LLM output) can inject markup; the only unescaped HTML is the literal
463
+ # tag strings this method writes itself.
464
+ def render_markdown(text)
465
+ return "" if text.nil?
466
+
467
+ html = +""
468
+ pos = 0
469
+ text.to_s.scan(CODE_FENCE) do
470
+ match = Regexp.last_match
471
+ html << render_prose(text[pos...match.begin(0)])
472
+ html << "<pre>#{escape(match[1].strip)}</pre>"
473
+ pos = match.end(0)
474
+ end
475
+ html << render_prose(text[pos..])
476
+ html
477
+ end
478
+
479
+ def render_prose(text)
480
+ return "" if text.nil? || text.strip.empty?
481
+
482
+ text.strip.split(/\n{2,}/).map do |paragraph|
483
+ formatted = escape(paragraph.strip).gsub(BOLD, '<strong>\1</strong>').gsub(INLINE_CODE, '<code>\1</code>')
484
+ "<p>#{formatted.gsub("\n", "<br>")}</p>"
485
+ end.join
486
+ end
487
+
433
488
  def static_csv_row(f)
434
489
  [
435
490
  f["category"], f["rule_id"], f["severity"],
@@ -439,7 +494,14 @@ module Scryer
439
494
  end
440
495
 
441
496
  def dependency_csv_row(f)
442
- identifier = f["kind"] == "insecure_source" ? "insecure_source" : "#{f["gem_name"]} #{f["installed_version"]} (#{f["advisory_id"]})"
497
+ identifier =
498
+ if f["kind"] == "insecure_source"
499
+ "insecure_source"
500
+ elsif f["advisory_id"]
501
+ "#{f["gem_name"]} #{f["installed_version"]} (#{f["advisory_id"]})"
502
+ else
503
+ "#{f["gem_name"]} #{f["installed_version"]}"
504
+ end
443
505
  [f["kind"], identifier, f["severity"], "Gemfile.lock", f["message"], f["suggested_fix"], nil, f["url"]]
444
506
  end
445
507
 
@@ -448,6 +510,86 @@ module Scryer
448
510
  s.match?(/[",\n\r]/) ? "\"#{s.gsub('"', '""')}\"" : s
449
511
  end
450
512
 
513
+ # kind/title/severity for the three DependencyAudit finding kinds, which
514
+ # (unlike security/performance/style findings) aren't backed by a
515
+ # Scryer::Rule — described here just so SARIF's tool.driver.rules[]
516
+ # taxonomy has an entry for them too.
517
+ DEPENDENCY_SARIF_RULES = [
518
+ { "id" => "vulnerable_dependency", "title" => "Known-vulnerable gem version (OSV.dev)", "severity" => "critical" },
519
+ { "id" => "insecure_source", "title" => "Insecure (unencrypted) Gemfile.lock source", "severity" => "warning" },
520
+ { "id" => "ruby_eol", "title" => "Ruby version is end-of-life", "severity" => "critical" },
521
+ { "id" => "credentials_exposure", "title" => "config/master.key present and not gitignored", "severity" => "critical" }
522
+ ].freeze
523
+
524
+ def sarif_hash
525
+ h = as_hash
526
+ findings = h["security_findings"] + h["performance_findings"] + h["style_findings"]
527
+
528
+ {
529
+ "$schema" => "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json",
530
+ "version" => "2.1.0",
531
+ "runs" => [
532
+ {
533
+ "tool" => {
534
+ "driver" => {
535
+ "name" => "Scryer",
536
+ "version" => Scryer::VERSION,
537
+ "informationUri" => "https://ramlaxmanyadav.github.io/scryer/",
538
+ "rules" => sarif_rules
539
+ }
540
+ },
541
+ "results" => findings.map { |f| sarif_result(f) } + h["dependency_findings"].map { |f| sarif_dependency_result(f) }
542
+ }
543
+ ]
544
+ }
545
+ end
546
+
547
+ def sarif_rules
548
+ rule_entries = Scryer::RuleSet.all.map do |rule|
549
+ {
550
+ "id" => rule.rule_id,
551
+ "name" => rule.rule_id,
552
+ "shortDescription" => { "text" => rule.title },
553
+ "defaultConfiguration" => { "level" => SARIF_LEVEL_BY_SEVERITY[rule.default_severity] || "warning" }
554
+ }
555
+ end
556
+
557
+ dependency_entries = DEPENDENCY_SARIF_RULES.map do |r|
558
+ {
559
+ "id" => r["id"],
560
+ "name" => r["id"],
561
+ "shortDescription" => { "text" => r["title"] },
562
+ "defaultConfiguration" => { "level" => SARIF_LEVEL_BY_SEVERITY[r["severity"]] || "warning" }
563
+ }
564
+ end
565
+
566
+ rule_entries + dependency_entries
567
+ end
568
+
569
+ def sarif_result(f)
570
+ physical_location = { "artifactLocation" => { "uri" => f["file"] } }
571
+ physical_location["region"] = { "startLine" => f["line"] } if f["line"]
572
+
573
+ {
574
+ "ruleId" => f["rule_id"],
575
+ "level" => SARIF_LEVEL_BY_SEVERITY[f["severity"]] || "warning",
576
+ "message" => { "text" => f["message"] },
577
+ "locations" => [{ "physicalLocation" => physical_location }]
578
+ }
579
+ end
580
+
581
+ # Dependency findings don't point at a line in app source — Gemfile.lock
582
+ # itself is the meaningful "location" (no region: nothing to underline
583
+ # inside it the way a code finding underlines a specific line).
584
+ def sarif_dependency_result(f)
585
+ {
586
+ "ruleId" => f["kind"],
587
+ "level" => SARIF_LEVEL_BY_SEVERITY[f["severity"]] || "warning",
588
+ "message" => { "text" => f["message"] },
589
+ "locations" => [{ "physicalLocation" => { "artifactLocation" => { "uri" => "Gemfile.lock" } } }]
590
+ }
591
+ end
592
+
451
593
  CSS = <<~CSS
452
594
  body { font-family: -apple-system, Helvetica, Arial, sans-serif; margin: 2rem; color: #1e293b; }
453
595
  h1 { margin-bottom: 0.25rem; }
@@ -474,7 +616,13 @@ module Scryer
474
616
  .badge.kind { background: #ede9fe; color: #5b21b6; }
475
617
  .loc { color: #64748b; font-size: 0.8rem; }
476
618
  pre { background: #0f172a; color: #e2e8f0; padding: 0.5rem 0.75rem; border-radius: 6px; overflow-x: auto; font-size: 0.8rem; }
619
+ code { background: #e2e8f0; color: #334155; padding: 0.1rem 0.35rem; border-radius: 4px; font-size: 0.85em; }
477
620
  .fix { background: #eef2ff; border-radius: 6px; padding: 0.5rem 0.75rem; font-size: 0.875rem; }
621
+ .fix > strong { display: block; margin-bottom: 0.35rem; }
622
+ .fix p { margin: 0 0 0.5rem; }
623
+ .fix p:last-child { margin-bottom: 0; }
624
+ .fix pre { margin: 0.5rem 0 0; }
625
+ .fix pre:last-child { margin-bottom: 0; }
478
626
  .dup-member { margin-top: 0.5rem; }
479
627
  .dup-member:first-child { margin-top: 0; }
480
628
  .muted { color: #94a3b8; }
@@ -0,0 +1,47 @@
1
+ module Scryer
2
+ module Rules
3
+ # Flags `config.action_cable.disable_request_forgery_protection = true`
4
+ # — an explicit opt-out of Action Cable's default check that a
5
+ # WebSocket connection's request `Origin` header matches the app's own
6
+ # allowed origins, which otherwise blocks cross-site WebSocket hijacking.
7
+ # Same shape/reasoning as ForceSslRule: only the explicit opt-in to the
8
+ # insecure behavior is flagged, not its absence.
9
+ class ActionCableForgeryProtectionRule < Rule
10
+ self.rule_id = "action_cable_forgery_protection_disabled"
11
+ self.category = "security"
12
+ self.default_severity = "critical"
13
+ self.title = "Action Cable request forgery protection explicitly disabled"
14
+
15
+ def scan
16
+ findings = []
17
+
18
+ Ast.each_node(sexp) do |node|
19
+ next unless Ast.tagged?(node, :assign)
20
+
21
+ target = node[1]
22
+ next unless Ast.tagged?(target, :field)
23
+ next unless Ast.ident_text(target[3]) == "disable_request_forgery_protection"
24
+
25
+ value = node[2]
26
+ next unless Ast.true_literal?(value)
27
+
28
+ line = Ast.line_of(node)
29
+ findings << finding(
30
+ line: line,
31
+ message: "`config.action_cable.disable_request_forgery_protection = true` explicitly " \
32
+ "disables Action Cable's default check that a WebSocket connection's " \
33
+ "`Origin` header matches an allowed origin — without it, another site can " \
34
+ "open a WebSocket connection to this app in a visitor's browser and act as " \
35
+ "that visitor (cross-site WebSocket hijacking).",
36
+ suggested_fix: "Remove this override and set `config.action_cable.allowed_request_origins` " \
37
+ "to the app's real origin(s) instead, unless request forgery protection is " \
38
+ "deliberately being handled some other way — if so, leave a comment " \
39
+ "explaining that."
40
+ )
41
+ end
42
+
43
+ findings
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,47 @@
1
+ module Scryer
2
+ module Rules
3
+ # Flags an explicit `disposition: "inline"`/`:inline` on a blob/variant
4
+ # URL helper (`rails_blob_path`, `rails_blob_url`, `url_for`, a
5
+ # `.variant(...)` chain). Serving user-uploaded content inline (rendered
6
+ # directly in the browser, rather than downloaded) can lead to stored
7
+ # XSS if the uploaded file's content-type isn't tightly restricted — an
8
+ # uploaded SVG or HTML file executes in the page's own origin.
9
+ class ActiveStorageInlineDispositionRule < Rule
10
+ self.rule_id = "active_storage_inline_disposition"
11
+ self.category = "security"
12
+ self.default_severity = "warning"
13
+ self.title = "Active Storage content served with inline disposition"
14
+
15
+ def scan
16
+ findings = []
17
+
18
+ Ast.each_node(sexp) do |node|
19
+ next unless Ast.tagged?(node, :method_add_arg, :command, :command_call)
20
+
21
+ inner = Ast.tagged?(node, :method_add_arg) ? node[1] : node
22
+ name_pair = Ast.call_name(inner)
23
+ next unless name_pair
24
+
25
+ args = Ast.call_arguments(node)
26
+ disposition = Ast.keyword_arg(args, "disposition")
27
+ next unless disposition && Ast.literal_text(disposition) == "inline"
28
+
29
+ line = Ast.line_of(node)
30
+ findings << finding(
31
+ line: line,
32
+ message: "`disposition: \"inline\"` renders this attachment's content directly in " \
33
+ "the browser instead of downloading it — if the attachment's content-type " \
34
+ "isn't tightly restricted, a user-uploaded SVG or HTML file served this way " \
35
+ "executes as if it were part of the site.",
36
+ suggested_fix: "Prefer the default `disposition: \"attachment\"` (or drop the option " \
37
+ "entirely) unless inline rendering is genuinely required — and if it " \
38
+ "is, make sure the attachment has a strict `content_type:` allowlist " \
39
+ "(e.g. image types only) so nothing executable can reach this code path."
40
+ )
41
+ end
42
+
43
+ findings
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,76 @@
1
+ module Scryer
2
+ module Rules
3
+ # Flags `has_one_attached`/`has_many_attached :name` with no
4
+ # `validates :name, content_type: [...]` anywhere in the same class —
5
+ # without a content-type allowlist, a user can upload anything (an SVG
6
+ # or HTML file that executes script when served, an executable, ...),
7
+ # not just the file type the feature was built for.
8
+ class ActiveStorageMissingContentTypeValidationRule < Rule
9
+ self.rule_id = "active_storage_missing_content_type_validation"
10
+ self.category = "security"
11
+ self.default_severity = "warning"
12
+ self.title = "Active Storage attachment without a content-type validation"
13
+
14
+ ATTACHMENT_METHODS = %w[has_one_attached has_many_attached].freeze
15
+
16
+ def scan
17
+ findings = []
18
+
19
+ Ast.each_node(sexp) do |node|
20
+ next unless Ast.tagged?(node, :class)
21
+
22
+ body = node[3]
23
+ validated_names = each_content_type_validated_names(body)
24
+
25
+ each_attachment(body).each do |call_node, name|
26
+ next if validated_names.include?(name)
27
+
28
+ line = Ast.line_of(call_node)
29
+ findings << finding(
30
+ line: line,
31
+ message: "`:#{name}` is attached via Active Storage with no `content_type:` " \
32
+ "validation anywhere in this class — any file type can be uploaded, " \
33
+ "including ones that execute in a browser if ever served back (e.g. SVG, " \
34
+ "HTML) or aren't safe to store at all.",
35
+ suggested_fix: "Add an explicit allowlist: `validates :#{name}, content_type: " \
36
+ "['image/png', 'image/jpeg']` (whatever types this feature actually " \
37
+ "needs) so anything else is rejected at upload time."
38
+ )
39
+ end
40
+ end
41
+
42
+ findings
43
+ end
44
+
45
+ private
46
+
47
+ def each_attachment(body)
48
+ Ast.each_node(body).filter_map do |n|
49
+ next unless Ast.tagged?(n, :command, :command_call, :method_add_arg)
50
+
51
+ inner = Ast.tagged?(n, :method_add_arg) ? n[1] : n
52
+ name_pair = Ast.call_name(inner)
53
+ next unless name_pair && ATTACHMENT_METHODS.include?(name_pair[1])
54
+
55
+ name = Ast.call_arguments(n).filter_map { |a| Ast.literal_text(a) }.first
56
+ [n, name] if name
57
+ end
58
+ end
59
+
60
+ def each_content_type_validated_names(body)
61
+ Ast.each_node(body).filter_map do |n|
62
+ next unless Ast.tagged?(n, :command, :command_call, :method_add_arg)
63
+
64
+ inner = Ast.tagged?(n, :method_add_arg) ? n[1] : n
65
+ name_pair = Ast.call_name(inner)
66
+ next unless name_pair && name_pair[1] == "validates"
67
+
68
+ args = Ast.call_arguments(n)
69
+ next unless Ast.keyword_arg(args, "content_type")
70
+
71
+ Ast.literal_text(args.first)
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,72 @@
1
+ module Scryer
2
+ module Rules
3
+ # Flags a controller class that calls `skip_before_action`/
4
+ # `skip_action_callback` naming a common authentication filter
5
+ # (`authenticate_user!`, `authenticate!`, ...) — same shape and reasoning
6
+ # as CsrfProtectionRule, just for auth filters instead of CSRF: skipping
7
+ # one is sometimes correct (a public endpoint, a webhook) but is also a
8
+ # common way to accidentally leave an action reachable without login,
9
+ # especially with a broad `except:`/no scoping at all.
10
+ class AuthenticationBypassRule < Rule
11
+ self.rule_id = "authentication_bypass"
12
+ self.category = "security"
13
+ self.default_severity = "warning"
14
+ self.title = "Authentication filter explicitly skipped"
15
+
16
+ SKIP_METHODS = %w[skip_before_action skip_action_callback skip_before_filter].freeze
17
+ AUTH_FILTER_NAMES = %w[
18
+ authenticate_user! authenticate! authenticate_admin! authenticate_account!
19
+ require_login require_authentication authorize_request
20
+ ].freeze
21
+
22
+ def scan
23
+ findings = []
24
+
25
+ Ast.each_node(sexp) do |node|
26
+ next unless Ast.tagged?(node, :class)
27
+
28
+ class_name = Ast.ident_text(node[1].is_a?(Array) ? node[1][1] : nil)
29
+ next unless class_name.to_s.end_with?("Controller")
30
+
31
+ each_skip_call(node[3]).each do |skip_node, filter_name|
32
+ line = Ast.line_of(skip_node)
33
+ findings << finding(
34
+ line: line,
35
+ message: "`#{class_name}` skips the `#{filter_name}` authentication filter " \
36
+ "(`#{skip_call_method(skip_node)} :#{filter_name}`) — every action this " \
37
+ "applies to is reachable without logging in unless something else in " \
38
+ "this controller re-checks authentication.",
39
+ suggested_fix: "If this is genuinely a public action (a webhook, a login/signup " \
40
+ "page), scope the skip tightly with `only: [:action_name]` rather " \
41
+ "than leaving it unscoped or using a broad `except:`. If it's not " \
42
+ "meant to be public, remove the skip."
43
+ )
44
+ end
45
+ end
46
+
47
+ findings
48
+ end
49
+
50
+ private
51
+
52
+ def each_skip_call(body)
53
+ Ast.each_node(body).filter_map do |n|
54
+ next unless Ast.tagged?(n, :method_add_arg, :command, :command_call)
55
+
56
+ inner = Ast.tagged?(n, :method_add_arg) ? n[1] : n
57
+ name_pair = Ast.call_name(inner)
58
+ next unless name_pair && SKIP_METHODS.include?(name_pair[1])
59
+
60
+ args = Ast.call_arguments(n)
61
+ filter_name = args.filter_map { |a| Ast.literal_text(a) }.find { |v| AUTH_FILTER_NAMES.include?(v) }
62
+ [n, filter_name] if filter_name
63
+ end
64
+ end
65
+
66
+ def skip_call_method(node)
67
+ inner = Ast.tagged?(node, :method_add_arg) ? node[1] : node
68
+ Ast.call_name(inner)&.last
69
+ end
70
+ end
71
+ end
72
+ end