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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9cd7338bfd390fb74f2a1fae54f3132250ae75bd721bc1f65c42f9b80899e2e8
4
- data.tar.gz: 984d8060977af0b21d2740384664350f9ee2b08e846bbdf71e3f069af40db386
3
+ metadata.gz: 4d053e64db4a99c6ea79407677ebe96ac57391e4e9e8442e0246286bbc5f02b3
4
+ data.tar.gz: c3f2850013d4e85d18c317b6c208a97efd79b29f88fa56d573d98788e8527219
5
5
  SHA512:
6
- metadata.gz: 7d88896585a86b731a7d0600036f9fc4fe75195fa55d41296670e8e63834ead37f2a699f4865ebd77181802022964ece7c3634559bdc2d7699a0084f805c7feb
7
- data.tar.gz: 110d568db0d07ee2cb649ba10ca18c324bb8cf073aef7fe3ca2e866c9e994ed5356d3fc88507ff22b32078cd42f74b8d2792a0c3b0857dcc183d6a5ae13b15cb
6
+ metadata.gz: 9cb23f7a30cdf2660cd208589134b265d213547d73d014ccfafba7729665b78ed5dad3125fd08c30d637c067d6051584e8ffa4a578bd9a40b9b932a96071c78d
7
+ data.tar.gz: 6ae2daecc08965c60a8ce1097a95721993e0fcfb5e772c1f10f88f552f07f8609212248a03588026cb180b1b1e50de7ec8f2188672163aaf7118d2560aaaebfb
data/CHANGELOG.md CHANGED
@@ -3,6 +3,74 @@
3
3
  All notable changes to this project are documented here. Format loosely follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
