scryer 1.0.0 → 1.2.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.
Files changed (67) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +414 -0
  3. data/README.md +114 -649
  4. data/docs/architecture.md +268 -0
  5. data/docs/contributing.md +42 -0
  6. data/docs/fix-mode.md +364 -0
  7. data/docs/rails-integration.md +162 -0
  8. data/docs/rules.md +310 -0
  9. data/docs/usage.md +301 -0
  10. data/lib/generators/scryer/USAGE +10 -2
  11. data/lib/generators/scryer/templates/scryer_initializer.rb +15 -0
  12. data/lib/scryer/ai_fix_suggester.rb +37 -11
  13. data/lib/scryer/ast.rb +26 -0
  14. data/lib/scryer/authorization_watcher.rb +156 -0
  15. data/lib/scryer/baseline.rb +75 -0
  16. data/lib/scryer/cli.rb +688 -14
  17. data/lib/scryer/colorizer.rb +56 -0
  18. data/lib/scryer/dependency_fixer.rb +96 -0
  19. data/lib/scryer/finding.rb +6 -0
  20. data/lib/scryer/fix_runner.rb +161 -0
  21. data/lib/scryer/fix_verifier.rb +169 -0
  22. data/lib/scryer/mechanical_fixer.rb +288 -0
  23. data/lib/scryer/minitest.rb +48 -0
  24. data/lib/scryer/performance_rules/inefficient_save_loop_rule.rb +32 -0
  25. data/lib/scryer/performance_rules/missing_pagination_rule.rb +1 -0
  26. data/lib/scryer/performance_rules/n_plus_one_query_rule.rb +1 -0
  27. data/lib/scryer/performance_rules/unbounded_table_scan_rule.rb +1 -0
  28. data/lib/scryer/report_renderer.rb +539 -46
  29. data/lib/scryer/rspec.rb +55 -0
  30. data/lib/scryer/rule.rb +22 -2
  31. data/lib/scryer/rules/action_cable_forgery_protection_rule.rb +3 -0
  32. data/lib/scryer/rules/active_storage_inline_disposition_rule.rb +3 -0
  33. data/lib/scryer/rules/active_storage_missing_content_type_validation_rule.rb +3 -0
  34. data/lib/scryer/rules/authentication_bypass_rule.rb +30 -7
  35. data/lib/scryer/rules/command_injection_rule.rb +3 -0
  36. data/lib/scryer/rules/consider_all_requests_local_rule.rb +51 -0
  37. data/lib/scryer/rules/cors_misconfiguration_rule.rb +51 -20
  38. data/lib/scryer/rules/csrf_protection_rule.rb +60 -11
  39. data/lib/scryer/rules/force_ssl_rule.rb +3 -0
  40. data/lib/scryer/rules/graphql_missing_query_limits_rule.rb +31 -0
  41. data/lib/scryer/rules/hardcoded_basic_auth_rule.rb +3 -0
  42. data/lib/scryer/rules/hardcoded_secret_key_base_rule.rb +3 -0
  43. data/lib/scryer/rules/hardcoded_secret_rule.rb +3 -0
  44. data/lib/scryer/rules/host_authorization_disabled_rule.rb +50 -0
  45. data/lib/scryer/rules/idor_rule.rb +63 -9
  46. data/lib/scryer/rules/insecure_cookie_serializer_rule.rb +3 -0
  47. data/lib/scryer/rules/job_raw_params_rule.rb +40 -7
  48. data/lib/scryer/rules/jwt_insecure_rule.rb +3 -0
  49. data/lib/scryer/rules/mass_assignment_rule.rb +32 -5
  50. data/lib/scryer/rules/missing_authorization_rule.rb +103 -0
  51. data/lib/scryer/rules/missing_policy_scope_rule.rb +134 -0
  52. data/lib/scryer/rules/open_redirect_rule.rb +3 -0
  53. data/lib/scryer/rules/path_traversal_rule.rb +22 -1
  54. data/lib/scryer/rules/security_headers_rule.rb +3 -0
  55. data/lib/scryer/rules/sql_injection_rule.rb +3 -0
  56. data/lib/scryer/rules/ssrf_rule.rb +67 -13
  57. data/lib/scryer/rules/unsafe_deserialization_rule.rb +3 -0
  58. data/lib/scryer/rules/verbose_production_log_level_rule.rb +53 -0
  59. data/lib/scryer/rules/weak_crypto_rule.rb +37 -2
  60. data/lib/scryer/rules/weak_session_cookie_rule.rb +3 -0
  61. data/lib/scryer/rules/xss_unsafe_html_rule.rb +41 -0
  62. data/lib/scryer/scanner.rb +25 -12
  63. data/lib/scryer/style_rules/frozen_string_literal_rule.rb +1 -0
  64. data/lib/scryer/version.rb +1 -1
  65. data/lib/scryer.rb +30 -1
  66. data/lib/tasks/scryer.rake +447 -20
  67. metadata +52 -12
