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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +87 -0
- data/README.md +148 -32
- data/lib/scryer/ast.rb +69 -6
- data/lib/scryer/cli.rb +33 -8
- data/lib/scryer/dependency_audit.rb +108 -5
- data/lib/scryer/report_renderer.rb +152 -4
- data/lib/scryer/rules/action_cable_forgery_protection_rule.rb +47 -0
- data/lib/scryer/rules/active_storage_inline_disposition_rule.rb +47 -0
- data/lib/scryer/rules/active_storage_missing_content_type_validation_rule.rb +76 -0
- data/lib/scryer/rules/authentication_bypass_rule.rb +72 -0
- data/lib/scryer/rules/cors_misconfiguration_rule.rb +69 -0
- data/lib/scryer/rules/force_ssl_rule.rb +45 -0
- data/lib/scryer/rules/graphql_missing_query_limits_rule.rb +75 -0
- data/lib/scryer/rules/hardcoded_basic_auth_rule.rb +48 -0
- data/lib/scryer/rules/hardcoded_secret_key_base_rule.rb +55 -0
- data/lib/scryer/rules/idor_rule.rb +111 -0
- data/lib/scryer/rules/insecure_cookie_serializer_rule.rb +44 -0
- data/lib/scryer/rules/job_raw_params_rule.rb +98 -0
- data/lib/scryer/rules/jwt_insecure_rule.rb +120 -0
- data/lib/scryer/rules/mass_assignment_rule.rb +2 -5
- data/lib/scryer/rules/path_traversal_rule.rb +89 -0
- data/lib/scryer/rules/security_headers_rule.rb +130 -0
- data/lib/scryer/rules/ssrf_rule.rb +85 -0
- data/lib/scryer/rules/weak_session_cookie_rule.rb +48 -0
- data/lib/scryer/version.rb +1 -1
- data/lib/tasks/scryer.rake +22 -10
- metadata +19 -2
|
@@ -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:} },
|
|
60
|
-
#
|
|
61
|
-
#
|
|
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
|
|
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
|
|
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
|
-
|
|
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"] ? " · <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
|
|
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("&", "&").gsub("<", "<").gsub(">", ">")
|
|
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 =
|
|
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
|