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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4d053e64db4a99c6ea79407677ebe96ac57391e4e9e8442e0246286bbc5f02b3
|
|
4
|
+
data.tar.gz: c3f2850013d4e85d18c317b6c208a97efd79b29f88fa56d573d98788e8527219
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
26
|
+
Security 13 findings
|
|
27
27
|
Performance 10 findings
|
|
28
28
|
Code Quality 248 findings
|
|
29
29
|
Dependencies 24 findings
|
|
30
30
|
────────────────────────────────
|
|
31
|
-
Total
|
|
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).
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
302
|
-
(`scryer -o report.csv` / `rails 'scryer:report[csv]'`)
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
68
|
-
# [:call, receiver, [:@period,...]|:"::", [:@ident, "name", pos]]
|
|
69
|
-
# [:vcall, [:@ident, "name", pos]]
|
|
70
|
-
# [:
|
|
71
|
-
# [:
|
|
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
|
-
|
|
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,
|
|
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 .
|
|
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:} },
|
|
60
|
-
#
|
|
61
|
-
#
|
|
77
|
+
# Parses a Gemfile.lock into `{ gems: { name => {version:, source:} },
|
|
78
|
+
# git_or_path_sources: [...], ruby_version: "3.3.3" | nil }`. `source`
|
|
79
|
+
# is "gem", "git", or "path" — taken from which top-level block
|
|
80
|
+
# (GEM/GIT/PATH) the spec's `specs:` list appeared under.
|
|
62
81
|
def parse_lockfile(path)
|
|
63
82
|
gems = {}
|
|
64
83
|
git_or_path_sources = []
|
|
84
|
+
ruby_version = nil
|
|
65
85
|
|
|
66
|
-
current_block = nil # "gem" | "git" | "path" | other section
|
|
86
|
+
current_block = nil # "gem" | "git" | "path" | nil (other section)
|
|
87
|
+
current_section = nil # PLATFORMS | DEPENDENCIES | BUNDLED WITH | RUBY VERSION | nil
|
|
67
88
|
current_remote = nil
|
|
68
89
|
in_specs = false
|
|
69
90
|
|
|
@@ -71,10 +92,12 @@ module Scryer
|
|
|
71
92
|
case line
|
|
72
93
|
when /\A(GEM|GIT|PATH)\s*\z/
|
|
73
94
|
current_block = Regexp.last_match(1).downcase
|
|
95
|
+
current_section = nil
|
|
74
96
|
current_remote = nil
|
|
75
97
|
in_specs = false
|
|
76
98
|
when /\A(PLATFORMS|DEPENDENCIES|BUNDLED WITH|RUBY VERSION)\s*\z/
|
|
77
99
|
current_block = nil
|
|
100
|
+
current_section = Regexp.last_match(1)
|
|
78
101
|
in_specs = false
|
|
79
102
|
when /\A {2}remote:\s*(\S+)\s*\z/
|
|
80
103
|
current_remote = Regexp.last_match(1)
|
|
@@ -90,10 +113,46 @@ module Scryer
|
|
|
90
113
|
# in pathological Gemfiles; last one wins, consistent with how
|
|
91
114
|
# Bundler itself resolves a single spec per gem name.
|
|
92
115
|
gems[name] = { version: version, source: current_block }
|
|
116
|
+
when /\A\s*ruby\s+(\S+)\s*\z/
|
|
117
|
+
ruby_version = Regexp.last_match(1) if current_section == "RUBY VERSION"
|
|
93
118
|
end
|
|
94
119
|
end
|
|
95
120
|
|
|
96
|
-
{ gems: gems, git_or_path_sources: git_or_path_sources }
|
|
121
|
+
{ gems: gems, git_or_path_sources: git_or_path_sources, ruby_version: ruby_version }
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Offline. Flags the Ruby version pinned in Gemfile.lock's RUBY
|
|
125
|
+
# VERSION section if its minor series is past end-of-life — after that
|
|
126
|
+
# date Ruby publishes no more security patches for it, for any issue,
|
|
127
|
+
# so this is a real gap even with every gem otherwise up to date.
|
|
128
|
+
# Returns [] if no version is pinned, or its series isn't one
|
|
129
|
+
# RUBY_EOL_DATES knows about (never guessed as "fine" by omission).
|
|
130
|
+
def ruby_eol_check(root)
|
|
131
|
+
lockfile = File.join(root, "Gemfile.lock")
|
|
132
|
+
return [] unless File.exist?(lockfile)
|
|
133
|
+
|
|
134
|
+
ruby_version = parse_lockfile(lockfile)[:ruby_version]
|
|
135
|
+
return [] unless ruby_version
|
|
136
|
+
|
|
137
|
+
series = ruby_version[/\A\d+\.\d+/]
|
|
138
|
+
eol_date = series && RUBY_EOL_DATES[series]
|
|
139
|
+
return [] unless eol_date && Date.today > eol_date
|
|
140
|
+
|
|
141
|
+
[
|
|
142
|
+
Finding.new(
|
|
143
|
+
kind: "ruby_eol",
|
|
144
|
+
gem_name: "Ruby",
|
|
145
|
+
installed_version: ruby_version,
|
|
146
|
+
severity: "critical",
|
|
147
|
+
title: "Ruby #{series} is end-of-life",
|
|
148
|
+
url: "https://www.ruby-lang.org/en/downloads/branches/",
|
|
149
|
+
patched_versions: [],
|
|
150
|
+
message: "Ruby #{ruby_version} (#{series} series) reached end-of-life on " \
|
|
151
|
+
"#{eol_date} — no security patches are published for it anymore, for any issue.",
|
|
152
|
+
suggested_fix: "Upgrade to a Ruby version still receiving security maintenance — see " \
|
|
153
|
+
"https://www.ruby-lang.org/en/downloads/branches/ for current status."
|
|
154
|
+
)
|
|
155
|
+
]
|
|
97
156
|
end
|
|
98
157
|
|
|
99
158
|
# Offline. Flags GIT/PATH sources recorded with an unencrypted remote
|
|
@@ -160,6 +219,41 @@ module Scryer
|
|
|
160
219
|
findings
|
|
161
220
|
end
|
|
162
221
|
|
|
222
|
+
# Offline. Flags `config/master.key` (the key that decrypts Rails
|
|
223
|
+
# encrypted credentials, config/credentials.yml.enc) if it exists on
|
|
224
|
+
# disk and .gitignore doesn't exclude it — Rails generates it
|
|
225
|
+
# gitignored by default (`/config/master.key`), but that line is easy
|
|
226
|
+
# to lose (a merge, a from-scratch .gitignore, copying the file into a
|
|
227
|
+
# repo that never had the Rails default). If this file is ever
|
|
228
|
+
# committed, anyone with repo access — including git history, even
|
|
229
|
+
# after a later removal — can decrypt every credential in
|
|
230
|
+
# config/credentials.yml.enc.
|
|
231
|
+
def credentials_exposure_check(root)
|
|
232
|
+
master_key_path = File.join(root, "config", "master.key")
|
|
233
|
+
return [] unless File.exist?(master_key_path)
|
|
234
|
+
return [] if master_key_gitignored?(root)
|
|
235
|
+
|
|
236
|
+
[
|
|
237
|
+
Finding.new(
|
|
238
|
+
kind: "credentials_exposure",
|
|
239
|
+
gem_name: "Rails",
|
|
240
|
+
severity: "critical",
|
|
241
|
+
title: "config/master.key is present and not gitignored",
|
|
242
|
+
url: "https://guides.rubyonrails.org/security.html",
|
|
243
|
+
patched_versions: [],
|
|
244
|
+
message: "config/master.key exists in this app, but .gitignore doesn't exclude it " \
|
|
245
|
+
"(checked for both `/config/master.key` and `config/master.key`) — if this " \
|
|
246
|
+
"file is ever committed, anyone with repository access, including git " \
|
|
247
|
+
"history even after a later removal, can decrypt every credential in " \
|
|
248
|
+
"config/credentials.yml.enc.",
|
|
249
|
+
suggested_fix: "Add `/config/master.key` to .gitignore immediately. If this key was " \
|
|
250
|
+
"ever actually committed to git history, treat every credential in " \
|
|
251
|
+
"config/credentials.yml.enc as compromised: rotate them and regenerate " \
|
|
252
|
+
"the master key (delete both files, then `bin/rails credentials:edit`)."
|
|
253
|
+
)
|
|
254
|
+
]
|
|
255
|
+
end
|
|
256
|
+
|
|
163
257
|
# Needs network. One-off OSV.dev lookup for a single gem, independent
|
|
164
258
|
# of any Gemfile.lock — backs `scryer --check-gem NAME[:VERSION]`.
|
|
165
259
|
# With a version, only vulnerabilities affecting that exact version
|
|
@@ -171,6 +265,15 @@ module Scryer
|
|
|
171
265
|
|
|
172
266
|
private
|
|
173
267
|
|
|
268
|
+
def master_key_gitignored?(root)
|
|
269
|
+
gitignore_path = File.join(root, ".gitignore")
|
|
270
|
+
return false unless File.exist?(gitignore_path)
|
|
271
|
+
|
|
272
|
+
File.foreach(gitignore_path).any? do |line|
|
|
273
|
+
line.strip.match?(%r{\A/?config/master\.key\z})
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
|
|
174
277
|
def query_osv(name, version = nil)
|
|
175
278
|
require "net/http"
|
|
176
279
|
require "uri"
|