+ ## [1.0.0] - 2026-08-13
7
+
8
+ Seventeen new security rules, a SARIF report format, a Ruby end-of-life check, and a
9
+ credentials-exposure check — see the README's "Scryer vs RuboCop vs Brakeman vs bundler-audit"
10
+ section for how the new security coverage is positioned (heuristic pattern-matching, not taint
11
+ analysis; `idor` in particular carries real false-positive risk by nature of the problem).
12
+
13
+ - New security rules (all `category: "security"`, skippable individually via `--skip RULE_ID` /
14
+ `c.skip_rules`, same as every other rule):
15
+ - `force_ssl_disabled` — `config.force_ssl = false`.
16
+ - `weak_session_cookie` — `session_store :cookie_store` with no `secure: true`.
17
+ - `insecure_cookie_serializer` — `cookies_serializer = :marshal` (Rails defaults to the safe
18
+ `:json`; only an explicit opt-out is flagged).
19
+ - `authentication_bypass` — `skip_before_action` naming a common auth filter
20
+ (`authenticate_user!`, etc.).
21
+ - `hardcoded_basic_auth` — `http_basic_authenticate_with` with a literal `password:`.
22
+ - `idor` — `Model.find(params[...])` in a controller with no visible authorization call
23
+ (`authorize`/`policy_scope`/`can?`) anywhere in the class. The least precise rule in the gem —
24
+ expect real false positives on admin-gated or genuinely-global-model controllers.
25
+ - `ssrf` — an outbound HTTP call (`Net::HTTP`, `URI.open`/bare `open`, `HTTParty`, `Faraday`,
26
+ `RestClient`) with a `params`-derived URL.
27
+ - `path_traversal` — a filesystem call (`File.*`, `Dir.*`, `send_file`) with a `params`-derived
28
+ path; recognizes `File.basename(...)` as sanitization and doesn't flag it.
29
+ - `active_storage_missing_content_type_validation` — `has_one_attached`/`has_many_attached` with
30
+ no matching `validates ..., content_type: [...]`.
31
+ - `active_storage_inline_disposition` — `disposition: "inline"` on a blob/variant URL helper.
32
+ - `security_headers_disabled` — an explicit insecure override of a Rails default security
33
+ header/config: `X-Frame-Options`/`X-Content-Type-Options` set to `ALLOWALL`/falsy via
34
+ `config.action_dispatch.default_headers`, or `config.content_security_policy = nil`. Absence of
35
+ a header isn't flagged, same reasoning as `force_ssl_disabled`.
36
+ - `cors_misconfiguration` — a wildcard `origins '*'` combined with `credentials: true` on a
37
+ `resource` call anywhere in the same file (the standard Rack::Cors antipattern; either half
38
+ alone is fine). Checked file-wide rather than same-block, same looseness as `idor`.
39
+ - `jwt_insecure_usage` — `JWT.decode(token, secret, false, ...)` (signature verification
40
+ disabled), `algorithm: 'none'`/`'alg' => 'none'`, or a literal string passed directly as the
41
+ secret/key argument to `JWT.decode`/`JWT.encode`.
42
+ - `action_cable_forgery_protection_disabled` —
43
+ `config.action_cable.disable_request_forgery_protection = true`.
44
+ - `hardcoded_secret_key_base` — `config.secret_key_base = "literal"`, a `.field=` shape
45
+ `hardcoded_secret` misses (its target-name lookup returns the receiver's name — `config` —
46
+ rather than the attribute being assigned; see this new rule's comment for the full
47
+ explanation).
48
+ - `job_raw_params` — `SomeJob.perform_async`/`perform_later`/`perform_now` called with an
49
+ argument that references `params` — Sidekiq stores job args in Redis in plaintext (visible in
50
+ its web UI) and both Sidekiq and ActiveJob log them by default.
51
+ - `graphql_missing_query_limits` — a `class X < GraphQL::Schema` with no `max_depth` or
52
+ `max_complexity` call anywhere in its body (one finding per class, not per missing directive).
53
+ - New `Scryer::DependencyAudit.credentials_exposure_check` — flags `config/master.key` existing on
54
+ disk with no `.gitignore` line excluding it (checks both `/config/master.key` and
55
+ `config/master.key` forms). Runs alongside the other dependency checks (same
56
+ `--no-deps`/`nodeps` opt-out).
57
+ - New shared `Ast` helpers backing the above (and available to any future rule):
58
+ `references_params?`, `keyword_arg`, `literal_text`, `true_literal?`/`false_literal?`. Also
59
+ fixed `Ast.call_name` to recognize `:command_call` nodes (`config.session_store :x, y: z` —
60
+ receiver + args, no parens), which it previously returned `nil` for.
61
+ - New `Scryer::DependencyAudit.ruby_eol_check` — flags the Ruby version pinned in Gemfile.lock's
62
+ `RUBY VERSION` section if its series is past Ruby's own published end-of-life date. Runs
63
+ automatically alongside the other dependency checks (same `--no-deps`/`nodeps` opt-out).
64
+ Deliberately not a general Ruby-interpreter CVE feed — OSV.dev has no queryable ecosystem for
65
+ that, and a maintained local CVE list would be exactly the kind of stale bundled knowledge base
66
+ this gem avoids elsewhere; EOL dates, unlike CVEs, are announced years ahead and don't change.
67
+ - New `.sarif` report format (`scryer -o report.sarif` / `scryer:report[sarif]`) — SARIF 2.1.0,
68
+ the format GitHub Code Scanning and similar CI dashboards ingest natively for inline PR
69
+ annotations. Pure mapping of the same data every other format has; see the README's new
70
+ "CI/CD integration" section for a GitHub Actions example.
71
+ - Fixed: dependency-finding display (HTML and CSV) dropped a finding's `title` entirely when it had
72
+ no `advisory_id` (true for the new `ruby_eol` finding) instead of showing the title on its own.
73
+
6
74
  ## [0.3.0] - 2026-08-11
7
75
 
8
76
  - New `scryer -r PATH`/`--require PATH` flag (repeatable): requires a Ruby file before scanning.
@@ -60,6 +128,7 @@ Initial release.
60
128
  actual request handling.
61
129
  - `scryer` standalone executable and `scryer:report`/`scryer:audit_dependencies` Rails rake tasks.
62
130
 
131
+ [1.0.0]: https://github.com/ramlaxmanyadav/scryer/releases/tag/v1.0.0
63
132
  [0.3.0]: https://github.com/ramlaxmanyadav/scryer/releases/tag/v0.3.0
