scryer 1.1.1 → 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.
data/README.md CHANGED
@@ -1,10 +1,12 @@
1
- # Scryer — Ruby & Rails Application Security & Risk Auditor
1
+ # Scryer — Ruby on Rails Security Auditor
2
2
 
3
- Scryer scans a Ruby/Rails codebase and answers a different question than a single-purpose linter
4
- does: not just "what's wrong," but **what's actually worth fixing first**. It looks across
5
- security vulnerabilities, performance problems, dependency risk, and code quality in one pass,
6
- ranks what it finds by severity, and surfaces the handful of issues that matter most at the top of
7
- every report with a human-reviewable suggested fix for each one.
3
+ Scryer is an all-in-one security auditing and static analysis tool for Ruby on Rails
4
+ applications tells you what to fix first, across security, performance, dependencies, and code
5
+ quality. It answers a different question than a single-purpose linter does: not just "what's wrong," but
6
+ **what's actually worth fixing first**. It looks across security vulnerabilities, performance
7
+ problems, dependency risk, and code quality in one pass, ranks what it finds by severity, and
8
+ surfaces the handful of issues that matter most at the top of every report — with a
9
+ human-reviewable suggested fix for each one.
8
10
 
9
11
  Brakeman is very good at finding security patterns in Rails apps — years of deep taint/data-flow
10
12
  analysis that Scryer's heuristics don't try to match (see the honest comparison below). But a
@@ -57,1033 +59,78 @@ That's real output from a scan of a live 236-file Rails app (an internal product
57
59
  call "acme-app" here — file/controller names above are anonymized, since we don't publish that
58
60
  app's source; the finding counts, severities, rule IDs, and line numbers are exactly as scanned,
