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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +69 -0
- data/README.md +104 -19
- data/lib/scryer/ast.rb +69 -6
- data/lib/scryer/cli.rb +20 -8
- data/lib/scryer/dependency_audit.rb +108 -5
- data/lib/scryer/report_renderer.rb +102 -2
- 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
|
@@ -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"]
|
|
@@ -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
|
|
|
@@ -481,7 +494,14 @@ module Scryer
|
|
|
481
494
|
end
|
|
482
495
|
|
|
483
496
|
def dependency_csv_row(f)
|
|
484
|
-
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
|
|
485
505
|
[f["kind"], identifier, f["severity"], "Gemfile.lock", f["message"], f["suggested_fix"], nil, f["url"]]
|
|
486
506
|
end
|
|
487
507
|
|
|
@@ -490,6 +510,86 @@ module Scryer
|
|
|
490
510
|
s.match?(/[",\n\r]/) ? "\"#{s.gsub('"', '""')}\"" : s
|
|
491
511
|
end
|
|
492
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
|
+
|
|
493
593
|
CSS = <<~CSS
|
|
494
594
|
body { font-family: -apple-system, Helvetica, Arial, sans-serif; margin: 2rem; color: #1e293b; }
|
|
495
595
|
h1 { margin-bottom: 0.25rem; }
|
|
@@ -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
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
module Scryer
|
|
2
|
+
module Rules
|
|
3
|
+
# Flags the well-known Rack::Cors antipattern: a wildcard origin
|
|
4
|
+
# (`origins '*'`) combined with `credentials: true` on a `resource` call.
|
|
5
|
+
# Per the CORS spec, browsers reject this combination in practice, but
|
|
6
|
+
# it's still the standard misconfiguration flagged in security reviews —
|
|
7
|
+
# either half alone (wildcard origin with no credentials, or credentials
|
|
8
|
+
# with a real origin allowlist) is fine.
|
|
9
|
+
#
|
|
10
|
+
# `config/initializers/cors.rb` is small and self-contained, so this
|
|
11
|
+
# checks "does `origins(...)` with a literal `'*'` appear anywhere in the
|
|
12
|
+
# file AND does a `resource` call with `credentials: true` appear
|
|
13
|
+
# anywhere in the file" rather than requiring both inside the exact same
|
|
14
|
+
# `allow do ... end` block — the same file-wide-evidence looseness
|
|
15
|
+
# IdorRule uses for its class-wide authorization check.
|
|
16
|
+
class CorsMisconfigurationRule < Rule
|
|
17
|
+
self.rule_id = "cors_misconfiguration"
|
|
18
|
+
self.category = "security"
|
|
19
|
+
self.default_severity = "critical"
|
|
20
|
+
self.title = "CORS wildcard origin combined with credentials"
|
|
21
|
+
|
|
22
|
+
def scan
|
|
23
|
+
findings = []
|
|
24
|
+
return findings unless wildcard_origin_anywhere?(sexp)
|
|
25
|
+
|
|
26
|
+
each_credentialed_resource(sexp).each do |node|
|
|
27
|
+
findings << finding(
|
|
28
|
+
line: Ast.line_of(node),
|
|
29
|
+
message: "This file allows a wildcard origin (`origins '*'`) somewhere and this " \
|
|
30
|
+
"`resource` call sets `credentials: true` — browsers won't honor that " \
|
|
31
|
+
"combination for actual credentialed requests, and Rack::Cors handling it " \
|
|
32
|
+
"inconsistently is the standard CORS misconfiguration flagged in reviews. " \
|
|
33
|
+
"Either restrict origins to a real allowlist, or drop `credentials: true`.",
|
|
34
|
+
suggested_fix: "Replace the wildcard with an explicit origin allowlist wherever " \
|
|
35
|
+
"`credentials: true` is set: `origins 'https://app.example.com'` " \
|
|
36
|
+
"instead of `origins '*'` — a wildcard origin should only be paired " \
|
|
37
|
+
"with `credentials: false` (the default)."
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
findings
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def wildcard_origin_anywhere?(node)
|
|
47
|
+
Ast.each_node(node).any? do |n|
|
|
48
|
+
next false unless Ast.tagged?(n, :method_add_arg, :command, :command_call)
|
|
49
|
+
|
|
50
|
+
inner = Ast.tagged?(n, :method_add_arg) ? n[1] : n
|
|
51
|
+
next false unless Ast.call_name(inner)&.last == "origins"
|
|
52
|
+
|
|
53
|
+
Ast.call_arguments(n).any? { |a| Ast.plain_string_value(a) == "*" }
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def each_credentialed_resource(node)
|
|
58
|
+
Ast.each_node(node).select do |n|
|
|
59
|
+
next false unless Ast.tagged?(n, :method_add_arg, :command, :command_call)
|
|
60
|
+
|
|
61
|
+
inner = Ast.tagged?(n, :method_add_arg) ? n[1] : n
|
|
62
|
+
next false unless Ast.call_name(inner)&.last == "resource"
|
|
63
|
+
|
|
64
|
+
Ast.true_literal?(Ast.keyword_arg(Ast.call_arguments(n), "credentials"))
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
module Scryer
|
|
2
|
+
module Rules
|
|
3
|
+
# Flags `config.force_ssl = false` — an explicit opt-out of Rails'
|
|
4
|
+
# built-in HTTPS enforcement (redirects, HSTS, secure cookie flag).
|
|
5
|
+
# Absence of `config.force_ssl = true` isn't flagged — that would require
|
|
6
|
+
# confirming no environment file sets it anywhere, which needs whole-app
|
|
7
|
+
# context this per-file rule doesn't have. Only the explicit opt-out is a
|
|
8
|
+
# reliable, low-noise signal on its own.
|
|
9
|
+
class ForceSslRule < Rule
|
|
10
|
+
self.rule_id = "force_ssl_disabled"
|
|
11
|
+
self.category = "security"
|
|
12
|
+
self.default_severity = "critical"
|
|
13
|
+
self.title = "HTTPS enforcement 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]) == "force_ssl"
|
|
24
|
+
|
|
25
|
+
value = node[2]
|
|
26
|
+
next unless Ast.false_literal?(value)
|
|
27
|
+
|
|
28
|
+
line = Ast.line_of(node)
|
|
29
|
+
findings << finding(
|
|
30
|
+
line: line,
|
|
31
|
+
message: "`config.force_ssl = false` explicitly disables Rails' HTTPS enforcement " \
|
|
32
|
+
"(redirects, HSTS, and the secure flag on cookies) — traffic can be served " \
|
|
33
|
+
"and session cookies transmitted over plain HTTP.",
|
|
34
|
+
suggested_fix: "Set `config.force_ssl = true` (the default for a new Rails production " \
|
|
35
|
+
"environment) unless this app is deliberately terminating TLS " \
|
|
36
|
+
"elsewhere (a load balancer already enforcing HTTPS) — and if so, " \
|
|
37
|
+
"leave a comment explaining that so this doesn't look like an oversight."
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
findings
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
module Scryer
|
|
2
|
+
module Rules
|
|
3
|
+
# Flags a `class X < GraphQL::Schema` (or a class whose superclass
|
|
4
|
+
# constant path text is exactly `GraphQL::Schema`) whose body calls
|
|
5
|
+
# neither `max_depth` nor `max_complexity` anywhere — without at least
|
|
6
|
+
# one of those, a client can send an arbitrarily deep/expensive query
|
|
7
|
+
# and the server will try to resolve all of it, a common
|
|
8
|
+
# denial-of-service vector for GraphQL APIs. Both missing is flagged as
|
|
9
|
+
# a single finding per class (not one per missing directive), the same
|
|
10
|
+
# "did the class do the safe thing at all" pattern as
|
|
11
|
+
# ActiveStorageMissingContentTypeValidationRule/IdorRule.
|
|
12
|
+
class GraphqlMissingQueryLimitsRule < Rule
|
|
13
|
+
self.rule_id = "graphql_missing_query_limits"
|
|
14
|
+
self.category = "security"
|
|
15
|
+
self.default_severity = "warning"
|
|
16
|
+
self.title = "GraphQL schema without query depth/complexity limits"
|
|
17
|
+
|
|
18
|
+
LIMIT_METHODS = %w[max_depth max_complexity].freeze
|
|
19
|
+
|
|
20
|
+
def scan
|
|
21
|
+
findings = []
|
|
22
|
+
|
|
23
|
+
Ast.each_node(sexp) do |node|
|
|
24
|
+
next unless Ast.tagged?(node, :class)
|
|
25
|
+
|
|
26
|
+
superclass = superclass_name(node[2])
|
|
27
|
+
next unless superclass == "GraphQL::Schema"
|
|
28
|
+
|
|
29
|
+
body = node[3]
|
|
30
|
+
next if each_call_names(body).any? { |name| LIMIT_METHODS.include?(name) }
|
|
31
|
+
|
|
32
|
+
class_name = Ast.ident_text(node[1].is_a?(Array) ? node[1][1] : nil)
|
|
33
|
+
line = Ast.line_of(node)
|
|
34
|
+
findings << finding(
|
|
35
|
+
line: line,
|
|
36
|
+
message: "`#{class_name}` extends `GraphQL::Schema` but calls neither `max_depth` nor " \
|
|
37
|
+
"`max_complexity` — without either limit, a client can send an arbitrarily " \
|
|
38
|
+
"deep or expensive query and the server will attempt to resolve all of it, a " \
|
|
39
|
+
"common denial-of-service vector for GraphQL APIs.",
|
|
40
|
+
suggested_fix: "Add at least one limit to the schema, e.g. `max_depth 15` and/or " \
|
|
41
|
+
"`max_complexity 300` (tune both to what this API's real queries need)."
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
findings
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def each_call_names(body)
|
|
51
|
+
Ast.each_node(body).filter_map do |n|
|
|
52
|
+
next unless Ast.tagged?(n, :method_add_arg, :command, :command_call, :fcall, :vcall)
|
|
53
|
+
|
|
54
|
+
inner = Ast.tagged?(n, :method_add_arg) ? n[1] : n
|
|
55
|
+
Ast.call_name(inner)&.last
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Textual name of a (possibly namespaced) constant superclass node —
|
|
60
|
+
# "GraphQL::Schema" for the `[:const_path_ref, ...]` chain a `< X::Y`
|
|
61
|
+
# superclass parses into, or the bare name for an unnamespaced
|
|
62
|
+
# superclass (`[:var_ref, [:@const, ...]]`). nil if there's no
|
|
63
|
+
# superclass (`node[2]` is nil for a plain `class X` with no `< ...`).
|
|
64
|
+
def superclass_name(node)
|
|
65
|
+
if Ast.tagged?(node, :var_ref) && node[1].is_a?(Array) && node[1][0] == :@const
|
|
66
|
+
node[1][1]
|
|
67
|
+
elsif Ast.tagged?(node, :const_path_ref)
|
|
68
|
+
left = superclass_name(node[1])
|
|
69
|
+
right = node[2].is_a?(Array) && node[2][0] == :@const ? node[2][1] : nil
|
|
70
|
+
[left, right].compact.join("::")
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
module Scryer
|
|
2
|
+
module Rules
|
|
3
|
+
# Flags `http_basic_authenticate_with` calls whose `password:` (or
|
|
4
|
+
# `name:`) keyword argument is a plain string literal — a shape
|
|
5
|
+
# `HardcodedSecretRule` doesn't cover (that rule only looks at
|
|
6
|
+
# `x = "literal"` assignment targets, not keyword-argument values in a
|
|
7
|
+
# method call).
|
|
8
|
+
class HardcodedBasicAuthRule < Rule
|
|
9
|
+
self.rule_id = "hardcoded_basic_auth"
|
|
10
|
+
self.category = "security"
|
|
11
|
+
self.default_severity = "critical"
|
|
12
|
+
self.title = "Hardcoded HTTP Basic Auth credential"
|
|
13
|
+
|
|
14
|
+
PLACEHOLDER_VALUES = /\A(x+|0+|change-?me|your[_-]?password|placeholder|example|dummy|fake|test|redacted|\*+)\z/i.freeze
|
|
15
|
+
|
|
16
|
+
def scan
|
|
17
|
+
findings = []
|
|
18
|
+
|
|
19
|
+
Ast.each_node(sexp) do |node|
|
|
20
|
+
next unless Ast.tagged?(node, :method_add_arg, :command, :command_call)
|
|
21
|
+
|
|
22
|
+
inner = Ast.tagged?(node, :method_add_arg) ? node[1] : node
|
|
23
|
+
name_pair = Ast.call_name(inner)
|
|
24
|
+
next unless name_pair && name_pair[1] == "http_basic_authenticate_with"
|
|
25
|
+
|
|
26
|
+
args = Ast.call_arguments(node)
|
|
27
|
+
password_value = Ast.plain_string_value(Ast.keyword_arg(args, "password"))
|
|
28
|
+
next unless password_value && !password_value.strip.empty? && !PLACEHOLDER_VALUES.match?(password_value.strip)
|
|
29
|
+
|
|
30
|
+
line = Ast.line_of(node)
|
|
31
|
+
findings << finding(
|
|
32
|
+
line: line,
|
|
33
|
+
message: "`http_basic_authenticate_with` is called with a literal `password:` — " \
|
|
34
|
+
"anyone with read access to this source (including git history) has the " \
|
|
35
|
+
"credential, and it can't be rotated without a code change and deploy.",
|
|
36
|
+
suggested_fix: "Move the credential out of source: " \
|
|
37
|
+
"`http_basic_authenticate_with name: ENV.fetch(\"BASIC_AUTH_USER\"), " \
|
|
38
|
+
"password: ENV.fetch(\"BASIC_AUTH_PASSWORD\")` (or Rails encrypted " \
|
|
39
|
+
"credentials), and rotate this password since it's likely already " \
|
|
40
|
+
"exposed in git history."
|
|
41
|
+
)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
findings
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
module Scryer
|
|
2
|
+
module Rules
|
|
3
|
+
# Flags `config.secret_key_base = "literal"` (or
|
|
4
|
+
# `Rails.application.config.secret_key_base = "literal"`) — a plain
|
|
5
|
+
# string literal assigned to `secret_key_base` via a `.field=` target.
|
|
6
|
+
#
|
|
7
|
+
# HardcodedSecretRule doesn't catch this shape: its `target_name` walks
|
|
8
|
+
# the assignment target depth-first and returns the *first* identifier
|
|
9
|
+
# it finds, which for a `.field=` target with a receiver is the
|
|
10
|
+
# receiver's own name (`config`) rather than the attribute being
|
|
11
|
+
# assigned (`secret_key_base`) — so `config.secret_key_base = "..."`
|
|
12
|
+
# never matches its NAME_PATTERN, even though the value is exactly the
|
|
13
|
+
# kind of credential that rule exists to catch. Bare assignment
|
|
14
|
+
# (`secret_key_base = "..."` with no receiver) IS already caught by
|
|
15
|
+
# HardcodedSecretRule, since there the identifier IS the target name —
|
|
16
|
+
# this rule only covers the receiver'd shape it misses.
|
|
17
|
+
class HardcodedSecretKeyBaseRule < Rule
|
|
18
|
+
self.rule_id = "hardcoded_secret_key_base"
|
|
19
|
+
self.category = "security"
|
|
20
|
+
self.default_severity = "critical"
|
|
21
|
+
self.title = "Hardcoded secret_key_base"
|
|
22
|
+
|
|
23
|
+
PLACEHOLDER_VALUES = /\A(x+|0+|change-?me|placeholder|example|dummy|fake|test|redacted|\*+)\z/i.freeze
|
|
24
|
+
|
|
25
|
+
def scan
|
|
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]) == "secret_key_base"
|
|
34
|
+
|
|
35
|
+
value = Ast.plain_string_value(node[2])
|
|
36
|
+
next unless value && !value.strip.empty? && !PLACEHOLDER_VALUES.match?(value.strip)
|
|
37
|
+
|
|
38
|
+
line = Ast.line_of(node)
|
|
39
|
+
findings << finding(
|
|
40
|
+
line: line,
|
|
41
|
+
message: "`secret_key_base` is assigned a literal string — this key signs/encrypts " \
|
|
42
|
+
"Rails sessions and other secrets; anyone with read access to this source " \
|
|
43
|
+
"(including git history) can forge session cookies and other signed data.",
|
|
44
|
+
suggested_fix: "Use `Rails.application.credentials.secret_key_base` (the Rails default, " \
|
|
45
|
+
"set via `bin/rails credentials:edit`) or `ENV.fetch(\"SECRET_KEY_BASE\")` " \
|
|
46
|
+
"instead, and rotate this key since it's likely already exposed in git " \
|
|
47
|
+
"history."
|
|
48
|
+
)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
findings
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|