64
133
  [0.2.0]: https://github.com/ramlaxmanyadav/scryer/releases/tag/v0.2.0
65
134
  [0.1.0]: https://github.com/ramlaxmanyadav/scryer/releases/tag/v0.1.0
data/README.md CHANGED
@@ -23,12 +23,12 @@ scryer
23
23
  Scryer Audit — 236 files scanned
24
24
  ────────────────────────────────
25
25
 
26
- Security 8 findings
26
+ Security 13 findings
27
27
  Performance 10 findings
28
28
  Code Quality 248 findings
29
29
  Dependencies 24 findings
30
30
  ────────────────────────────────
31
- Total 290 findings
31
+ Total 295 findings
32
32
 
33
33
  JSON report: tmp/scryer_report.json
34
34
  HTML report: tmp/scryer_report.html
@@ -45,12 +45,28 @@ below for exactly how it stacks up against the tools it's meant to consolidate.
45
45
  * SQL injection
46
46
  * Mass assignment
47
47
  * Command injection
48
- * Hardcoded secrets
48
+ * Hardcoded secrets (and hardcoded HTTP Basic Auth credentials)
49
49
  * Unsafe deserialization
50
50
  * XSS-prone HTML
51
51
  * CSRF gaps
52
52
  * Weak cryptography
53
53
  * Open redirects