59
61
  unedited) — not a mockup, and the harsh grade is real too (see
60
- [Security score](#security-score) for what it does and doesn't mean). "Top priorities" is the same
62
+ [Security score](docs/architecture.md#security-score) for what it does and doesn't mean). "Top priorities" is the same
61
63
  severity ranking [`ReportRenderer#top_risks`](lib/scryer/report_renderer.rb) applies across *all*
62
64
  categories — security, dependencies, performance, code quality — not just within each one; in the
63
65
  HTML report it's at the top of the Findings section, ahead of the 309 individual findings
64
66
  underneath it. See
65
- [Scryer vs RuboCop vs Brakeman vs bundler-audit](#scryer-vs-rubocop-vs-brakeman-vs-bundler-audit)
67
+ [Scryer vs RuboCop vs Brakeman vs bundler-audit](docs/architecture.md#scryer-vs-rubocop-vs-brakeman-vs-bundler-audit)
66
68
  below for exactly how this differs from what those tools do.
67
69
 
68
- ### What Scryer detects
69
-
70
- **Security**
71
-
72
- * SQL injection
73
- * Mass assignment
74
- * Command injection
75
- * Hardcoded secrets (and hardcoded HTTP Basic Auth credentials)
76
- * Unsafe deserialization
77
- * XSS-prone HTML
78
- * CSRF gaps
79
- * Weak cryptography
80
- * Open redirects
81
- * Server-side request forgery (SSRF)
82
- * Path traversal
83
- * Insecure direct object references (IDOR) the least precise check in the gem; see
84
- [comparison table](#scryer-vs-rubocop-vs-brakeman-vs-bundler-audit) for the false-positive tradeoff.
85
- Correctly examines namespaced controllers too (`Admin::PostsController`,
86
- `Api::V1::UsersController`) an earlier version silently skipped any namespaced class here
87
- * A `create`/`update`/`destroy` action with no authorization check anywhere in its controller
88
- (broader than IDOR — catches write actions that don't call `.find` at all)
89
- * A Pundit-using controller's `index` action querying a model directly instead of through
90
- `policy_scope` (the common "remembered `authorize`, forgot `policy_scope`" gotcha)
91
- * Authentication filters explicitly skipped (`skip_before_action`) also correctly examines
92
- namespaced controllers, same fix as IDOR above
93
- * Rails security configuration: disabled HTTPS enforcement, session cookies missing the secure
94
- flag, the Marshal cookie serializer, disabled default security headers (`X-Frame-Options`,
95
- `X-Content-Type-Options`, Content-Security-Policy), Action Cable request forgery protection
96
- disabled, a hardcoded `secret_key_base`
97
- * Active Storage attachments without a content-type allowlist, or served with inline disposition
98
- * CORS misconfiguration (wildcard origin combined with `credentials: true`)
99
- * Insecure JWT usage (`JWT.decode` with signature verification disabled, `algorithm: 'none'`, or a
100
- hardcoded secret)
101
- * Background jobs (`perform_async`/`perform_later`/`perform_now`) passed raw `params` — risks
102
- leaking request data into Sidekiq/ActiveJob logs, Redis, or the Sidekiq web UI
103
- * GraphQL schemas with no query depth/complexity limit (`max_depth`/`max_complexity`)
104
- * Rails config audit: `config.consider_all_requests_local = true` left on in
105
- `config/environments/production.rb` (shows full debug error pages backtrace, local variables,
106
- request params to anyone who triggers an exception), `config.log_level = :debug` in production
107
- (logs full request params and SQL bind values), `config.hosts.clear` (disables the Host-header
108
- allowlist that protects against DNS-rebinding/Host-header-injection). All three are Rails
109
- defaults in development/test where they're completely normal only flagged where they're
110
- actually risky.
111
-
112
- Every security finding also carries a **CWE ID**, an **OWASP Top 10 (2021) category**, and a
113
- **confidence level** (`high`/`medium`/`low`) all three appear in every report format (JSON, CSV,
114
- HTML, SARIF) and in the OWASP Top 10 coverage scorecard (console summary + a dedicated HTML
115
- section). Two things worth being precise about:
116
-
117
- - **Confidence is a different axis from severity.** Severity is "how bad is this if real";
118
- confidence is "how often does this specific rule's pattern-match actually reflect a real issue."
119
- `idor` is a good example of the split: a real IDOR is serious (`warning` severity, arguably
120
- understating it), but the rule's own heuristic no visible authorization call anywhere in the
121
- controller class — is the least precise in the gem, so it's tagged `low` confidence. Most rules
122
- are `medium`; the narrowest, most literal-match rules (a hardcoded string literal, an explicit
123
- `= false` config assignment) are `high`.
124
- - **The CWE/OWASP mapping is Scryer's own best-effort categorization**, chosen for practitioner
125
- convenience (a quick answer to "does our tooling cover OWASP category X" in a compliance
126
- conversation) — it is not an OWASP-endorsed mapping and hasn't been independently audited. A
127
- handful of the 31 assignments involved a real judgment call where a rule's issue doesn't map
128
- cleanly onto exactly one OWASP category (e.g. `job_raw_params`, which is as much a data-exposure
129
- concern as a logging one). Treat it as a useful pointer, not a certification.
130
-
131
- **Code quality**
132
-
133
- * Near-duplicate code
134
- * Repeated logic
135
- * Potentially problematic code patterns
136
- * Missing `frozen_string_literal` magic comment (the one deliberate, narrow style check — see
137
- [comparison table](#scryer-vs-rubocop-vs-brakeman-vs-bundler-audit) for why not more)
138
-
139
- **Performance**
140
-
141
- * N+1 queries
142
- * Missing pagination
143
- * Inefficient per-record saves
144
- * Unbounded full-table iteration
145
-
146
- **Dependencies**
147
-
148
- * Known dependency vulnerabilities via OSV.dev
149
- * Insecure gem sources
150
- * Ruby version end-of-life (no more security patches published for it, for any issue)
151
- * `config/master.key` present on disk with no matching `.gitignore` entry
152
-
153
- **Runtime** (opt-in, needs a running app — see [Runtime query watcher](#runtime-query-watcher) and
154
- [Runtime authorization watcher](#runtime-authorization-watcher))
155
-
156
- * N+1 queries and unused eager loading, as they actually happen
157
- * A write action that completed with no Pundit/CanCanCan authorization check actually invoked
158
- during that request — a live, false-positive-resistant companion to the static `idor`/
159
- `missing_authorization`/`missing_policy_scope` rules above
160
-
161
- ### Example findings
162
-
163
- Real output — three lines of deliberately flawed Rails code, scanned with plain `scryer`:
164
-
165
- ```ruby
166
- def create
167
- @invoice = Invoice.create(params[:invoice])
168
- end
169
- ```
170
-
171
- ```
172
- [CRITICAL] mass_assignment — app/controllers/invoices_controller.rb:3
173
- `create` receives `params` (or a subscript of it) directly, with no `.permit(...)` call — every
174
- attribute in the request can be set, including ones the form/API was never meant to expose (e.g.
175
- `admin`, `role_id`).
176
-
177
- fix: Wrap the params in a strong-parameters method, e.g. `create(order_params)` with
178
- `def order_params; params.require(:order).permit(:status, :total); end` — only the explicitly
179
- permitted keys get through.
180
- ```
181
-
182
- ```ruby
183
- Invoice.where("customer_name = '#{name}'")
184
- ```
185
-
186
- ```
187
- [CRITICAL] sql_injection — app/controllers/invoices_controller.rb:9
188
- `where` is called with a string built via interpolation, which lets user-controlled input change
189
- the SQL executed.
190
-
191
- fix: Use a parameterized form instead, e.g. `where("column = ?", value)` or the hash form
192
- `where(column: value)` — both let Active Record escape the value safely instead of interpolating
193
- it directly into SQL.
194
- ```
195
-
196
- ```ruby
197
- @invoices = Invoice.where(status: "open")
198
- @invoices.each { |invoice| invoice.account.name }
199
- ```
200
-
201
- ```
202
- [WARNING] n_plus_one_query — app/controllers/invoices_controller.rb:14
203
- `invoice.account` is called inside a loop — if `account` is an association, this issues a separate
204
- query per iteration instead of one batched query (a classic N+1).
205
-
206
- fix: Eager-load the association on the base query before the loop, e.g.
207
- `invoices = Model.includes(:account).where(...)` (or add `:account` to an existing `.includes(...)`
208
- call), so Rails fetches it in one extra query instead of one per record.
209
- ```
210
-
211
- Every finding follows this shape, whichever category it's in: exactly where the issue is, why it
212
- matters, and a fix written against your actual code — not a generic paragraph you have to
213
- translate into your own file.
214
-
215
- ### Reports
216
-
217
- Generate detailed JSON or self-contained HTML reports:
218
-
219
- ```bash
220
- scryer -o report.json -o report.html
221
- ```
222
-
223
- The HTML report leads with a [security score](#security-score) badge and a severity distribution
224
- chart, then Overview/Summary/OWASP coverage, collapsed-by-default reference tables (every rule
225
- that *can* fire, not a wall of always-expanded detail), and the Findings section itself — Top
226
- priorities first, then a text filter box (rule/file/message, no page reload) above the full
227
- severity-grouped, accordion-collapsed list. Each finding also shows its CWE ID, OWASP category, and
228
- confidence level as small tags; clicking a count in the OWASP coverage table jumps to Findings with
229
- that exact category pre-filled into the search box, so "how many `A01` findings are there" and
230
- "which ones, specifically" are one click apart instead of two different views.
231
-
232
- ### Developer-friendly suggestions
233
-
234
- Every finding includes a human-reviewable suggested fix. Scryer never automatically modifies your source code.
235
-
236
- ### Runtime analysis
237
-
238
- Scryer can optionally monitor ActiveRecord queries at runtime to detect N+1 queries and unused eager loading.
239
-
240
- ### Designed for Ruby
241
-
242
- Scryer uses Ruby's standard-library `Ripper` parser, allowing source analysis without requiring Rails or Bundler to run the static scan itself.
243
-
244
- Several more things live alongside the static scan, each documented in its own section below:
245
-
246
- - **Skipping rules** ([Skipping rules](#skipping-rules)): silence a specific rule by `rule_id`
247
- (config-wide via `c.skip_rules`, or one-off via `--skip`) without editing or deleting it.
248
- - A **dependency audit** ([Dependency audit](#dependency-audit)), on by default, that checks
249
- `Gemfile.lock` against [OSV.dev](https://osv.dev) for known-vulnerable gem versions and insecure
250
- sources — the same broad goal as [bundler-audit](https://github.com/rubysec/bundler-audit), built
251
- independently on a different data source. Pass `--no-deps` (or the `nodeps` rake arg) for a fast,
252
- fully offline run instead.
253
- - **AI-assisted fix suggestions** ([AI-assisted fix suggestions](#ai-assisted-fix-suggestions)),
254
- optional, provider-agnostic: rewrites each finding's `suggested_fix` against its actual code
255
- using an LLM you configure — any LLM, not a specific vendor.
256
-
257
- **Nothing is auto-applied.** A security or performance fix needs a human's judgment about the
258
- surrounding code; this gem's job is to point at the issue and explain it clearly, not to rewrite
259
- your files. This holds whether `suggested_fix` came from a rule's static template or from the
260
- optional AI enrichment below — either way, it's text for a human to read and act on.
261
-
262
- ## A note on how this gem was actually verified
263
-
264
- Unlike most hand-written Rails work, this gem's core scanning engine was genuinely executed
265
- during development — `Ripper` needs no Rails or bundler to run, so every rule here was tested
266
- against real vulnerable-code and clean/fixed-code fixtures, not just written and hoped for. Where
267
- a rule produced a false positive during that testing (an actual one did — `BCrypt::Password.create`
268
- initially tripped the mass-assignment rule meant for `Model.create(params)`), it was caught and
269
- fixed before being considered done. That's a meaningfully higher bar of confidence than most of
270
- this account's Rails-app work, which can't be executed in the sandbox it was built in.
271
-
272
- That said: this is a heuristic tool, not full data-flow/taint analysis (that's what a mature tool
273
- like Brakeman spends years building). Expect some false positives and false negatives — that's
274
- normal for this class of tool, not a bug. An already-guarded call can still get flagged since
275
- rules don't trace surrounding conditionals — always review a finding in its surrounding context
276
- before acting on it.
277
-
278
- ## Security score
279
-
280
- Every scan produces a single 0-100 score plus a letter grade (A-F), shown in the console summary
281
- and as a badge at the very top of the HTML report:
282
-
283
- ```
284
- Security Score: 11/100 (F)
285
- ```
286
-
287
- The formula (`ReportRenderer#security_score` in `lib/scryer/report_renderer.rb`): every security
288
- and dependency finding costs points, weighted by both its severity *and* its confidence (a
289
- `low`-confidence `idor` finding costs less than a `high`-confidence `sql_injection` finding at the
290
- same severity), combined via exponential decay from 100 rather than linear subtraction — one
291
- critical finding visibly moves the score (100 → ~86) without a handful of findings driving any
292
- real app straight to a hard-clamped 0.
293
-
294
- Two things worth being precise about before you treat this number as meaningful:
295
-
296
- - **It's not normalized by app size.** A 10-file app and a 1,000-file app with the same finding
297
- *density* will score very differently here — this score reflects a scan's absolute finding
298
- exposure, not a rate. That makes it useful for tracking *one project's own trend* over time (did
299
- the next scan score higher or lower), not for comparing two differently-sized codebases against
300
- each other.
301
- - **Performance and code-quality findings aren't part of it.** This is a *security* score — only
302
- security and dependency findings count. A slow app with clean security findings still scores
303
- well here; check the "Performance"/"Code Quality" rows in the summary box separately for that.
304
-
305
- Alongside the score, every scan also reports a **rule-level pass rate** — "how many of Scryer's
306
- registered checks fired zero findings":
307
-
308
- ```
309
- Checks: 23/36 rules clean (63.9%)
310
- ```
311
-
312
- This is the closest thing Scryer has to Brakeman's "X checks, Y warnings" framing — a coverage
313
- signal, not a risk signal. It's deliberately a *different* number from the security score, and the
314
- two won't always agree: a codebase can have a high clean rate (few distinct rules ever fire) and
315
- still a low score (the few that did fire were severe and high-confidence), or the reverse (many
316
- different rules each firing once, none of them serious). `ReportRenderer#rules_clean_rate` computes
317
- it from `Scryer::RuleSet.all` (every registered security/performance/style rule) against which
318
- rule_ids actually appeared in this scan's findings — dependency checks aren't counted here since
319
- they're not backed by a `Scryer::Rule` subclass. Report both; neither alone tells the whole story.
320
-
321
- The 10/F above is a real score from the acme-app example, not a cherry-picked good result — see
322
- [A note on how this gem was actually verified](#a-note-on-how-this-gem-was-actually-verified) for
323
- why every example in this README is real output.
324
-
325
- ## Accuracy benchmark
326
-
327
- Every rule in this gem is heuristic pattern-matching — stated throughout this README, but until now
328
- never backed by a measured number, just "expect some false positives, that's normal for this class
329
- of tool." [`benchmark/`](benchmark/) turns that into an actual precision/recall benchmark: a
330
- hand-labeled corpus of small Ruby/Rails snippets per rule (`benchmark/corpus.rb`), each marked
331
- `vulnerable` (should fire) or `safe` (should NOT fire — including deliberately close "near miss"
332
- samples that resemble the vulnerable shape, not just obviously unrelated code), run through the
333
- real `Scryer::Rule#scan` — the same code path an actual scan uses, not a simulation.
334
-
335
- ```bash
336
- rake benchmark # or: ruby benchmark/run.rb
337
- ```
338
-
339
- Current numbers, across all 36 registered rules and 180 labeled samples:
340
-
341
- | | TP | FN | FP | TN | Precision | Recall | F1 |
342
- |---|---|---|---|---|---|---|---|
343
- | **All 36 rules** | 82 | 3 | 1 | 94 | **98.8%** | **96.5%** | **97.6%** |
344
-
345
- Two rules score below 100% — both are gaps this gem already disclosed in the rule's own top comment
346
- before this benchmark existed, now actually measured instead of just asserted:
347
-
348
- - **`graphql_missing_query_limits`** (50% precision, 50% recall on its 4 samples): misses a schema
349
- that gets its query limits through a shared custom base class or an `include`d module — both
350
- require cross-file resolution this per-file rule doesn't attempt.
351
- - **`unbounded_table_scan`** (recall 66.7%): misses an unbounded scan once the query is assigned to
352
- a variable before `.each` instead of chained directly (`orders = Order.where(...); orders.each`).
353
- - **`mass_assignment`** (recall 66.7%): the one gap this benchmark actually *found*, not just
354
- measured — see the CHANGELOG entry on the ivar-receiver fix. The corpus's other documented
355
- false-negative sample for this rule (a namespaced `Admin::Order.create(...)` receiver) is still
356
- an open, disclosed gap.
357
-
358
- **Read `benchmark/README.md` before quoting any of these numbers** — this is a corpus we wrote
359
- ourselves specifically to stress-test our own rules, not an independent third-party benchmark (the
360
- way, say, the OWASP Benchmark project is for Java tools), and not real production code the way the
361
- acme-app numbers used throughout this README are. Treat it as a lower bound on rigor (something
362
- concrete was actually measured) and an upper bound on confidence (real-world false-positive/
363
- false-negative rates on *your* codebase, with its own idioms and libraries, can and will differ).
364
-
365
- ## Scryer vs RuboCop vs Brakeman vs bundler-audit
366
-
367
- Brakeman is the deeper, more mature tool for the security categories it's spent years on — real
368
- taint/data-flow analysis, not heuristic pattern-matching (footnote [i]). RuboCop owns style/lint
369
- conventions; Scryer stays out of that territory except for one narrow, deliberately-scoped check
370
- (footnote [h]). Scryer isn't trying to out-analyze either of them at their own specialty. What it
371
- does instead: look at security, performance, dependency, and code-quality findings *together* and
372
- rank the result by severity, so "what should I fix first" has one answer instead of four separate
373
- tool outputs (and four different severity scales) to reconcile by hand — see footnote [j]. If you
374
- already run Brakeman for its deeper security analysis or RuboCop for style, keep doing that; Scryer
375
- sits alongside them and adds the cross-category picture neither one (nor bundler-audit) produces.
376
-
377
- | Capability | Scryer | RuboCop | Brakeman | bundler-audit |
378
- |-------------------------------------------|:------:|:--------:|:--------:|:-------------:|
379
- | Style/lint conventions | Partial [h] | ✅ | ❌ | ❌ |
380
- | Rails security scanning | ✅ [i] | ❌ | ✅ | ❌ |
381
- | Performance heuristics | ✅ | Partial [a] | ❌ | ❌ |
382
- | Duplicate/similar code detection | ✅ | Partial [b] | ❌ | ❌ |
383
- | Dependency vulnerability scanning | ✅ | ❌ | ❌ | ✅ |
384
- | Runtime query analysis (N+1 in production) | ✅ | ❌ | ❌ | ❌ |
385
- | HTML report | ✅ | Partial [c] | ✅ | ❌ |
386
- | JSON report | ✅ | ✅ | ✅ | Limited [d] |
387
- | SARIF report (GitHub Code Scanning, etc.) | ✅ | ❌ | ✅ | ❌ |
388
- | Human-reviewable fix suggestions | ✅ | Partial [e] | Partial [f] | Limited [g] |
389
- | Cross-category risk ranking ("fix this first") | ✅ [j] | ❌ | ❌ | ❌ |
390
- | CWE / OWASP Top 10 category tags | ✅ [k] | ❌ | ✅ [k] | ❌ |
391
- | Confidence level per finding | ✅ | ❌ | ✅ | ❌ |
392
- | Official GitHub Action | ✅ [l] | ❌ | ❌ | ❌ |
393
- | Baseline / new-vs-existing-findings diffing | ✅ [m] | ❌ | Partial [m] | ❌ |
394
- | Overall security score | ✅ | ❌ | ❌ | ❌ |
395
- | Fix verified against an actual re-scan | ✅ [n] | ❌ | ❌ | ❌ |
396
- | Single command covering all of the above | ✅ | ❌ | ❌ | ❌ |
397
-
398
- - **[a]** `rubocop-performance` adds some Ruby/Rails performance cops, but nothing like N+1-query
399
- or missing-pagination detection.
400
- - **[b]** A handful of cops catch exact-duplicate patterns (e.g. `Lint/DuplicateMethods`) — no
401
- near-duplicate/similarity detection across methods.
402
- - **[c]** RuboCop's built-in HTML formatter is a plain findings list, not an interactive report.
403
- - **[d]** bundler-audit's output is primarily console text; no first-class JSON formatter.
404
- - **[e]** RuboCop's `-A` auto-corrects many style violations directly — an automatic rewrite, not
405
- a human-reviewable explanation, and only for auto-correctable cops.
406
- - **[f]** Brakeman's warnings describe the issue and a confidence level, not a concrete
407
- before/after code fix.
408
- - **[g]** bundler-audit names the patched version to upgrade to; no code-level remediation (it
409
- doesn't operate on your code at all, only `Gemfile.lock`).
410
- - **[h]** One check only: a missing `# frozen_string_literal: true` magic comment
411
- (`frozen_string_literal`, info severity). Everything else in RuboCop's style/lint domain —
412
- naming, layout, quote style, line length, and hundreds more — is intentionally out of scope; see
413
- [Skipping rules](#skipping-rules) if you don't want even this one.
414
- - **[i]** Heuristic pattern-matching, not taint/data-flow analysis — Brakeman traces whether user
415
- input can actually *reach* a sink; Scryer checks whether a known-dangerous call shape and a
416
- `params` reference appear in the same expression. That's a real precision gap on the harder
417
- checks especially (`idor` in particular has real false-positive risk — see
418
- [What Scryer detects](#what-scryer-detects)), and it's why every finding says "review this,"
419
- never "this is definitely a bug."
420
- - **[j]** Each tool ranks findings within its own domain at best (e.g. Brakeman's per-warning
421
- confidence level). None of them combine security, performance, dependency, and code-quality
422
- findings into one ranked list — that's a different question than any single tool is built to
423
- answer. `ReportRenderer#top_risks` sorts every severity-bearing finding from a scan (across all
424
- four categories) by severity, shown as "Top priorities" in the console summary and at the top of
425
- the HTML report — see the example near the top of this README.
426
- - **[k]** Not a Scryer-only capability — Brakeman's own warnings already include a CWE reference.
427
- Scryer's version is a fuller mapping (every one of its 31 security rules carries both a CWE ID
428
- and an OWASP Top 10 (2021) category, aggregated into an OWASP coverage scorecard in every
429
- report), but the underlying idea isn't new; see
430
- [What Scryer detects](#what-scryer-detects) for the honesty caveat on how this mapping was built
431
- (Scryer's own best-effort categorization, not OWASP-audited).
432
- - **[l]** [`action.yml`](action.yml) in this repo — `uses: ramlaxmanyadav/scryer@<version>` runs
433
- the scan and uploads SARIF to GitHub Code Scanning in one step, instead of hand-writing the
434
- workflow YAML in [CI/CD integration](#cicd-integration) yourself (that manual version still
435
- works and is documented there too, for anyone who wants full control over the steps).
436
- - **[m]** Brakeman has an ignore file (`brakeman -I`) that permanently silences specific warnings
437
- by fingerprint — genuinely similar in mechanism to [Baseline mode](#baseline-mode)'s
438
- fingerprinting, marked Partial rather than ✅ here because it's a permanent suppression list you
439
- maintain by hand, not an explicit "show me only what's new since this snapshot, and tell me
440
- what got fixed" diff against a saved baseline the way `scryer --baseline` reports both.
441
- - **[n]** `scryer verify --rule RULE_ID --file PATH` re-runs one rule against one file to confirm a
442
- fix actually cleared it, and — when an `ai_client` is configured — the same check runs
443
- automatically on every AI-rewritten `suggested_fix` before the report is written (see
444
- [Verifying a fix](#verifying-a-fix)). RuboCop's `-A` rewrites code directly rather than verifying
445
- a *suggested* fix; Brakeman and bundler-audit don't rewrite or re-check at all.
446
-
447
- Brakeman's security analysis and RuboCop's style checks are still worth running on their own —
448
- Scryer doesn't try to replace either, and running it alongside them costs nothing (different tools,
449
- different config files, no shared state). What Scryer adds is the view none of them produce alone:
450
- one ranked list of what's actually most worth fixing, built from security, performance,
451
- dependency, and code-quality findings together.
452
-
453
- ## Install
454
-
455
- Scryer is a development-time analysis tool, not something a running production process needs —
456
- scope it to the `:development` group so a production deploy (typically `bundle install --without
457
- development`, or `BUNDLE_WITHOUT=development` in CI) never installs it at all:
458
-
459
- ```ruby
460
- # Gemfile
461
- group :development do
462
- gem "scryer"
463
- end
464
- ```
465
-
466
- ```bash
467
- bundle install
468
- bin/rails generate scryer:install
469
- ```
470
-
471
- This drops a commented `config/initializers/scryer.rb` into your app — see
472
- [Generators](#generators) below for exactly what it does and what each setting controls. Scryer
473
- is entirely local: there is nothing to point at a server and nothing to authenticate, so the
474
- generator's only job is letting you override the defaults (project name, which directories get
475
- scanned, the branch label).
476
-
477
- ## Running a scan
478
-
479
- ```bash
480
- bin/rails scryer:report # writes tmp/scryer_report.{json,html}
481
- ```
482
-
483
- By default this writes both `tmp/scryer_report.json` and `tmp/scryer_report.html`, and includes a
484
- dependency audit against `Gemfile.lock` (needs network — see [Dependency audit](#dependency-audit);
485
- pass the `nodeps` arg for a fast, fully offline run instead). Format and output path are
486
- configurable via task args — any mix of `json`, `html`, `csv`, `sarif` plus at most one path (quote
487
- the whole thing so your shell doesn't eat the brackets/commas, e.g.
488
- `bin/rails 'scryer:report[json,doc/security_report.json]'`). Rake splits bracket args on every
489
- comma, so each format is its own item in the list rather than one comma-joined string
490
- (`scryer:report[json,html]` is two args, not `"json,html"` as one). At most one non-format,
491
- non-`nodeps` token is accepted per call; giving two raises an error rather than guessing which one
492
- you meant.
493
-
494
- Outside a Rails app (or in CI, or anywhere you don't want a rake task), the gem also ships a
495
- `scryer` executable — same idea as `brakeman -o report.json`, with `-o` repeatable and format
496
- inferred per-file from its extension:
497
-
498
- ```bash
499
- scryer # scans ., writes tmp/scryer_report.{json,html}
500
- scryer -o report.json # just one file, exact path, format from extension
501
- scryer -o report.json -o report.html # as many outputs as you like, one -o each
502
- scryer -p /path/to/app -o /tmp/out.html # -p sets the root to scan (default: cwd)
503
- scryer --no-deps # skip the dependency audit for a fast offline run
504
- scryer --help # full option list (--project-name, --branch, --version, ...)
505
- ```
506
-
507
- Console output is a summary box across every category — see the box in the intro above for a real
508
- example — followed by where each report was written.
509
-
510
- It exits `0` when the scan is clean and `1` when there's at least one security or dependency
511
- finding, so `scryer -o report.json` can gate a CI job the same way `brakeman -o report.json` does.
512
- A `2` exit means a usage error (bad flag, unrecognized output extension) rather than anything
513
- about the scan itself.
514
-
515
- Or use the scanning engine directly as a library:
516
-
517
- ```ruby
518
- require "scryer"
519
- result = Scryer::Scanner.new(root: "/path/to/app").call
520
- puts result.security_findings.size
521
- ```
522
-
523
- Open `tmp/scryer_report.html` in a browser — it's a single self-contained file (inline CSS, no
524
- external assets, works offline) with an overview, a summary of counts by severity, the full list
525
- of checks that ran, a breakdown of warnings by type, every finding in detail, and duplicate-code
526
- groups — laid out similarly to a Brakeman report. `tmp/scryer_report.json` has the same data in
527
- machine-readable form, for feeding into your own dashboard or CI gate. `.csv`
528
- (`scryer -o report.csv` / `rails 'scryer:report[csv]'`) is a flat one-row-per-finding table
529
- (security + performance findings, plus dependency findings unless `--no-deps`/`nodeps` was used) —
530
- handy for dropping into a spreadsheet or importing into a ticketing tool; it skips duplicate-code
531
- groups, which don't reduce to a single actionable row. `.sarif` (`scryer -o report.sarif` /
532
- `rails 'scryer:report[sarif]'`) is for feeding into a CI security dashboard instead — see
533
- [CI/CD integration](#cicd-integration).
534
-
535
- ## CI/CD integration
536
-
537
- `scryer -o report.sarif` writes a [SARIF 2.1.0](https://docs.oasis-open.org/sarif/sarif/v2.1.0/)
538
- report — the format GitHub Code Scanning, Azure DevOps, and most CI security dashboards ingest
539
- natively, turning findings into inline pull-request annotations and Security-tab entries instead
540
- of a report file nobody opens. It's a pure mapping of the same data every other format has —
541
- security, performance, and style findings (each mapped from `Scryer::Rule.rule_id`/`severity`) plus
542
- dependency findings (mapped from `kind`, located at `Gemfile.lock`) — so nothing behaves any
543
- differently than it does in HTML/JSON/CSV.
544
-
545
- ### Option A: the bundled `action.yml`
546
-
547
- [`action.yml`](action.yml) in this repo wraps the whole thing — install, scan, SARIF upload, and
548
- correctly ordering all three so the upload still happens even when findings were reported (see the
549
- `continue-on-error` explanation below for why that ordering matters):
550
-
551
- ```yaml
552
- # .github/workflows/scryer.yml
553
- name: Scryer
554
- on: [push, pull_request]
555
-
556
- jobs:
557
- scryer:
558
- runs-on: ubuntu-latest
559
- permissions:
560
- contents: read
561
- security-events: write # required to upload SARIF
562
- steps:
563
- - uses: actions/checkout@v4
564
- - uses: ramlaxmanyadav/scryer@v1.1.0
565
- with:
566
- ruby-version: "3.3"
567
- ```
568
-
569
- All inputs are optional — `path` (default `.`), `scryer-version` (default: latest), `extra-args`
570
- (e.g. `--skip idor --no-deps`), `fail-on-findings` (default `true`), `upload-sarif` (default
571
- `true`). Outputs: `sarif-path`, `json-path`, `exit-code`. See [action.yml](action.yml) for the full
572
- input/output reference.
573
-
574
- `@v1.1.0` above needs a matching git tag pushed to GitHub before `uses:` can resolve it — replace
575
- it with whatever version tag you actually push (or `@main` to always track the latest commit,
576
- accepting that it can change under you).
577
-
578
- ### Option B: hand-written steps
579
-
580
- For full control over the individual steps (a different Ruby setup, extra caching, a different
581
- job structure):
582
-
583
- ```yaml
584
- # .github/workflows/scryer.yml
585
- name: Scryer
586
- on: [push, pull_request]
587
-
588
- jobs:
589
- scryer:
590
- runs-on: ubuntu-latest
591
- permissions:
592
- contents: read
593
- security-events: write # required to upload SARIF
594
- steps:
595
- - uses: actions/checkout@v4
596
- - uses: ruby/setup-ruby@v1
597
- with:
598
- ruby-version: "3.3"
599
- - run: gem install scryer
600
- - run: scryer -o scryer.sarif
601
- continue-on-error: true # let the upload step run even on a non-zero exit; see below
602
- - uses: github/codeql-action/upload-sarif@v3
603
- with:
604
- sarif_file: scryer.sarif
605
- ```
606
-
607
- `continue-on-error: true` on the scan step matters: `scryer` exits non-zero when it finds anything
608
- (see [Running a scan](#running-a-scan)), which would otherwise skip the upload step entirely on
609
- the run where you most want to see the results. If you want the job to still fail the build overall
610
- on findings, check `scryer`'s own exit code in a separate step, or drop `continue-on-error` and
611
- accept that the SARIF upload only happens on clean runs. `action.yml` (Option A) handles this
612
- ordering for you automatically.
613
-
614
- ### Rails apps: `bin/rails scryer:ci`
615
-
616
- Inside a Rails app (rather than the standalone `scryer` executable above), `scryer:ci` is a
617
- memorable shortcut for the same CI-sensible defaults — JSON + SARIF under `tmp/`, dependency audit
618
- on, same `SCRYER_BASELINE` support and same fail-the-build-on-findings behavior as `scryer:report`
619
- — without needing to remember the bracket-arg syntax:
620
-
621
- ```bash
622
- bin/rails scryer:ci # same as bin/rails 'scryer:report[json,sarif]'
623
- ```
624
-
625
- ## Verifying a fix
626
-
627
- Two ways to confirm a specific finding is actually resolved, without waiting on (or paying for) a
628
- full rescan:
629
-
630
- **`scryer verify`** re-parses one file and re-runs one rule against it — meant to run right after
631
- applying a fix by hand (or reviewing an AI-suggested one):
632
-
633
- ```bash
634
- scryer verify --rule sql_injection --file app/models/user.rb
635
- # scryer verify: sql_injection no longer fires on app/models/user.rb — fix verified. (exit 0)
636
- # or: scryer verify: sql_injection still fires on app/models/user.rb (1 finding(s)): (exit 1)
637
- # line 12: `where` is called with a string built via interpolation, ...
638
- ```
639
-
640
- `--path ROOT` sets the project root `--file` is resolved against (default: current directory);
641
- `--list-rules` prints every known `rule_id`. Deliberately narrow: this only answers "does the one
642
- thing I targeted still fire," not "did this change introduce a different finding elsewhere" — a
643
- normal `scryer` run (or `--baseline`) already answers that.
644
-
645
- **AI-verified remediation** does the same check automatically when an `ai_client` is configured
646
- (see [AI-assisted fix suggestions](#ai-assisted-fix-suggestions)) — every AI-rewritten
647
- `suggested_fix` is re-scanned against an in-memory copy of the real file before the report is
648
- written, and each finding gets a `fix_verified: true/false/nil` field (`true`: the rule no longer
649
- fires with this fix applied; `false`: it was checked and still fires, or the rewritten line doesn't
650
- even parse; `nil`: not attempted at all — no `ai_client` configured, or the AI's reply didn't
651
- include a usable code block). The HTML report shows this as a green "AI fix verified" or red "AI
652
- fix NOT verified" line under each finding's suggested fix. Nothing is ever written back to the real
653
- file for this check — same "never auto-applied" rule as `suggested_fix` itself; this only confirms
654
- whether the *suggestion* would work if you applied it.
655
-
656
- ## What gets scanned
657
-
658
- By default: `app/`, `lib/`, `config/`, `db/` (relative to the app root) — configurable via
659
- `c.dirs` in the initializer. `vendor/`, `node_modules/`, `tmp/`, `log/`, `.git/`, `spec/`, `test/`
660
- are always skipped.
661
-
662
- ## Branch reporting
663
-
664
- By default the `git_branch` recorded in the report comes from `git rev-parse --abbrev-ref HEAD` in
665
- the app root. Set `c.branch` in the initializer to override this — useful in CI, where checkouts
666
- often land in a detached-HEAD state and there's no real branch name to detect:
667
-
668
- ```ruby
669
- Scryer.configure do |c|
670
- c.branch = ENV["CI_COMMIT_BRANCH"]
671
- end
672
- ```
673
-
674
- This only changes the label shown in the report; it does not check out a different branch or
675
- restrict which branch can be scanned.
676
-
677
- ## Skipping rules
678
-
679
- Every rule is heuristic, so an occasional false positive on your specific codebase is expected
680
- (see "A note on how this gem was actually verified" above) — `skip_rules` silences one by
681
- `rule_id` without editing or deleting the rule itself, so it still runs (and could still catch a
682
- real issue) on every other codebase that uses this gem:
683
-
684
- ```ruby
685
- # config/initializers/scryer.rb
686
- Scryer.configure do |c|
687
- c.skip_rules = ["mass_assignment"] # rule_id, from the finding's `rule_id` field
688
- end
689
- ```
690
-
691
- The `scryer` executable's `--skip RULE_ID` flag (repeatable) adds to this list for a single run
692
- without touching the initializer — useful for a one-off "does this go away without rule X" check:
693
-
694
- ```bash
695
- scryer --skip mass_assignment --skip weak_crypto
696
- ```
697
-
698
- ## Baseline mode
699
-
700
- `--skip` silences a rule everywhere, permanently. Baseline mode is for the more common real-world
701
- situation: an existing app has genuine pre-existing findings you're not going to fix today, but you
702
- still want CI to fail on anything *new* from here on:
703
-
704
- ```bash
705
- # Once: snapshot the current state
706
- scryer --save-baseline tmp/scryer_baseline.json
707
-
708
- # Every run after that: only new findings show up, count toward the exit code,
709
- # and appear in -o reports — everything already in the baseline is suppressed
710
- scryer --baseline tmp/scryer_baseline.json -o scryer.sarif
711
- ```
712
-
713
- ```
714
- Baseline: tmp/scryer_baseline.json — showing new findings only (2 fixed since baseline).
715
-
716
- Security Score: 96/100 (A)
717
-
718
- Security 1 finding
719
- ...
720
- ```
721
-
722
- Findings are matched by a **fingerprint** (`rule_id` + file + the offending source text), not
723
- file:line — an unrelated edit earlier in the same file shifting every following line number
724
- wouldn't otherwise make an existing finding look simultaneously "new" and "fixed" on every commit.
725
- "Fixed since baseline" counts findings that were in the baseline but aren't in this scan anymore
726
- (across every category — security, performance, style, dependencies); duplicate-code groups aren't
727
- part of baseline mode (they don't fit the same fingerprint shape) and always show in full.
728
-
729
- Rails/rake equivalents: `rails 'scryer:save_baseline[tmp/scryer_baseline.json]'` to save, and
730
- `SCRYER_BASELINE=tmp/scryer_baseline.json rails scryer:report` to compare (an env var, since rake's
731
- bracket-arg parsing is already stretched thin between format tokens, `nodeps`, and an output path).
732
-
733
- ## Runtime query watcher
734
-
735
- Everything above is a one-shot static scan — it reads source, never boots Rails, and can't see
736
- what actually happens at request time. `Scryer::QueryWatcher` is different: it's an opt-in
737
- runtime instrumentation that watches a *running* app for two of the problems
738
- [Bullet](https://github.com/flyerhzm/bullet) is best known for catching — a collection query
739
- followed by one repeat query per row ("N+1"), and an `.includes`/`.preload`/`.eager_load`
740
- association that gets fetched but never actually read ("unused eager loading"). It was built
741
- independently of Bullet, on a different mechanism (SQL-shape correlation via
742
- `ActiveSupport::Notifications`, plus a `Module#prepend` on `ActiveRecord::QueryMethods`'s eager-load
743
- methods and `Association#reader` — not Bullet's own per-request association bookkeeping); no
744
- Bullet source was read or copied to build it.
745
-
746
- Enable it from an initializer (typically gated to development/test, though nothing stops you from
747
- running it in production if you want the noise):
748
-
749
- ```ruby
750
- # config/initializers/scryer.rb
751
- if Rails.env.development? || Rails.env.test?
752
- require "scryer/query_watcher"
753
- Scryer::QueryWatcher.enable!
754
- Rails.application.config.middleware.use Scryer::QueryWatcher::Middleware
755
- end
756
- ```
757
-
758
- The middleware opens one tracking scope per request and logs (via `Rails.logger` by default —
759
- override with `Scryer::QueryWatcher.enable!(logger: ...)`) anything it finds when the request
760
- ends. Outside a request — a Sidekiq job, a rake task, a console session — wrap the code yourself:
761
-
762
- ```ruby
763
- findings = Scryer::QueryWatcher.watch { SomeJob.new.perform }
764
- ```
765
-
766
- `enable!(n_plus_one_threshold: 2)` controls how many repeats of the same query shape from the same
767
- call site count as N+1 (default: the second occurrence already means one collection load produced
768
- more than one query). Each finding is a `Scryer::QueryWatcher::Finding` with `kind`
769
- (`n_plus_one_query_runtime` or `unused_eager_load`), `message`, `call_site`, `count`, and
770
- `suggested_fix` — the same shape as everything else in this gem, so it's straightforward to feed
771
- into your own logging/alerting instead of (or alongside) the built-in logger call.
772
-
773
- This is genuinely runtime-only: with no queries running, it finds nothing, and it never appears in
774
- the static `tmp/scryer_report.html` output. Think of it as this gem's answer to "what actually
775
- happened during this request", where the rest of Scryer answers "what does this code look like".
776
-
777
- ## Runtime authorization watcher
778
-
779
- The static `idor`/`missing_authorization`/`missing_policy_scope` rules can only ever say "no call
780
- to a known authorization method is visible anywhere in this controller's source" — which is exactly
781
- as wrong as it sounds whenever the real check happens somewhere the static AST walk can't see (a
782
- shared base controller, a concern, a class-level macro). `Scryer::AuthorizationWatcher` answers a
783
- narrower but far more reliable question instead: for *this actual request*, did Pundit's
784
- `authorize`/`policy_scope` or CanCanCan's `authorize!` genuinely get called? Both libraries already
785
- track this themselves internally (for their own `verify_authorized`/`check_authorization` helpers)
786
- — this watcher registers one more `after_action` alongside those and checks the same flags
787
- (`Pundit::Authorization#pundit_policy_authorized?`/`#pundit_policy_scoped?`, CanCanCan's
788
- `@_authorized` ivar — verified by reading both gems' actual source, not guessed).
789
-
790
- ```ruby
791
- # config/initializers/scryer.rb
792
- require "scryer/authorization_watcher"
793
- Scryer::AuthorizationWatcher.enable!
794
- ```
795
-
796
- No middleware to install and no per-request scope to open (unlike `QueryWatcher` above) — a Rails
797
- controller instance is already fresh per request, so the `after_action` above is all `enable!`
798
- needs. It flags a `create`/`update`/`destroy` action (or any `POST`/`PUT`/`PATCH`/`DELETE` request)
799
- that completed successfully (status < 400) with neither flag set:
800
-
801
- ```ruby
802
- Scryer::AuthorizationWatcher.findings
803
- # => [#<struct Scryer::AuthorizationWatcher::Finding kind="runtime_missing_authorization",
804
- # message="WidgetsController#update completed a PATCH request (status 200) with no
805
- # authorization check actually invoked during it ...", controller="WidgetsController",
806
- # action="update", method="PATCH", path="/widgets/1", suggested_fix="...">]
807
- ```
808
-
809
- Two honesty points worth being precise about:
810
-
811
- - **Pundit/CanCanCan-only, same scope as the static rules it complements.** With neither gem
812
- loaded, `enable!` still runs, but every request is silently skipped — an app with fully custom,
813
- non-object-level authorization (a single `before_action :require_admin!`) gets no findings and no
814
- false-positive flood, rather than being flagged for a pattern this watcher has no way to
815
- recognize as intentional.
816
- - **Write actions only.** Read-scoping gaps (an unscoped `index` — see the static
817
- `missing_policy_scope` rule) aren't covered here: verifying "was the returned data correctly
818
- scoped" at runtime, rather than "was a method called," is a materially different and harder check
819
- this class doesn't attempt.
820
-
821
- ## Dependency audit
822
-
823
- `Scryer::DependencyAudit` checks `Gemfile.lock` for the same broad concerns
824
- [bundler-audit](https://github.com/rubysec/bundler-audit) targets — known-vulnerable gem versions
825
- and insecure dependency sources — built independently on a different data source and a different
826
- lockfile parser: rather than bundler-audit's local clone of the `ruby-advisory-db` git repo, this
827
- queries [OSV.dev](https://osv.dev) (Google's Open Source Vulnerabilities database) live, one
828
- lookup per gem+version, over a small stdlib-only thread pool; `Gemfile.lock` is read with a small
829
- hand-rolled parser (see `DependencyAudit.parse_lockfile`) instead of depending on the `bundler`
830
- library, so it works the same whether or not this happens to run under Bundler. No bundler-audit
831
- source was read or copied to build this.
832
-
833
- ```bash
834
- bin/rails scryer:audit_dependencies # inside a Rails app — dependency audit only, no static scan
835
- ```
836
-
837
- This is the one part of Scryer that needs a live network connection (to reach OSV.dev), and it
838
- runs automatically as part of every `scryer`/`scryer:report` scan — pass `--no-deps` (CLI) or the
839
- `nodeps` arg (rake) for a fast, fully offline run instead. `scryer:audit_dependencies` above (and
840
- `scryer --audit-deps` below) are for when you want *only* the dependency audit, the same way
841
- `bundle-audit check` is a separate, standalone command from your test suite. Either way it exits
842
- non-zero if anything is found, so it can gate CI.
843
-
844
- Three checks run, and any of them can be called on its own as a library:
845
-
846
- ```ruby
847
- Scryer::DependencyAudit.insecure_sources(Rails.root.to_s) # offline — no network needed
848
- Scryer::DependencyAudit.vulnerable_gems(Rails.root.to_s) # needs network (OSV.dev)
849
- Scryer::DependencyAudit.ruby_eol_check(Rails.root.to_s) # offline — checked against a small embedded table
850
- ```
851
-
852
- - **Insecure sources**: flags any `GIT`/`PATH` block in `Gemfile.lock` whose `remote:` is
853
- unencrypted (`git://` or plain `http://`) — the same supply-chain concern bundler-audit's
854
- insecure-source check targets.
855
- - **Vulnerable gems**: for every RubyGems-sourced gem (git/path-sourced gems are skipped — their
856
- version string doesn't necessarily correspond to the same code as the published gem of that
857
- name, so checking them against RubyGems advisories could misattribute or miss vulnerabilities),
858
- queries OSV.dev for known vulnerabilities affecting that exact version. Each finding carries the
859
- advisory id, title, a severity bucketed from OSV's own severity level, a link to the advisory,
860
- and the fixed version(s) to upgrade to. This is also where Rails-framework CVEs surface —
861
- `rails`/`actionview`/`activestorage`/etc. are just gems in `Gemfile.lock` like any other.
862
- - **Ruby EOL**: checks the Ruby version pinned in `Gemfile.lock`'s `RUBY VERSION` section against
863
- Ruby's own [published end-of-life dates](https://www.ruby-lang.org/en/downloads/branches/) — once
864
- a series is EOL, no security patches are published for it at all, for any issue, regardless of
865
- how up to date every gem is. Unlike the two checks above, this doesn't call OSV.dev: EOL dates
866
- are announced years in advance and essentially never change, so a small embedded table
867
- (`DependencyAudit::RUBY_EOL_DATES`) is a one-time/occasional-update cost rather than a live feed
868
- — deliberately not a general Ruby-interpreter CVE database, which would mean maintaining exactly
869
- the kind of stale bundled knowledge base this gem avoids elsewhere (OSV.dev has no queryable
870
- Ruby-interpreter ecosystem to query live instead).
871
-
872
- **From the `scryer` executable** (outside a Rails app, or in CI): `scryer --audit-deps` runs the
873
- same three checks standalone (no static scan), same output/exit-code behavior as the rake task above.
874
- Plain `scryer` already folds them into the normal `-o` report — one HTML/JSON/CSV file covering
875
- static findings *and* dependency findings together — so `--audit-deps` is only for when you want
876
- dependency findings *without* the static scan. And for a single gem, without touching
877
- `Gemfile.lock` at all:
878
-
879
- ```bash
880
- scryer --check-gem rack # every advisory ever filed against rack, any version
881
- scryer --check-gem rack:2.0.8 # only advisories affecting exactly this version
882
- ```
883
-
884
- This is the same OSV.dev client `vulnerable_gems` uses, exposed as a one-off lookup — useful for
885
- "is this gem I'm about to add actually fine" before it's even in your `Gemfile.lock`.
886
-
887
- ## AI-assisted fix suggestions
888
-
889
- Every rule already ships a generic, human-reviewable `suggested_fix` — that's always there and
890
- needs nothing configured. `Scryer::AiFixSuggester` optionally rewrites that text per finding using
891
- an LLM, so the suggestion is written against the finding's actual offending line instead of a
892
- generic template. This is entirely opt-in: with no client configured, `AiFixSuggester` makes zero
893
- network calls and every finding keeps its original `suggested_fix` — nothing below is required to
894
- use the rest of Scryer.
895
-
896
- ### Step by step
897
-
898
- **Inside a Rails app** — `config/initializers/scryer.rb` is autoloaded at boot, so setting
899
- `c.ai_client` there is picked up automatically the next time you scan:
900
-
901
- ```ruby
902
- # config/initializers/scryer.rb
903
- Scryer.configure do |c|
904
- c.ai_client = ->(prompt) { MyLlmClient.chat(prompt) } # any callable — see below for real examples
905
- end
906
- ```
907
-
908
- ```bash
909
- bin/rails scryer:report
910
- ```
911
-
912
- That's the whole flow — no extra flag, no second command. Look for `Scryer: rewriting suggested
913
- fixes via the configured AI client...` in the task's own output, then open the report: every
914
- finding's `suggested_fix` is now the LLM's rewrite instead of the generic template.
915
-
916
- **Outside Rails (the `scryer` executable)** — there's no `config/initializers/` to autoload here,
917
- so `Scryer.configure` needs to actually run before the scan starts. That's what `-r`/`--require` is
918
- for: point it at a small Ruby file that calls `Scryer.configure`, and `scryer` requires it first:
919
-
920
- ```ruby
921
- # scryer_config.rb — anywhere in your project, any filename
922
- Scryer.configure do |c|
923
- c.ai_client = ->(prompt) { MyLlmClient.chat(prompt) }
924
- end
925
- ```
926
-
927
- ```bash
928
- scryer -r ./scryer_config.rb
929
- ```
930
-
931
- Same output, same "rewriting suggested fixes..." line, same result — `-r` is the only difference
932
- between the two paths, and it's required precisely because the standalone executable has nothing
933
- else to make `Scryer.configure` code actually run before it scans.
934
-
935
- **Provider-agnostic by design — bring any LLM.** Scryer doesn't depend on or assume any specific
936
- vendor's API or SDK (consistent with the zero-runtime-dependency design described in the gemspec).
937
- `c.ai_client` accepts any object, or even a bare `Proc`/lambda, that responds to `#call(prompt)`
938
- (or `#complete(prompt)`) and returns the model's reply as a `String` — the two examples above used
939
- a placeholder; `Scryer::AiClient` below is a ready-made adapter for a real HTTP endpoint.
940
-
941
- ### `Scryer::AiClient` — a ready-made HTTP adapter
942
-
943
- For the common case of a JSON/HTTP chat endpoint, `Scryer::AiClient` saves writing the request
944
- plumbing by hand. It takes the two pieces of vendor-specific shape as plain `Proc`s and handles the
945
- HTTP call itself (stdlib `Net::HTTP`, no gem):
946
-
947
- ```ruby
948
- # Claude (Messages API)
949
- Scryer.configure do |c|
950
- c.ai_client = Scryer::AiClient.new(
951
- url: "https://api.anthropic.com/v1/messages",
952
- headers: { "x-api-key" => ENV.fetch("ANTHROPIC_API_KEY"), "anthropic-version" => "2023-06-01" },
953
- build_request: ->(prompt) { { model: "claude-opus-5", max_tokens: 1024, messages: [{ role: "user", content: prompt }] } },
954
- parse_response: ->(json) { json.dig("content", 0, "text") }
955
- )
956
- end
957
- ```
958
-
959
- ```ruby
960
- # Any OpenAI-compatible chat completions endpoint (OpenAI itself, a local
961
- # Ollama/vLLM server, Azure OpenAI, ...) — same adapter, different shape.
962
- Scryer.configure do |c|
963
- c.ai_client = Scryer::AiClient.new(
964
- url: "https://api.openai.com/v1/chat/completions",
965
- headers: { "Authorization" => "Bearer #{ENV.fetch('OPENAI_API_KEY')}" },
966
- build_request: ->(prompt) { { model: "gpt-4o-mini", messages: [{ role: "user", content: prompt }] } },
967
- parse_response: ->(json) { json.dig("choices", 0, "message", "content") }
968
- )
969
- end
970
- ```
971
-
972
- Neither example pins Scryer to that vendor — `build_request`/`parse_response` are just data telling
973
- `AiClient` how to shape one HTTP call; point it at any endpoint that takes a JSON body and returns
974
- a JSON body.
975
-
976
- ### What happens with it configured
977
-
978
- `bin/rails scryer:report` and the `scryer` executable both check `Scryer.configuration.ai_client`
979
- after scanning and, if set, call `Scryer::AiFixSuggester.enhance_result!(result)` before rendering
980
- — one LLM call per security/performance/style finding (plus dependency findings too, if the
981
- dependency audit ran), run across a small thread pool (same pattern as the dependency audit's
982
- OSV.dev lookups) rather than one at a time. A client that raises, times out, or returns nothing
983
- usable just leaves that finding's original `suggested_fix` in place — a failed enrichment never
984
- fails the scan.
985
-
986
- ```ruby
987
- Scryer::AiFixSuggester.enhance!(finding) # one Finding, in place
988
- Scryer::AiFixSuggester.enhance_result!(result) # every finding on a Scanner::Result, in place
989
- ```
990
-
991
- Both `bin/rails scryer:report` and the `scryer` executable also pass `root:` so each rewritten fix
992
- gets verified against a re-scan automatically (`finding.fix_verified` — see
993
- [Verifying a fix](#verifying-a-fix) for what that means and how to trigger it manually with
994
- `scryer verify`); calling `enhance!`/`enhance_result!` directly as shown above skips verification
995
- unless you pass `root:` yourself.
996
-
997
- **This sends code snippets to whatever endpoint you configure.** `code_snippet`, `message`, and the
998
- file path are included in the prompt — the same privacy consideration as any third-party service:
999
- don't point this at an endpoint you don't trust with your source, and be mindful this is a second
1000
- place (besides the HTML report itself) where finding detail leaves your machine.
1001
-
1002
- ## Generators
1003
-
1004
- Scryer ships one generator: `scryer:install`. Run `bin/rails generate scryer:install --help`
1005
- in a host app for the full description (also in
1006
- [lib/generators/scryer/USAGE](lib/generators/scryer/USAGE)); short version:
1007
-
1008
- - **What it creates**: a single file, `config/initializers/scryer.rb`, templated from
1009
- [lib/generators/scryer/templates/scryer_initializer.rb](lib/generators/scryer/templates/scryer_initializer.rb).
1010
- Nothing else — no routes, no migrations, no controllers/views.
1011
- - **What it doesn't do**: the rake tasks (`scryer:report`) are registered by
1012
- [Scryer::Railtie](lib/scryer/railtie.rb) as soon as the gem is in your `Gemfile`, whether or
1013
- not you ever run this generator. The generator exists purely to give you an editable config file.
1014
- - **Settings it exposes**: `c.project_name` (report header label), `c.dirs` (which top-level
1015
- directories get scanned), `c.branch` (override the git branch label — see
1016
- [Branch reporting](#branch-reporting)), `c.skip_rules` (silence specific rule_ids — see
1017
- [Skipping rules](#skipping-rules)), `c.ai_client` (see
1018
- [AI-assisted fix suggestions](#ai-assisted-fix-suggestions)). All are optional; the
1019
- commented-out initializer works as-is with just `bundle install` + the generator.
1020
- - **Re-running it**: standard Thor/Rails::Generators behavior — if
1021
- `config/initializers/scryer.rb` already exists, you'll be prompted to overwrite, skip, or diff
1022
- rather than have it silently clobbered.
1023
-
1024
- ## Testing Scryer results in your own test suite
1025
-
1026
- Two opt-in files turn "this app's own scan stays clean" into a normal test-suite assertion, so a
1027
- regression fails the suite the same way any other regression would, instead of only showing up the
1028
- next time someone runs `scryer` by hand. Neither loads with the gem automatically (RSpec/Minitest
1029
- are never Scryer runtime dependencies — see the gemspec) — require the one matching your test
1030
- framework yourself:
1031
-
1032
- ```ruby
1033
- # spec/spec_helper.rb (RSpec)
1034
- require "scryer/rspec"
1035
-
1036
- RSpec.describe "security" do
1037
- it "has no critical findings" do
1038
- expect(Scryer.scan(root: Rails.root.to_s)).to have_no_critical_findings
1039
- end
1040
-
1041
- it "never reintroduces the mass-assignment bug fixed in PR #123" do
1042
- expect(Scryer.scan(root: Rails.root.to_s)).to have_no_findings_for("mass_assignment")
1043
- end
1044
- end
1045
- ```
1046
-
1047
- ```ruby
1048
- # test/test_helper.rb (Minitest / ActiveSupport::TestCase)
1049
- require "scryer/minitest"
1050
-
1051
- class SecurityTest < ActiveSupport::TestCase
1052
- include Scryer::MinitestAssertions
1053
-
1054
- test "no critical findings" do
1055
- assert_no_critical_scryer_findings(Scryer.scan(root: Rails.root.to_s))
1056
- end
1057
- end
1058
- ```
1059
-
1060
- `Scryer.scan(root:)` runs the same static scan `scryer:report`/the `scryer` executable do (using
1061
- `c.dirs`/`c.skip_rules` from your initializer), without needing a report written to disk —
1062
- `have_no_critical_findings`/`assert_no_critical_scryer_findings` only look at *security* findings
1063
- (same scoping as the [security score](#security-score)); `have_no_findings_for`/
1064
- `assert_no_scryer_findings_for` check a specific `rule_id` across all three static categories,
1065
- for pinning a specific bug so it can't come back unnoticed. This talks to the live filesystem on
1066
- every test run (a real `Ripper`-based scan, same cost as running `scryer` itself) — for a large app
1067
- this is meaningfully slower than a typical unit test, so it's usually one dedicated test/spec file
1068
- run occasionally (a nightly job, a pre-release check) rather than part of every `rspec`/`rails test`
1069
- invocation.
1070
-
1071
- ## Extending it
1072
-
1073
- Every rule is a small class extending `Scryer::Rule` — see `lib/scryer/rules/*.rb` (security),
1074
- `lib/scryer/performance_rules/*.rb` (performance), and `lib/scryer/style_rules/*.rb` (style) for
1075
- the pattern. A new rule file dropped into any of the three directories is picked up automatically
1076
- (rules self-register via `Rule.inherited` — no manual wiring needed, just `self.category =
1077
- "security"|"performance"|"style"`). `Scryer::Ast` has the tree-walking helpers used throughout.
1078
-
1079
- Scryer's own rules have their own Minitest suite — `rake test` (from the repo root, not inside a
1080
- host app) runs a bad/clean fixture pair against every registered rule (`test/rule_fixtures_test.rb`),
1081
- plus a completeness check that fails loudly if a new rule ships with no fixture. Add an entry to
1082
- `RuleFixturesTest::FIXTURES` for any new or changed rule — and, for a genuine false positive/
1083
- negative found against real code (not just the regression pair), add it to
1084
- [`benchmark/corpus.rb`](benchmark/corpus.rb) too (see [Accuracy benchmark](#accuracy-benchmark))
1085
- with a `note:` explaining the real-world shape it represents, so it's measured going forward
1086
- instead of just fixed once and forgotten.
70
+ ## Frequently asked questions
71
+
72
+ - **What does Scryer do?** Static security auditing and code analysis for Ruby/Rails codebases —
73
+ security vulnerabilities, performance heuristics, dependency risk, and code-quality issues in one
74
+ scan, ranked by severity across all four categories. See
75
+ [What Scryer detects](docs/rules.md#what-scryer-detects).
76
+ - **Is it Rails-specific?** Most of the 31 security rules target Rails conventions specifically
77
+ (controllers, `config/environments/*`, Active Storage, Action Cable, `params`), but the scanning
78
+ engine itself only needs Ruby source — no Rails app or database required to run it. See
79
+ [Designed for Ruby](docs/rules.md#designed-for-ruby).
80
+ - **What vulnerabilities does it detect?** SQL injection, mass assignment, SSRF, path traversal,
81
+ IDOR, missing authorization, insecure JWT/CORS/session/cookie config, hardcoded secrets, XSS,
82
+ weak crypto, unsafe deserialization, open redirects, and more — full list in
83
+ [What Scryer detects](docs/rules.md#what-scryer-detects). Every finding carries a CWE ID and an OWASP Top 10
84
+ (2021) category.
85
+ - **How is it different from Brakeman?** Brakeman does real taint/data-flow analysis for Rails
86
+ security specifically years of maturity Scryer doesn't try to match. Scryer's checks are
87
+ heuristic pattern-matching, and its job is different: combining security, performance,
88
+ dependency, and code-quality findings into one severity-ranked list, which none of those tools do
89
+ on their own. See [Taint analysis vs. heuristic pattern
90
+ matching](https://ramlaxmanyadav.github.io/scryer/taint-analysis-vs-heuristic-pattern-matching.html)
91
+ and the [full comparison table](docs/architecture.md#scryer-vs-rubocop-vs-brakeman-vs-bundler-audit).
92
+ - **Is it actively maintained?** See [CHANGELOG.md](CHANGELOG.md) for release history and
93
+ [lib/scryer/version.rb](lib/scryer/version.rb) for the current version.
94
+ - **Does it work in CI?** Yes — a bundled [GitHub Action](action.yml), SARIF output for GitHub Code
95
+ Scanning, and a documented exit-code contract for gating any CI system. See
96
+ [CI/CD integration](docs/usage.md#cicd-integration) and [Exit codes](docs/usage.md#exit-codes).
97
+ - **What's the license?** MIT. See [License](#license).
98
+ - **How do I install it?** `gem install scryer`, or add `gem "scryer", group: :development` to your
99
+ `Gemfile`. See [Install](docs/usage.md#install).
100
+
101
+
102
+ ## Documentation
103
+
104
+ This README covers the pitch, the FAQ, and licensing. Everything else lives in focused pages —
105
+ each one longer-form than a single section here, cross-linked from wherever it's relevant:
106
+
107
+ - [**Usage & Configuration**](docs/usage.md) Install, running a scan, exit codes, CI/CD
108
+ integration (GitHub Action + hand-written steps), what gets scanned, branch reporting, skipping
109
+ rules, baseline mode.
110
+ - [**What Scryer Detects**](docs/rules.md) the full rule list (security/performance/code
111
+ quality/dependencies), example findings, report formats with real JSON/SARIF output, the
112
+ dependency audit.
113
+ - [**Fix Mode & AI-Assisted Fixes**](docs/fix-mode.md) — `scryer verify`, `scryer fix`
114
+ (mechanical, no-AI fixes plus the interactive accept/skip review), and optional AI-rewritten
115
+ suggestions with any LLM you configure.
116
+ - [**Architecture, Performance & Security Model**](docs/architecture.md) how it's built, real
117
+ performance numbers, the security model, false-positive handling, the accuracy benchmark, and
118
+ the full Scryer vs RuboCop vs Brakeman vs bundler-audit comparison.
119
+ - [**Rails Integration**](docs/rails-integration.md) the runtime query/authorization watchers,
120
+ the `scryer:install` generator, and testing Scryer's own results in your app's test suite.
121
+ - [**Contributing & Releasing**](docs/contributing.md) how to add a new rule, and how this
122
+ gem's own release process works.
123
+
124
+ Longer-form guides on the [docs site](https://ramlaxmanyadav.github.io/scryer/), each answering
125
+ one question in depth for search/AI-assistant discovery rather than restating the above:
126
+
127
+ - [What is a Rails security scanner?](https://ramlaxmanyadav.github.io/scryer/rails-security-scanner.html)
128
+ - [Taint analysis vs. heuristic pattern matching](https://ramlaxmanyadav.github.io/scryer/taint-analysis-vs-heuristic-pattern-matching.html)
129
+ - [Dependency security for Rails applications](https://ramlaxmanyadav.github.io/scryer/dependency-security.html)
130
+ - [Security code review checklist for Rails](https://ramlaxmanyadav.github.io/scryer/security-code-review-checklist.html)
131
+
132
+ Machine-readable overview for AI assistants/agents: [llms.txt](llms.txt) /
133
+ [llms-full.txt](llms-full.txt) (full concatenated docs, one fetch).
1087
134
 
1088
135
  ## License
1089
136