data/docs/rules.md ADDED
@@ -0,0 +1,310 @@
1
+ # What Scryer Detects
2
+
3
+ [← Back to Scryer — Ruby on Rails Security Auditor](../README.md)
4
+
5
+ ## What Scryer detects
6
+
7
+ **Security**
8
+
9
+ * SQL injection
10
+ * Mass assignment
11
+ * Command injection
12
+ * Hardcoded secrets (and hardcoded HTTP Basic Auth credentials)
13
+ * Unsafe deserialization
14
+ * XSS-prone HTML
15
+ * CSRF gaps
16
+ * Weak cryptography
17
+ * Open redirects
18
+ * Server-side request forgery (SSRF)
19
+ * Path traversal
20
+ * Insecure direct object references (IDOR) — the least precise check in the gem; see
21
+ [comparison table](./architecture.md#scryer-vs-rubocop-vs-brakeman-vs-bundler-audit) for the false-positive tradeoff.
22
+ Correctly examines namespaced controllers too (`Admin::PostsController`,
23
+ `Api::V1::UsersController`) — an earlier version silently skipped any namespaced class here
24
+ * A `create`/`update`/`destroy` action with no authorization check anywhere in its controller
25
+ (broader than IDOR — catches write actions that don't call `.find` at all)
26
+ * A Pundit-using controller's `index` action querying a model directly instead of through
27
+ `policy_scope` (the common "remembered `authorize`, forgot `policy_scope`" gotcha)
28
+ * Authentication filters explicitly skipped (`skip_before_action`) — also correctly examines
29
+ namespaced controllers, same fix as IDOR above
30
+ * Rails security configuration: disabled HTTPS enforcement, session cookies missing the secure
31
+ flag, the Marshal cookie serializer, disabled default security headers (`X-Frame-Options`,
32
+ `X-Content-Type-Options`, Content-Security-Policy), Action Cable request forgery protection
33
+ disabled, a hardcoded `secret_key_base`
34
+ * Active Storage attachments without a content-type allowlist, or served with inline disposition
35
+ * CORS misconfiguration (wildcard origin combined with `credentials: true`)
36
+ * Insecure JWT usage (`JWT.decode` with signature verification disabled, `algorithm: 'none'`, or a
37
+ hardcoded secret)
38
+ * Background jobs (`perform_async`/`perform_later`/`perform_now`) passed raw `params` — risks
39
+ leaking request data into Sidekiq/ActiveJob logs, Redis, or the Sidekiq web UI
40
+ * GraphQL schemas with no query depth/complexity limit (`max_depth`/`max_complexity`)
41
+ * Rails config audit: `config.consider_all_requests_local = true` left on in
42
+ `config/environments/production.rb` (shows full debug error pages — backtrace, local variables,
43
+ request params — to anyone who triggers an exception), `config.log_level = :debug` in production
44
+ (logs full request params and SQL bind values), `config.hosts.clear` (disables the Host-header
45
+ allowlist that protects against DNS-rebinding/Host-header-injection). All three are Rails
46
+ defaults in development/test where they're completely normal — only flagged where they're
47
+ actually risky.
48
+
49
+ Every security finding also carries a **CWE ID**, an **OWASP Top 10 (2021) category**, and a
50
+ **confidence level** (`high`/`medium`/`low`) — all three appear in every report format (JSON, CSV,
51
+ HTML, SARIF) and in the OWASP Top 10 coverage scorecard (console summary + a dedicated HTML
52
+ section). Two things worth being precise about:
53
+
54
+ - **Confidence is a different axis from severity.** Severity is "how bad is this if real";
55
+ confidence is "how often does this specific rule's pattern-match actually reflect a real issue."
56
+ `idor` is a good example of the split: a real IDOR is serious (`warning` severity, arguably
57
+ understating it), but the rule's own heuristic — no visible authorization call anywhere in the
58
+ controller class — is the least precise in the gem, so it's tagged `low` confidence. Most rules
59
+ are `medium`; the narrowest, most literal-match rules (a hardcoded string literal, an explicit
60
+ `= false` config assignment) are `high`.
61
+ - **The CWE/OWASP mapping is Scryer's own best-effort categorization**, chosen for practitioner
62
+ convenience (a quick answer to "does our tooling cover OWASP category X" in a compliance
63
+ conversation) — it is not an OWASP-endorsed mapping and hasn't been independently audited. A
64
+ handful of the 31 assignments involved a real judgment call where a rule's issue doesn't map
65
+ cleanly onto exactly one OWASP category (e.g. `job_raw_params`, which is as much a data-exposure
66
+ concern as a logging one). Treat it as a useful pointer, not a certification.
67
+
68
+ **Code quality**
69
+
70
+ * Near-duplicate code
71
+ * Repeated logic
72
+ * Potentially problematic code patterns
73
+ * Missing `frozen_string_literal` magic comment (the one deliberate, narrow style check — see
74
+ [comparison table](./architecture.md#scryer-vs-rubocop-vs-brakeman-vs-bundler-audit) for why not more)
75
+
76
+ **Performance**
77
+
78
+ * N+1 queries
79
+ * Missing pagination
80
+ * Inefficient per-record saves
81
+ * Unbounded full-table iteration
82
+
83
+ **Dependencies**
84
+
85
+ * Known dependency vulnerabilities via OSV.dev
86
+ * Insecure gem sources
87
+ * Ruby version end-of-life (no more security patches published for it, for any issue)
88
+ * `config/master.key` present on disk with no matching `.gitignore` entry
89
+
90
+ **Runtime** (opt-in, needs a running app — see [Runtime query watcher](./rails-integration.md#runtime-query-watcher) and
91
+ [Runtime authorization watcher](./rails-integration.md#runtime-authorization-watcher))
92
+
93
+ * N+1 queries and unused eager loading, as they actually happen
94
+ * A write action that completed with no Pundit/CanCanCan authorization check actually invoked
95
+ during that request — a live, false-positive-resistant companion to the static `idor`/
96
+ `missing_authorization`/`missing_policy_scope` rules above
97
+
98
+ ## Example findings
99
+
100
+ Real output — three lines of deliberately flawed Rails code, scanned with plain `scryer`:
101
+
102
+ ```ruby
103
+ def create
104
+ @invoice = Invoice.create(params[:invoice])
105
+ end
106
+ ```
107
+
108
+ ```
109
+ [CRITICAL] mass_assignment — app/controllers/invoices_controller.rb:3
110
+ `create` receives `params` (or a subscript of it) directly, with no `.permit(...)` call — every
111
+ attribute in the request can be set, including ones the form/API was never meant to expose (e.g.
112
+ `admin`, `role_id`).
113
+
114
+ fix: Wrap the params in a strong-parameters method, e.g. `create(order_params)` with
115
+ `def order_params; params.require(:order).permit(:status, :total); end` — only the explicitly
116
+ permitted keys get through.
117
+ ```
118
+
119
+ ```ruby
120
+ Invoice.where("customer_name = '#{name}'")
121
+ ```
122
+
123
+ ```
124
+ [CRITICAL] sql_injection — app/controllers/invoices_controller.rb:9
125
+ `where` is called with a string built via interpolation, which lets user-controlled input change
126
+ the SQL executed.
127
+
128
+ fix: Use a parameterized form instead, e.g. `where("column = ?", value)` or the hash form
129
+ `where(column: value)` — both let Active Record escape the value safely instead of interpolating
130
+ it directly into SQL.
131
+ ```
132
+
133
+ ```ruby
134
+ @invoices = Invoice.where(status: "open")
135
+ @invoices.each { |invoice| invoice.account.name }
136
+ ```
137
+
138
+ ```
139
+ [WARNING] n_plus_one_query — app/controllers/invoices_controller.rb:14
140
+ `invoice.account` is called inside a loop — if `account` is an association, this issues a separate
141
+ query per iteration instead of one batched query (a classic N+1).
142
+
143
+ fix: Eager-load the association on the base query before the loop, e.g.
144
+ `invoices = Model.includes(:account).where(...)` (or add `:account` to an existing `.includes(...)`
145
+ call), so Rails fetches it in one extra query instead of one per record.
146
+ ```
147
+
148
+ Every finding follows this shape, whichever category it's in: exactly where the issue is, why it
149
+ matters, and a fix written against your actual code — not a generic paragraph you have to
150
+ translate into your own file.
151
+
152
+ ## Reports
153
+
154
+ Generate detailed JSON or self-contained HTML reports:
155
+
156
+ ```bash
157
+ scryer -o report.json -o report.html
158
+ ```
159
+
160
+ The HTML report leads with a [security score](./architecture.md#security-score) badge and a severity distribution
161
+ chart, then Overview/Summary/OWASP coverage, collapsed-by-default reference tables (every rule
162
+ that *can* fire, not a wall of always-expanded detail), and the Findings section itself — Top
163
+ priorities first, then a text filter box (rule/file/message, no page reload) above the full
164
+ severity-grouped, accordion-collapsed list. Each finding also shows its CWE ID, OWASP category, and
165
+ confidence level as small tags; clicking a count in the OWASP coverage table jumps to Findings with
166
+ that exact category pre-filled into the search box, so "how many `A01` findings are there" and
167
+ "which ones, specifically" are one click apart instead of two different views.
168
+
169
+ #### JSON output
170
+
171
+ `report.json`'s `security_findings`/`performance_findings`/`style_findings`/`dependency_findings`
172
+ arrays are plain objects (`Scryer::Finding#to_h`) — one real entry, unedited:
173
+
174
+ ```json
175
+ {
176
+ "rule_id": "mass_assignment",
177
+ "category": "security",
178
+ "severity": "critical",
179
+ "confidence": "medium",
180
+ "cwe": "CWE-915",
181
+ "owasp_category": "A08:2021-Software and Data Integrity Failures",
182
+ "file": "app/controllers/orders_controller.rb",
183
+ "line": 3,
184
+ "code_snippet": "Order.new(params[:order])",
185
+ "message": "`new` receives `params` (or a subscript of it) directly, with no `.permit(...)` call — every attribute in the request can be set, including ones the form/API was never meant to expose (e.g. `admin`, `role_id`).",
186
+ "suggested_fix": "Wrap the params in a strong-parameters method, e.g. `new(order_params)` with `def order_params; params.require(:order).permit(:status, :total); end` — only the explicitly permitted keys get through.",
187
+ "fix_verified": null
188
+ }
189
+ ```
190
+
191
+ `fix_verified` is `null` unless an [`ai_client`](./fix-mode.md#ai-assisted-fix-suggestions) is configured (then
192
+ `true`/`false`/`null` — see [Verifying a fix](./fix-mode.md#verifying-a-fix) for what each means).
193
+
194
+ #### SARIF output
195
+
196
+ `report.sarif` (SARIF 2.1.0) maps the same finding into a `results[]` entry GitHub Code Scanning
197
+ and similar dashboards understand natively:
198
+
199
+ ```json
200
+ {
201
+ "ruleId": "mass_assignment",
202
+ "level": "error",
203
+ "rank": 75,
204
+ "message": { "text": "`new` receives `params` (or a subscript of it) directly, with no `.permit(...)` call — every attribute in the request can be set, including ones the form/API was never meant to expose (e.g. `admin`, `role_id`)." },
205
+ "locations": [{ "physicalLocation": { "artifactLocation": { "uri": "app/controllers/orders_controller.rb" }, "region": { "startLine": 3 } } }],
206
+ "properties": { "confidence": "medium", "cwe": "CWE-915", "owasp_category": "A08:2021-Software and Data Integrity Failures" }
207
+ }
208
+ ```
209
+
210
+ `level` follows severity (`critical`→`error`, `warning`→`warning`, `info`→`note`); `rank` combines
211
+ severity and confidence into one prioritization number for dashboards that sort on it. See
212
+ [CI/CD integration](./usage.md#cicd-integration) for wiring this into `github/codeql-action/upload-sarif`.
213
+
214
+ ## Developer-friendly suggestions
215
+
216
+ Every finding includes a human-reviewable suggested fix. Scryer never automatically modifies your source code.
217
+
218
+ ## Runtime analysis
219
+
220
+ Scryer can optionally monitor ActiveRecord queries at runtime to detect N+1 queries and unused eager loading.
221
+
222
+ ## Designed for Ruby
223
+
224
+ Scryer uses Ruby's standard-library `Ripper` parser, allowing source analysis without requiring Rails or Bundler to run the static scan itself.
225
+
226
+ Several more things live alongside the static scan, each documented in its own section below:
227
+
228
+ - **Skipping rules** ([Skipping rules](./usage.md#skipping-rules)): silence a specific rule by `rule_id`
229
+ (config-wide via `c.skip_rules`, or one-off via `--skip`) without editing or deleting it.
230
+ - A **dependency audit** ([Dependency audit](#dependency-audit)), on by default, that checks
231
+ `Gemfile.lock` against [OSV.dev](https://osv.dev) for known-vulnerable gem versions and insecure
232
+ sources — the same broad goal as [bundler-audit](https://github.com/rubysec/bundler-audit), built
233
+ independently on a different data source. Pass `--no-deps` (or the `nodeps` rake arg) for a fast,
234
+ fully offline run instead.
235
+ - **AI-assisted fix suggestions** ([AI-assisted fix suggestions](./fix-mode.md#ai-assisted-fix-suggestions)),
236
+ optional, provider-agnostic: rewrites each finding's `suggested_fix` against its actual code
237
+ using an LLM you configure — any LLM, not a specific vendor.
238
+
239
+ **Nothing is auto-applied.** A security or performance fix needs a human's judgment about the
240
+ surrounding code; this gem's job is to point at the issue and explain it clearly, not to rewrite
241
+ your files. This holds whether `suggested_fix` came from a rule's static template or from the
242
+ optional AI enrichment below — either way, it's text for a human to read and act on.
243
+
244
+
245
+ ## Dependency audit
246
+
247
+ `Scryer::DependencyAudit` checks `Gemfile.lock` for the same broad concerns
248
+ [bundler-audit](https://github.com/rubysec/bundler-audit) targets — known-vulnerable gem versions
249
+ and insecure dependency sources — built independently on a different data source and a different
250
+ lockfile parser: rather than bundler-audit's local clone of the `ruby-advisory-db` git repo, this
251
+ queries [OSV.dev](https://osv.dev) (Google's Open Source Vulnerabilities database) live, one
252
+ lookup per gem+version, over a small stdlib-only thread pool; `Gemfile.lock` is read with a small
253
+ hand-rolled parser (see `DependencyAudit.parse_lockfile`) instead of depending on the `bundler`
254
+ library, so it works the same whether or not this happens to run under Bundler. No bundler-audit
255
+ source was read or copied to build this.
256
+
257
+ ```bash
258
+ bin/rails scryer:audit_dependencies # inside a Rails app — dependency audit only, no static scan
259
+ ```
260
+
261
+ This is the one part of Scryer that needs a live network connection (to reach OSV.dev), and it
262
+ runs automatically as part of every `scryer`/`scryer:report` scan — pass `--no-deps` (CLI) or the
263
+ `nodeps` arg (rake) for a fast, fully offline run instead. `scryer:audit_dependencies` above (and
264
+ `scryer --audit-deps` below) are for when you want *only* the dependency audit, the same way
265
+ `bundle-audit check` is a separate, standalone command from your test suite. Either way it exits
266
+ non-zero if anything is found, so it can gate CI.
267
+
268
+ Three checks run, and any of them can be called on its own as a library:
269
+
270
+ ```ruby
271
+ Scryer::DependencyAudit.insecure_sources(Rails.root.to_s) # offline — no network needed
272
+ Scryer::DependencyAudit.vulnerable_gems(Rails.root.to_s) # needs network (OSV.dev)
273
+ Scryer::DependencyAudit.ruby_eol_check(Rails.root.to_s) # offline — checked against a small embedded table
274
+ ```
275
+
276
+ - **Insecure sources**: flags any `GIT`/`PATH` block in `Gemfile.lock` whose `remote:` is
277
+ unencrypted (`git://` or plain `http://`) — the same supply-chain concern bundler-audit's
278
+ insecure-source check targets.
279
+ - **Vulnerable gems**: for every RubyGems-sourced gem (git/path-sourced gems are skipped — their
280
+ version string doesn't necessarily correspond to the same code as the published gem of that
281
+ name, so checking them against RubyGems advisories could misattribute or miss vulnerabilities),
282
+ queries OSV.dev for known vulnerabilities affecting that exact version. Each finding carries the
283
+ advisory id, title, a severity bucketed from OSV's own severity level, a link to the advisory,
284
+ and the fixed version(s) to upgrade to. This is also where Rails-framework CVEs surface —
285
+ `rails`/`actionview`/`activestorage`/etc. are just gems in `Gemfile.lock` like any other.
286
+ - **Ruby EOL**: checks the Ruby version pinned in `Gemfile.lock`'s `RUBY VERSION` section against
287
+ Ruby's own [published end-of-life dates](https://www.ruby-lang.org/en/downloads/branches/) — once
288
+ a series is EOL, no security patches are published for it at all, for any issue, regardless of
289
+ how up to date every gem is. Unlike the two checks above, this doesn't call OSV.dev: EOL dates
290
+ are announced years in advance and essentially never change, so a small embedded table
291
+ (`DependencyAudit::RUBY_EOL_DATES`) is a one-time/occasional-update cost rather than a live feed
292
+ — deliberately not a general Ruby-interpreter CVE database, which would mean maintaining exactly
293
+ the kind of stale bundled knowledge base this gem avoids elsewhere (OSV.dev has no queryable
294
+ Ruby-interpreter ecosystem to query live instead).
295
+
296
+ **From the `scryer` executable** (outside a Rails app, or in CI): `scryer --audit-deps` runs the
297
+ same three checks standalone (no static scan), same output/exit-code behavior as the rake task above.
298
+ Plain `scryer` already folds them into the normal `-o` report — one HTML/JSON/CSV file covering
299
+ static findings *and* dependency findings together — so `--audit-deps` is only for when you want
300
+ dependency findings *without* the static scan. And for a single gem, without touching
301
+ `Gemfile.lock` at all:
302
+
303
+ ```bash
304
+ scryer --check-gem rack # every advisory ever filed against rack, any version
305
+ scryer --check-gem rack:2.0.8 # only advisories affecting exactly this version
306
+ ```
307
+
308
+ This is the same OSV.dev client `vulnerable_gems` uses, exposed as a one-off lookup — useful for
309
+ "is this gem I'm about to add actually fine" before it's even in your `Gemfile.lock`.
310
+
data/docs/usage.md ADDED
@@ -0,0 +1,301 @@
1
+ # Usage & Configuration
2
+
3
+ [← Back to Scryer — Ruby on Rails Security Auditor](../README.md)
4
+
5
+ ## Install
6
+
7
+ Scryer is a development-time analysis tool, not something a running production process needs —
8
+ scope it to the `:development` group so a production deploy (typically `bundle install --without
9
+ development`, or `BUNDLE_WITHOUT=development` in CI) never installs it at all:
10
+
11
+ ```ruby
12
+ # Gemfile
13
+ group :development do
14
+ gem "scryer"
15
+ end
16
+ ```
17
+
18
+ ```bash
19
+ bundle install
20
+ bin/rails generate scryer:install
21
+ ```
22
+
23
+ This drops a commented `config/initializers/scryer.rb` into your app — see
24
+ [Generators](./rails-integration.md#generators) below for exactly what it does and what each setting controls. Scryer
25
+ is entirely local: there is nothing to point at a server and nothing to authenticate, so the
26
+ generator's only job is letting you override the defaults (project name, which directories get
27
+ scanned, the branch label).
28
+
29
+ ## Running a scan
30
+
31
+ ```bash
32
+ bin/rails scryer:report # writes tmp/scryer_report.{json,html}
33
+ ```
34
+
35
+ By default this writes both `tmp/scryer_report.json` and `tmp/scryer_report.html`, and includes a
36
+ dependency audit against `Gemfile.lock` (needs network — see [Dependency audit](./rules.md#dependency-audit);
37
+ pass the `nodeps` arg for a fast, fully offline run instead). Format and output path are
38
+ configurable via task args — any mix of `json`, `html`, `csv`, `sarif` plus at most one path (quote
39
+ the whole thing so your shell doesn't eat the brackets/commas, e.g.
40
+ `bin/rails 'scryer:report[json,doc/security_report.json]'`). Rake splits bracket args on every
41
+ comma, so each format is its own item in the list rather than one comma-joined string
42
+ (`scryer:report[json,html]` is two args, not `"json,html"` as one). At most one non-format,
43
+ non-`nodeps` token is accepted per call; giving two raises an error rather than guessing which one
44
+ you meant.
45
+
46
+ Outside a Rails app (or in CI, or anywhere you don't want a rake task), the gem also ships a
47
+ `scryer` executable — same idea as `brakeman -o report.json`, with `-o` repeatable and format
48
+ inferred per-file from its extension:
49
+
50
+ ```bash
51
+ scryer # scans ., writes tmp/scryer_report.{json,html}
52
+ scryer -o report.json # just one file, exact path, format from extension
53
+ scryer -o report.json -o report.html # as many outputs as you like, one -o each
54
+ scryer -p /path/to/app -o /tmp/out.html # -p sets the root to scan (default: cwd)
55
+ scryer --no-deps # skip the dependency audit for a fast offline run
56
+ scryer --help # full option list (--project-name, --branch, --version, ...)
57
+ ```
58
+
59
+ Console output is a summary box across every category — see the box in the intro above for a real
60
+ example — followed by where each report was written.
61
+
62
+ **Color.** The summary box, top priorities, and `scryer fix`/`scryer verify` output are colored
63
+ automatically at a real terminal — severity labels (`critical`/`warning`/`info` → red/yellow/cyan),
64
+ the security score's letter grade (A/B green, C yellow, D/F red), fixed/skipped status lines, and
65
+ so on. Off automatically whenever it would be wrong to color: piped/redirected output (e.g. `-o -`
66
+ to a file, or piped to `less` without `-R`), [`NO_COLOR`](https://no-color.org) set, or
67
+ `TERM=dumb`. `--color`/`--no-color` force it either way regardless of any of that — e.g. `scryer
68
+ --color | less -R` to keep color through a pager. Inside a Rails app: `SCRYER_COLOR=1`/
69
+ `SCRYER_NO_COLOR=1` (env vars, same precedence as the CLI flags). No new dependency — a small
70
+ hand-rolled ANSI helper (`Scryer::Colorizer`), consistent with the zero-runtime-dependency design.
71
+
72
+ See [Exit codes](#exit-codes) for what `0`/`1`/`2` mean here and for `scryer verify`/`scryer fix`.
73
+
74
+ Or use the scanning engine directly as a library:
75
+
76
+ ```ruby
77
+ require "scryer"
78
+ result = Scryer::Scanner.new(root: "/path/to/app").call
79
+ puts result.security_findings.size
80
+ ```
81
+
82
+ Open `tmp/scryer_report.html` in a browser — it's a single self-contained file (inline CSS, no
83
+ external assets, works offline) with an overview, a summary of counts by severity, the full list
84
+ of checks that ran, a breakdown of warnings by type, every finding in detail, and duplicate-code
85
+ groups — laid out similarly to a Brakeman report. `tmp/scryer_report.json` has the same data in
86
+ machine-readable form, for feeding into your own dashboard or CI gate. `.csv`
87
+ (`scryer -o report.csv` / `rails 'scryer:report[csv]'`) is a flat one-row-per-finding table
88
+ (security + performance findings, plus dependency findings unless `--no-deps`/`nodeps` was used) —
89
+ handy for dropping into a spreadsheet or importing into a ticketing tool; it skips duplicate-code
90
+ groups, which don't reduce to a single actionable row. `.sarif` (`scryer -o report.sarif` /
91
+ `rails 'scryer:report[sarif]'`) is for feeding into a CI security dashboard instead — see
92
+ [CI/CD integration](#cicd-integration).
93
+
94
+ ## Exit codes
95
+
96
+ | Command | `0` | `1` | `2` |
97
+ |---|---|---|---|
98
+ | `scryer` (scan) | Clean — no security or dependency findings | At least one security or dependency finding | Usage error (bad flag, unrecognized output extension) |
99
+ | `scryer verify --rule ID --file PATH` | The rule no longer fires on that file | The rule still fires (findings printed) | Usage error (unknown rule_id, missing file, unparseable source) |
100
+ | `scryer fix` | Everything matched was fixed (or nothing matched) | At least one finding still needs manual review after this run | Usage error (no `ai_client` and nothing mechanically fixable, bad flag, ...) |
101
+
102
+ Every non-zero exit is deliberate and stable across versions, so any of these can gate a CI job
103
+ directly (`scryer -o report.json || exit 1`, or just let the process's own exit code fail the
104
+ step) — this is the same contract `brakeman -o report.json` and `bundle-audit check` already use,
105
+ so swapping Scryer into an existing security-gate step doesn't change how the pipeline reacts to
106
+ it.
107
+
108
+ ## CI/CD integration
109
+
110
+ `scryer -o report.sarif` writes a [SARIF 2.1.0](https://docs.oasis-open.org/sarif/sarif/v2.1.0/)
111
+ report — the format GitHub Code Scanning, Azure DevOps, and most CI security dashboards ingest
112
+ natively, turning findings into inline pull-request annotations and Security-tab entries instead
113
+ of a report file nobody opens. It's a pure mapping of the same data every other format has —
114
+ security, performance, and style findings (each mapped from `Scryer::Rule.rule_id`/`severity`) plus
115
+ dependency findings (mapped from `kind`, located at `Gemfile.lock`) — so nothing behaves any
116
+ differently than it does in HTML/JSON/CSV.
117
+
118
+ ### Option A: the bundled `action.yml`
119
+
120
+ [`action.yml`](../action.yml) in this repo wraps the whole thing — install, scan, SARIF upload, and
121
+ correctly ordering all three so the upload still happens even when findings were reported (see the
122
+ `continue-on-error` explanation below for why that ordering matters):
123
+
124
+ ```yaml
125
+ # .github/workflows/scryer.yml
126
+ name: Scryer
127
+ on: [push, pull_request]
128
+
129
+ jobs:
130
+ scryer:
131
+ runs-on: ubuntu-latest
132
+ permissions:
133
+ contents: read
134
+ security-events: write # required to upload SARIF
135
+ steps:
136
+ - uses: actions/checkout@v4
137
+ - uses: ramlaxmanyadav/scryer@v1.2.0
138
+ with:
139
+ ruby-version: "3.3"
140
+ ```
141
+
142
+ All inputs are optional — `path` (default `.`), `scryer-version` (default: latest), `extra-args`
143
+ (e.g. `--skip idor --no-deps`), `fail-on-findings` (default `true`), `upload-sarif` (default
144
+ `true`). Outputs: `sarif-path`, `json-path`, `exit-code`. See [action.yml](../action.yml) for the full
145
+ input/output reference.
146
+
147
+ `@v1.2.0` above needs a matching git tag pushed to GitHub before `uses:` can resolve it — replace
148
+ it with whatever version tag you actually push (or `@main` to always track the latest commit,
149
+ accepting that it can change under you).
150
+
151
+ ### Option B: hand-written steps
152
+
153
+ For full control over the individual steps (a different Ruby setup, extra caching, a different
154
+ job structure):
155
+
156
+ ```yaml
157
+ # .github/workflows/scryer.yml
158
+ name: Scryer
159
+ on: [push, pull_request]
160
+
161
+ jobs:
162
+ scryer:
163
+ runs-on: ubuntu-latest
164
+ permissions:
165
+ contents: read
166
+ security-events: write # required to upload SARIF
167
+ steps:
168
+ - uses: actions/checkout@v4
169
+ - uses: ruby/setup-ruby@v1
170
+ with:
171
+ ruby-version: "3.3"
172
+ - run: gem install scryer
173
+ - run: scryer -o scryer.sarif
174
+ continue-on-error: true # let the upload step run even on a non-zero exit; see below
175
+ - uses: github/codeql-action/upload-sarif@v3
176
+ with:
177
+ sarif_file: scryer.sarif
178
+ ```
179
+
180
+ `continue-on-error: true` on the scan step matters: `scryer` exits non-zero when it finds anything
181
+ (see [Running a scan](#running-a-scan)), which would otherwise skip the upload step entirely on
182
+ the run where you most want to see the results. If you want the job to still fail the build overall
183
+ on findings, check `scryer`'s own exit code in a separate step, or drop `continue-on-error` and
184
+ accept that the SARIF upload only happens on clean runs. `action.yml` (Option A) handles this
185
+ ordering for you automatically.
186
+
187
+ ### Rails apps: `bin/rails scryer:ci`
188
+
189
+ Inside a Rails app (rather than the standalone `scryer` executable above), `scryer:ci` is a
190
+ memorable shortcut for the same CI-sensible defaults — JSON + SARIF under `tmp/`, dependency audit
191
+ on, same `SCRYER_BASELINE` support and same fail-the-build-on-findings behavior as `scryer:report`
192
+ — without needing to remember the bracket-arg syntax:
193
+
194
+ ```bash
195
+ bin/rails scryer:ci # same as bin/rails 'scryer:report[json,sarif]'
196
+ ```
197
+
198
+
199
+ ## What gets scanned
200
+
201
+ By default: `app/`, `lib/`, `config/`, `db/` (relative to the app root) — configurable via
202
+ `c.dirs` in the initializer. `vendor/`, `node_modules/`, `tmp/`, `log/`, `.git/`, `spec/`, `test/`
203
+ are always skipped.
204
+
205
+ ## Branch reporting
206
+
207
+ By default the `git_branch` recorded in the report comes from `git rev-parse --abbrev-ref HEAD` in
208
+ the app root. Set `c.branch` in the initializer to override this — useful in CI, where checkouts
209
+ often land in a detached-HEAD state and there's no real branch name to detect:
210
+
211
+ ```ruby
212
+ Scryer.configure do |c|
213
+ c.branch = ENV["CI_COMMIT_BRANCH"]
214
+ end
215
+ ```
216
+
217
+ This only changes the label shown in the report; it does not check out a different branch or
218
+ restrict which branch can be scanned.
219
+
220
+ ## Skipping rules
221
+
222
+ Every rule is heuristic, so an occasional false positive on your specific codebase is expected
223
+ (see "A note on how this gem was actually verified" above) — `skip_rules` silences one by
224
+ `rule_id` without editing or deleting the rule itself, so it still runs (and could still catch a
225
+ real issue) on every other codebase that uses this gem:
226
+
227
+ ```ruby
228
+ # config/initializers/scryer.rb
229
+ Scryer.configure do |c|
230
+ c.skip_rules = ["mass_assignment"] # rule_id, from the finding's `rule_id` field
231
+ end
232
+ ```
233
+
234
+ The `scryer` executable's `--skip RULE_ID` flag (repeatable) adds to this list for a single run
235
+ without touching the initializer — useful for a one-off "does this go away without rule X" check:
236
+
237
+ ```bash
238
+ scryer --skip mass_assignment --skip weak_crypto
239
+ ```
240
+
241
+ ## Duplicate-code detection
242
+
243
+ Method/query/cache-key similarity detection across models, controllers, helpers, and concerns
244
+ (see "What Scryer detects" for how it works) runs by default. Unlike the security/performance/
245
+ style checks, it isn't a `Scryer::Rule` with its own `rule_id`, so `skip_rules` can't address it —
246
+ `c.detect_duplicates` is its own on/off switch instead:
247
+
248
+ ```ruby
249
+ # config/initializers/scryer.rb
250
+ Scryer.configure do |c|
251
+ c.detect_duplicates = false # too noisy or too slow for this codebase
252
+ end
253
+ ```
254
+
255
+ For a one-off run without touching the initializer: the standalone executable's `--no-duplicates`
256
+ flag, or the rake task's `noduplicates` bracket token (alongside `nodeps`) — either only turns
257
+ detection off for that run, never back on if `c.detect_duplicates` is already `false`:
258
+
259
+ ```bash
260
+ scryer --no-duplicates
261
+ ```
262
+
263
+ ```ruby
264
+ rails 'scryer:report[html,noduplicates]'
265
+ ```
266
+
267
+ ## Baseline mode
268
+
269
+ `--skip` silences a rule everywhere, permanently. Baseline mode is for the more common real-world
270
+ situation: an existing app has genuine pre-existing findings you're not going to fix today, but you
271
+ still want CI to fail on anything *new* from here on:
272
+
273
+ ```bash
274
+ # Once: snapshot the current state
275
+ scryer --save-baseline tmp/scryer_baseline.json
276
+
277
+ # Every run after that: only new findings show up, count toward the exit code,
278
+ # and appear in -o reports — everything already in the baseline is suppressed
279
+ scryer --baseline tmp/scryer_baseline.json -o scryer.sarif
280
+ ```
281
+
282
+ ```
283
+ Baseline: tmp/scryer_baseline.json — showing new findings only (2 fixed since baseline).
284
+
285
+ Security Score: 96/100 (A)
286
+
287
+ Security 1 finding
288
+ ...
289
+ ```
290
+
291
+ Findings are matched by a **fingerprint** (`rule_id` + file + the offending source text), not
292
+ file:line — an unrelated edit earlier in the same file shifting every following line number
293
+ wouldn't otherwise make an existing finding look simultaneously "new" and "fixed" on every commit.
294
+ "Fixed since baseline" counts findings that were in the baseline but aren't in this scan anymore
295
+ (across every category — security, performance, style, dependencies); duplicate-code groups aren't
296
+ part of baseline mode (they don't fit the same fingerprint shape) and always show in full.
297
+
298
+ Rails/rake equivalents: `rails 'scryer:save_baseline[tmp/scryer_baseline.json]'` to save, and
299
+ `SCRYER_BASELINE=tmp/scryer_baseline.json rails scryer:report` to compare (an env var, since rake's
300
+ bracket-arg parsing is already stretched thin between format tokens, `nodeps`, and an output path).
301
+
@@ -28,13 +28,21 @@ Description:
28
28
  useful. Leave unset to use the actual checked-out
29
29
  branch.
30
30
 
31
- Two more things ship in this gem but aren't controlled by the
32
- initializer — see the README for both:
31
+ A few more things ship in this gem but aren't controlled by the
32
+ initializer — see the README for each:
33
33
 
34
34
  - Scryer::QueryWatcher: an opt-in runtime instrumentation for N+1
35
35
  queries and unused eager loading (README: "Runtime query watcher").
36
36
  - `bin/rails scryer:audit_dependencies`: a Gemfile.lock vulnerability
37
37
  + insecure-source audit against OSV.dev (README: "Dependency audit").
38
+ - `bin/rails scryer:ci`: a CI-friendly shortcut for
39
+ `scryer:report[json,sarif]` — JSON + SARIF output, dependency audit
40
+ on, same SCRYER_BASELINE support (README: "CI/CD integration").
41
+ - `require "scryer/rspec"` / `require "scryer/minitest"`: opt-in
42
+ matchers/assertions (`have_no_critical_findings`,
43
+ `assert_no_critical_scryer_findings`, ...) for asserting this app's
44
+ own scan stays clean as part of its normal test suite (README:
45
+ "Testing Scryer results in your own test suite").
38
46
 
39
47
  Example:
40
48
  bin/rails generate scryer:install
@@ -14,6 +14,21 @@ Scryer.configure do |c|
14
14
  # where checkouts often land in detached-HEAD state). Leave blank to use
15
15
  # the actual checked-out branch:
16
16
  # c.branch = ENV["CI_COMMIT_BRANCH"]
17
+
18
+ # Silence a specific rule by rule_id (repeatable) — a known false positive
19
+ # on this codebase, or a check that doesn't apply here. Prefer this over
20
+ # deleting/editing the rule itself; run `bin/rails scryer:report` once and
21
+ # check tmp/scryer_report.html's "Checks performed" section for the full
22
+ # list of rule_ids:
23
+ # c.skip_rules = %w[idor]
24
+
25
+ # Any object/Proc responding to #call(prompt) (or #complete(prompt)) that
26
+ # returns a String — opts into rewriting every finding's suggested_fix
27
+ # against its actual code via that LLM. Off by default: no network calls,
28
+ # no provider assumed. See the README's "AI-assisted fix suggestions" and
29
+ # "AI-verified remediation" sections for what this does and how the
30
+ # (optional) re-scan-to-confirm-the-fix-works verification works:
31
+ # c.ai_client = ->(prompt) { MyLlmClient.complete(prompt) }
17
32
  end
18
33
 
19
34
  # Runtime query watcher (N+1 / unused eager loading — see README's "Runtime