54
+ * Server-side request forgery (SSRF)
55
+ * Path traversal
56
+ * Insecure direct object references (IDOR) — the least precise check in the gem; see
57
+ [comparison table](#scryer-vs-rubocop-vs-brakeman-vs-bundler-audit) for the false-positive tradeoff
58
+ * Authentication filters explicitly skipped (`skip_before_action`)
59
+ * Rails security configuration: disabled HTTPS enforcement, session cookies missing the secure
60
+ flag, the Marshal cookie serializer, disabled default security headers (`X-Frame-Options`,
61
+ `X-Content-Type-Options`, Content-Security-Policy), Action Cable request forgery protection
62
+ disabled, a hardcoded `secret_key_base`
63
+ * Active Storage attachments without a content-type allowlist, or served with inline disposition
64
+ * CORS misconfiguration (wildcard origin combined with `credentials: true`)
65
+ * Insecure JWT usage (`JWT.decode` with signature verification disabled, `algorithm: 'none'`, or a
66
+ hardcoded secret)
67
+ * Background jobs (`perform_async`/`perform_later`/`perform_now`) passed raw `params` — risks
68
+ leaking request data into Sidekiq/ActiveJob logs, Redis, or the Sidekiq web UI
69
+ * GraphQL schemas with no query depth/complexity limit (`max_depth`/`max_complexity`)
54
70
 
55
71
  **Code quality**
56
72
 
@@ -71,6 +87,8 @@ below for exactly how it stacks up against the tools it's meant to consolidate.
71
87
 
72
88
  * Known dependency vulnerabilities via OSV.dev
73
89
  * Insecure gem sources
90
+ * Ruby version end-of-life (no more security patches published for it, for any issue)
91
+ * `config/master.key` present on disk with no matching `.gitignore` entry
74
92
 
75
93
  ### Example findings
76
94
 
@@ -185,21 +203,24 @@ before acting on it.
185
203
  Scryer isn't trying to out-lint RuboCop or out-analyze Brakeman — where an existing tool
186
204
  specializes, it's still worth running on its own; style/lint conventions are almost entirely
187
205
  RuboCop's job, and Scryer stays out of that territory except for one narrow, deliberately-scoped
188
- check (see footnote below). What Scryer actually replaces is *stitching several of these together
189
- yourself*: it covers categories none of the others do alone (performance heuristics,
190
- duplicate-code detection, runtime query analysis), and folds the categories they *do* cover into
191
- one command and one report instead of several separate tools, configs, and CI steps.
206
+ check (see footnote below). Brakeman also remains the deeper, more mature tool for the security
207
+ categories it's spent years on real taint/data-flow analysis, not heuristic pattern-matching.
208
+ What Scryer actually replaces is *stitching several of these together yourself*: it covers
209
+ categories none of the others do alone (performance heuristics, duplicate-code detection, runtime
210
+ query analysis, SSRF/path-traversal/IDOR/auth-config checks), and folds the categories they *do*
211
+ cover into one command and one report instead of several separate tools, configs, and CI steps.
192
212
 
193
213
  | Capability | Scryer | RuboCop | Brakeman | bundler-audit |
194
214
  |-------------------------------------------|:------:|:--------:|:--------:|:-------------:|
195
215
  | Style/lint conventions | Partial [h] | ✅ | ❌ | ❌ |
196
- | Rails security scanning | ✅ | ❌ | ✅ | ❌ |
216
+ | Rails security scanning | ✅ [i] | ❌ | ✅ | ❌ |
197
217
  | Performance heuristics | ✅ | Partial [a] | ❌ | ❌ |
198
218
  | Duplicate/similar code detection | ✅ | Partial [b] | ❌ | ❌ |
199
219
  | Dependency vulnerability scanning | ✅ | ❌ | ❌ | ✅ |
200
220
  | Runtime query analysis (N+1 in production) | ✅ | ❌ | ❌ | ❌ |
201
221
  | HTML report | ✅ | Partial [c] | ✅ | ❌ |
202
222
  | JSON report | ✅ | ✅ | ✅ | Limited [d] |
223
+ | SARIF report (GitHub Code Scanning, etc.) | ✅ | ❌ | ✅ | ❌ |
203
224
  | Human-reviewable fix suggestions | ✅ | Partial [e] | Partial [f] | Limited [g] |
204
225
  | Single command covering all of the above | ✅ | ❌ | ❌ | ❌ |
205
226
 
@@ -219,8 +240,17 @@ one command and one report instead of several separate tools, configs, and CI st
219
240
  (`frozen_string_literal`, info severity). Everything else in RuboCop's style/lint domain —
220
241
  naming, layout, quote style, line length, and hundreds more — is intentionally out of scope; see
221
242
  [Skipping rules](#skipping-rules) if you don't want even this one.
222
-
223
- If you already run RuboCop for style, keep it — Scryer isn't a replacement for it. If you're
243
+ - **[i]** Heuristic pattern-matching, not taint/data-flow analysis — Brakeman traces whether user
244
+ input can actually *reach* a sink; Scryer checks whether a known-dangerous call shape and a
245
+ `params` reference appear in the same expression. That's a real precision gap on the harder
246
+ checks especially (`idor` in particular has real false-positive risk — see
247
+ [What Scryer detects](#what-scryer-detects)), and it's why every finding says "review this,"
248
+ never "this is definitely a bug."
249
+
250
+ If you already run RuboCop for style, keep it — Scryer isn't a replacement for it. If you already
251
+ run Brakeman for its deeper security analysis, Scryer is a complement, not a swap — the categories
252
+ they both cover benefit from two different heuristics looking; the ones only Scryer covers
253
+ (performance, duplication, runtime queries, dependency EOL) are net-new either way. If you're
224
254
  currently running Brakeman + bundler-audit + a duplicate-code linter as three separate steps,
225
255
  Scryer is the "run one thing instead" option.
226
256
 
@@ -257,8 +287,8 @@ bin/rails scryer:report # writes tmp/scryer_report.{json,html}
257
287
  By default this writes both `tmp/scryer_report.json` and `tmp/scryer_report.html`, and includes a
258
288
  dependency audit against `Gemfile.lock` (needs network — see [Dependency audit](#dependency-audit);
259
289
  pass the `nodeps` arg for a fast, fully offline run instead). Format and output path are
260
- configurable via task args — any mix of `json`, `html`, `csv` plus at most one path (quote the
261
- whole thing so your shell doesn't eat the brackets/commas, e.g.
290
+ configurable via task args — any mix of `json`, `html`, `csv`, `sarif` plus at most one path (quote
291
+ the whole thing so your shell doesn't eat the brackets/commas, e.g.
262
292
  `bin/rails 'scryer:report[json,doc/security_report.json]'`). Rake splits bracket args on every
263
293
  comma, so each format is its own item in the list rather than one comma-joined string
264
294
  (`scryer:report[json,html]` is two args, not `"json,html"` as one). At most one non-format,
@@ -298,11 +328,55 @@ Open `tmp/scryer_report.html` in a browser — it's a single self-contained file
298
328
  external assets, works offline) with an overview, a summary of counts by severity, the full list
299
329
  of checks that ran, a breakdown of warnings by type, every finding in detail, and duplicate-code
300
330
  groups — laid out similarly to a Brakeman report. `tmp/scryer_report.json` has the same data in
301
- machine-readable form, for feeding into your own dashboard or CI gate. A third format, `.csv`
302
- (`scryer -o report.csv` / `rails 'scryer:report[csv]'`), is a flat one-row-per-finding table
331
+ machine-readable form, for feeding into your own dashboard or CI gate. `.csv`
332
+ (`scryer -o report.csv` / `rails 'scryer:report[csv]'`) is a flat one-row-per-finding table
303
333
  (security + performance findings, plus dependency findings unless `--no-deps`/`nodeps` was used) —
304
- handy for dropping into a spreadsheet or importing into a ticketing tool. It skips duplicate-code
305
- groups, which don't reduce to a single actionable row.
334
+ handy for dropping into a spreadsheet or importing into a ticketing tool; it skips duplicate-code
335
+ groups, which don't reduce to a single actionable row. `.sarif` (`scryer -o report.sarif` /
336
+ `rails 'scryer:report[sarif]'`) is for feeding into a CI security dashboard instead — see
337
+ [CI/CD integration](#cicd-integration).
338
+
339
+ ## CI/CD integration
340
+
341
+ `scryer -o report.sarif` writes a [SARIF 2.1.0](https://docs.oasis-open.org/sarif/sarif/v2.1.0/)
342
+ report — the format GitHub Code Scanning, Azure DevOps, and most CI security dashboards ingest
343
+ natively, turning findings into inline pull-request annotations and Security-tab entries instead
344
+ of a report file nobody opens. It's a pure mapping of the same data every other format has —
345
+ security, performance, and style findings (each mapped from `Scryer::Rule.rule_id`/`severity`) plus
346
+ dependency findings (mapped from `kind`, located at `Gemfile.lock`) — so nothing behaves any
347
+ differently than it does in HTML/JSON/CSV.
348
+
349
+ Upload it with GitHub's own action in a workflow:
350
+
351
+ ```yaml
352
+ # .github/workflows/scryer.yml
353
+ name: Scryer
354
+ on: [push, pull_request]
355
+
356
+ jobs:
357
+ scryer:
358
+ runs-on: ubuntu-latest
359
+ permissions:
360
+ contents: read
361
+ security-events: write # required to upload SARIF
362
+ steps:
363
+ - uses: actions/checkout@v4
364
+ - uses: ruby/setup-ruby@v1
365
+ with:
366
+ ruby-version: "3.3"
367
+ - run: gem install scryer
368
+ - run: scryer -o scryer.sarif
369
+ continue-on-error: true # let the upload step run even on a non-zero exit; see below
370
+ - uses: github/codeql-action/upload-sarif@v3
371
+ with:
372
+ sarif_file: scryer.sarif
373
+ ```
374
+
375
+ `continue-on-error: true` on the scan step matters: `scryer` exits non-zero when it finds anything
376
+ (see [Running a scan](#running-a-scan)), which would otherwise skip the upload step entirely on
377
+ the run where you most want to see the results. If you want the job to still fail the build overall
378
+ on findings, check `scryer`'s own exit code in a separate step, or drop `continue-on-error` and
379
+ accept that the SARIF upload only happens on clean runs.
306
380
 
307
381
  ## What gets scanned
308
382
 
@@ -413,11 +487,12 @@ runs automatically as part of every `scryer`/`scryer:report` scan — pass `--no
413
487
  `bundle-audit check` is a separate, standalone command from your test suite. Either way it exits
414
488
  non-zero if anything is found, so it can gate CI.
415
489
 
416
- Two checks run, and either can be called on its own as a library:
490
+ Three checks run, and any of them can be called on its own as a library:
417
491
 
418
492
  ```ruby
419
493
  Scryer::DependencyAudit.insecure_sources(Rails.root.to_s) # offline — no network needed
420
494
  Scryer::DependencyAudit.vulnerable_gems(Rails.root.to_s) # needs network (OSV.dev)
495
+ Scryer::DependencyAudit.ruby_eol_check(Rails.root.to_s) # offline — checked against a small embedded table
421
496
  ```
422
497
 
423
498
  - **Insecure sources**: flags any `GIT`/`PATH` block in `Gemfile.lock` whose `remote:` is
@@ -428,10 +503,20 @@ Scryer::DependencyAudit.vulnerable_gems(Rails.root.to_s) # needs network (OSV.
428
503
  name, so checking them against RubyGems advisories could misattribute or miss vulnerabilities),
429
504
  queries OSV.dev for known vulnerabilities affecting that exact version. Each finding carries the
430
505
  advisory id, title, a severity bucketed from OSV's own severity level, a link to the advisory,
431
- and the fixed version(s) to upgrade to.
506
+ and the fixed version(s) to upgrade to. This is also where Rails-framework CVEs surface —
507
+ `rails`/`actionview`/`activestorage`/etc. are just gems in `Gemfile.lock` like any other.
508
+ - **Ruby EOL**: checks the Ruby version pinned in `Gemfile.lock`'s `RUBY VERSION` section against
509
+ Ruby's own [published end-of-life dates](https://www.ruby-lang.org/en/downloads/branches/) — once
510
+ a series is EOL, no security patches are published for it at all, for any issue, regardless of
511
+ how up to date every gem is. Unlike the two checks above, this doesn't call OSV.dev: EOL dates
512
+ are announced years in advance and essentially never change, so a small embedded table
513
+ (`DependencyAudit::RUBY_EOL_DATES`) is a one-time/occasional-update cost rather than a live feed
514
+ — deliberately not a general Ruby-interpreter CVE database, which would mean maintaining exactly
515
+ the kind of stale bundled knowledge base this gem avoids elsewhere (OSV.dev has no queryable
516
+ Ruby-interpreter ecosystem to query live instead).
432
517
 
433
518
  **From the `scryer` executable** (outside a Rails app, or in CI): `scryer --audit-deps` runs the
434
- same two checks standalone (no static scan), same output/exit-code behavior as the rake task above.
519
+ same three checks standalone (no static scan), same output/exit-code behavior as the rake task above.
435
520
  Plain `scryer` already folds them into the normal `-o` report — one HTML/JSON/CSV file covering
436
521
  static findings *and* dependency findings together — so `--audit-deps` is only for when you want
437
522
  dependency findings *without* the static scan. And for a single gem, without touching
data/lib/scryer/ast.rb CHANGED
@@ -64,14 +64,17 @@ module Scryer
64
64
  # Returns the receiver node (nil for a bare/vcall) and the method name
65
65
  # string if `node` is a call to one of `method_names`, else nil.
66
66
  #
67
- # Handles the two shapes Ripper produces for a called method:
68
- # [:call, receiver, [:@period,...]|:"::", [:@ident, "name", pos]] (has a receiver)
69
- # [:vcall, [:@ident, "name", pos]] (bare, no args)
70
- # [:command, [:@ident, "name", pos], args] (bare, with args, no parens)
71
- # [:method_add_arg, call_or_fcall_node, args_node] (receiver/bare + parens)
67
+ # Handles the shapes Ripper produces for a called method:
68
+ # [:call, receiver, [:@period,...]|:"::", [:@ident, "name", pos]] (has a receiver, parens or no args)
69
+ # [:vcall, [:@ident, "name", pos]] (bare, no args)
70
+ # [:fcall, [:@ident, "name", pos]] (bare + parens, no receiver)
71
+ # [:command, [:@ident, "name", pos], args] (bare, with args, no parens)
72
+ # [:command_call, receiver, [:@period,...], [:@ident, "name", pos], args] (receiver + args, no parens —
73
+ # e.g. `config.session_store :x, y: z`)
74
+ # [:method_add_arg, call_or_fcall_node, args_node] (receiver/bare + parens, wraps one of the above)
72
75
  def call_name(node)
73
76
  case node
74
- when ->(n) { tagged?(n, :call) }
77
+ when ->(n) { tagged?(n, :call, :command_call) }
75
78
  [node[1], ident_text(node[3])]
76
79
  when ->(n) { tagged?(n, :vcall) }
77
80
  [nil, ident_text(node[1])]
@@ -247,6 +250,66 @@ module Scryer
247
250
  result
248
251
  end
249
252
 
253
+ # Given a list of top-level argument nodes (from call_arguments), finds
254
+ # the bare_assoc_hash entry whose label matches `label:` and returns its
255
+ # value node, or nil if that keyword argument isn't present. Covers
256
+ # Rails DSL calls like `session_store :cookie_store, secure: true` or
257
+ # `http_basic_authenticate_with password: "x"`, where keyword args
258
+ # arrive as a bare_assoc_hash rather than a real Hash literal.
259
+ def keyword_arg(args, label)
260
+ target = "#{label}:"
261
+
262
+ args.each do |arg|
263
+ next unless tagged?(arg, :bare_assoc_hash) && arg[1].is_a?(Array)
264
+
265
+ pair = arg[1].find { |p| tagged?(p, :assoc_new) && p[1].is_a?(Array) && p[1][0] == :@label && p[1][1] == target }
266
+ return pair[2] if pair
267
+ end
268
+
269
+ nil
270
+ end
271
+
272
+ # The literal text of a symbol_literal (`:inline`) or plain string_literal
273
+ # (`"inline"`) node — the two shapes a Rails DSL keyword argument's value
274
+ # commonly takes. nil for anything else (interpolated string, array,
275
+ # boolean, ...).
276
+ def literal_text(node)
277
+ return plain_string_value(node) if tagged?(node, :string_literal)
278
+ return nil unless tagged?(node, :symbol_literal) && node[1].is_a?(Array)
279
+
280
+ ident_text(node[1][1]) if tagged?(node[1], :symbol)
281
+ end
282
+
283
+ # True if `node` is the literal `true`/`false` keyword
284
+ # (`[:var_ref, [:@kw, "true"|"false", pos]]`).
285
+ def true_literal?(node)
286
+ kw_literal?(node) == "true"
287
+ end
288
+
289
+ def false_literal?(node)
290
+ kw_literal?(node) == "false"
291
+ end
292
+
293
+ def kw_literal?(node)
294
+ return nil unless tagged?(node, :var_ref) && node[1].is_a?(Array) && node[1][0] == :@kw
295
+
296
+ node[1][1]
297
+ end
298
+
299
+ # True if `node` is `params`, `params[:x]`, or contains such a reference
300
+ # anywhere in its subtree — the shared "does this touch raw request data"
301
+ # check behind mass assignment, IDOR, SSRF, and path traversal detection.
302
+ # Says nothing about whether that reference is guarded (`.permit`,
303
+ # sanitization, an allowlist, ...) — callers that care about a specific
304
+ # guard (like MassAssignmentRule's `.permit`) check for it separately.
305
+ def references_params?(node)
306
+ return false unless node.is_a?(Array)
307
+
308
+ each_node(node).any? do |n|
309
+ tagged?(n, :vcall, :var_ref, :fcall) && ident_text(n[1]) == "params"
310
+ end
311
+ end
312
+
250
313
  # Walks every terminal token-bearing node inside `node` and maps it to a
251
314
  # normalized symbol: identifiers/literals become placeholders (so renamed
252
315
  # variables/changed literal values still count as "the same" shape),
data/lib/scryer/cli.rb CHANGED
@@ -9,7 +9,7 @@ module Scryer
9
9
  # only needed for this CLI entry point, not when the gem is required
10
10
  # inside a host app.
11
11
  class CLI
12
- EXTENSION_FORMATS = { ".json" => "json", ".html" => "html", ".htm" => "html", ".csv" => "csv" }.freeze
12
+ EXTENSION_FORMATS = { ".json" => "json", ".html" => "html", ".htm" => "html", ".csv" => "csv", ".sarif" => "sarif" }.freeze
13
13
 
14
14
  def initialize(argv, stdout: $stdout, stderr: $stderr)
15
15
  @argv = argv
@@ -50,7 +50,8 @@ module Scryer
50
50
  dependency_findings = []
51
51
  if ran_deps
52
52
  @stdout.puts "Scryer: querying OSV.dev for known-vulnerable gems (needs network)..."
53
- dependency_findings = DependencyAudit.insecure_sources(root) + DependencyAudit.vulnerable_gems(root)
53
+ dependency_findings = DependencyAudit.insecure_sources(root) + DependencyAudit.vulnerable_gems(root) +
54
+ DependencyAudit.ruby_eol_check(root) + DependencyAudit.credentials_exposure_check(root)
54
55
  end
55
56
 
56
57
  if Scryer.configuration.ai_client
@@ -91,18 +92,28 @@ module Scryer
91
92
 
92
93
  @stdout.puts "Scryer: querying OSV.dev for known vulnerabilities (needs network)..."
93
94
  vulnerable = DependencyAudit.vulnerable_gems(root)
95
+ ruby_eol = DependencyAudit.ruby_eol_check(root)
96
+ credentials_exposure = DependencyAudit.credentials_exposure_check(root)
94
97
 
95
- (insecure + vulnerable).each do |f|
96
- label = f.kind == "insecure_source" ? "[#{f.severity.upcase}] #{f.message}" : "[#{f.severity.upcase}] #{f.gem_name} #{f.installed_version} - #{f.advisory_id}: #{f.title}"
97
- @stdout.puts label
98
+ (insecure + vulnerable + ruby_eol + credentials_exposure).each do |f|
99
+ @stdout.puts "[#{f.severity.upcase}] #{dependency_label(f)}"
98
100
  @stdout.puts " fix: #{f.suggested_fix}"
99
101
  end
100
102
 
101
- total = insecure.size + vulnerable.size
102
- @stdout.puts "\nScryer: #{total} dependency finding(s) (#{insecure.size} insecure source, #{vulnerable.size} vulnerable gem)."
103
+ total = insecure.size + vulnerable.size + ruby_eol.size + credentials_exposure.size
104
+ @stdout.puts "\nScryer: #{total} dependency finding(s) (#{insecure.size} insecure source, " \
105
+ "#{vulnerable.size} vulnerable gem, #{ruby_eol.size} Ruby EOL, " \
106
+ "#{credentials_exposure.size} credentials exposure)."
103
107
  total.positive? ? 1 : 0
104
108
  end
105
109
 
110
+ def dependency_label(f)
111
+ return f.message if f.kind == "insecure_source"
112
+ return "#{f.gem_name} #{f.installed_version} - #{f.advisory_id}: #{f.title}" if f.advisory_id
113
+
114
+ "#{f.gem_name} #{f.installed_version}: #{f.title}"
115
+ end
116
+
106
117
  # The "one audit command" summary — a single scan's worth of every
107
118
  # category Scryer covers (security, performance, duplicate/smelly code,
108
119
  # dependencies), the same categories usually split across RuboCop +
@@ -165,7 +176,7 @@ module Scryer
165
176
  opts.banner = "Usage: scryer [options]"
166
177
  opts.on("-o PATH", "--output PATH",
167
178
  "Write a report to PATH (repeatable). Format is inferred from the " \
168
- "extension: .json, .html, or .csv.") { |v| options[:outputs] << v }
179
+ "extension: .json, .html, .csv, or .sarif.") { |v| options[:outputs] << v }
169
180
  opts.on("-p PATH", "--path PATH", "Root directory to scan (default: current directory).") { |v| options[:path] = v }
170
181
  opts.on("-r PATH", "--require PATH",
171
182
  "Require a Ruby file before scanning (repeatable) — the file can call " \
@@ -212,6 +223,7 @@ module Scryer
212
223
  content = case format
213
224
  when "json" then renderer.as_json
214
225
  when "csv" then renderer.as_csv
226
+ when "sarif" then renderer.as_sarif
215
227
  else renderer.as_html
216
228
  end
217
229
 
@@ -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"