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.
@@ -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
- 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
 
@@ -481,7 +494,14 @@ module Scryer
481
494
  end
482
495
 
483
496
  def dependency_csv_row(f)
484
- 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
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