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/fix-mode.md ADDED
@@ -0,0 +1,364 @@
1
+ # Fix Mode & AI-Assisted Fixes
2
+
3
+ [← Back to Scryer — Ruby on Rails Security Auditor](../README.md)
4
+
5
+ ## Verifying a fix
6
+
7
+ Two ways to confirm a specific finding is actually resolved, without waiting on (or paying for) a
8
+ full rescan:
9
+
10
+ **`scryer verify`** re-checks whether specific findings still fire, without waiting on (or writing)
11
+ a full report. `--rule`/`--file` narrow the scope; omit either or both to broaden it:
12
+
13
+ ```bash
14
+ scryer verify --rule sql_injection --file app/models/user.rb # one rule, one file (narrowest)
15
+ # scryer verify: sql_injection no longer fires on app/models/user.rb — fix verified. (exit 0)
16
+ # or: scryer verify: sql_injection still fires on app/models/user.rb (1 finding(s)): (exit 1)
17
+ # line 12: `where` is called with a string built via interpolation, ...
18
+
19
+ scryer verify --file app/models/user.rb # every rule, just this one file
20
+ scryer verify --rule sql_injection # this one rule, across the whole project
21
+ scryer verify # every rule, across the whole project
22
+ ```
23
+
24
+ `--path ROOT` sets the project root `--file` is resolved against, or that gets checked entirely
25
+ when `--file` is omitted (default: current directory); `--list-rules` prints every known `rule_id`.
26
+ The `--rule ID --file PATH` case is deliberately the narrowest of the four: it only answers "does
27
+ the one thing I targeted still fire," not "did this change introduce a different finding
28
+ elsewhere" — that's what the broader `scryer verify` (no flags) case, a normal `scryer` run, or
29
+ `--baseline` already answer. Bare `scryer verify` covers security/performance/style findings, the
30
+ same as `--baseline` does — duplicate-code groups aren't included (they don't fit the same
31
+ single-finding shape, same reason `--baseline` excludes them too).
32
+
33
+ The three broader cases (anything other than `--rule ID --file PATH`) can turn up many findings at
34
+ once, so those print grouped by severity (critical first) with a short, length-truncated summary
35
+ per finding rather than the full message — a quick "what's still failing" glance, not a wall of
36
+ text:
37
+
38
+ ```
39
+ scryer verify: still has findings under . (36 finding(s)):
40
+
41
+ [critical] sql_injection — app/controllers/orders_controller.rb:14
42
+ `where` is called with a string built via interpolation, which lets user-controlled input change…
43
+ [warning] missing_authorization — app/controllers/api_keys_controller.rb:46
44
+ `ApiKeysController#update` writes data (a standard Rails write action), and `ApiKeysController` has…
45
+ ```
46
+
47
+ Full untruncated detail (every finding, unabridged) is what a real `scryer -o report.json`/
48
+ `report.html` is for — this command is meant to answer "is anything still failing," not replace a
49
+ proper report.
50
+
51
+ **AI-verified remediation** does the same check automatically when an `ai_client` is configured
52
+ (see [AI-assisted fix suggestions](#ai-assisted-fix-suggestions)) — every AI-rewritten
53
+ `suggested_fix` is re-scanned against an in-memory copy of the real file before the report is
54
+ written, and each finding gets a `fix_verified: true/false/nil` field (`true`: the rule no longer
55
+ fires with this fix applied; `false`: it was checked and still fires, or the rewritten line doesn't
56
+ even parse; `nil`: not attempted at all — no `ai_client` configured, or the AI's reply didn't
57
+ include a usable code block). The HTML report shows this as a green "AI fix verified" or red "AI
58
+ fix NOT verified" line under each finding's suggested fix. **This check itself never writes
59
+ anything to a real file** — same "never auto-applied" rule `suggested_fix` always followed, up
60
+ until `scryer fix` below, which is a deliberate, explicitly-invoked exception to that rule.
61
+
62
+ ## Fix mode
63
+
64
+ `scryer fix` is the third leg of scan → fix → verify: it actually writes a verified fix to the
65
+ real file, instead of only reporting it as `fix_verified: true` for a human to apply by hand.
66
+
67
+ **No AI required for a handful of rules.** `Scryer::MechanicalFixer` covers the small set of rules
68
+ where there's exactly one correct rewrite — no judgment call, so nothing an LLM would do
69
+ differently: `frozen_string_literal` (adds the magic comment), `sql_injection` (replaces `#{expr}`
70
+ inside the SQL string with a `?` bind parameter — only when the string is the call's sole
71
+ argument; anything more ambiguous, like an existing second argument, is left alone rather than
72
+ guessed at), and simple config flips (`force_ssl_disabled`, `insecure_cookie_serializer`,
73
+ `weak_session_cookie`, `security_headers_disabled`). These run with **no `ai_client` configured at
74
+ all**. Everything else (`mass_assignment`, `idor`, `missing_authorization`, `csrf`, ...) still needs
75
+ an `ai_client` or a manual fix — the correct rewrite depends on things Scryer can't know statically
76
+ (which params to permit, which policy to call).
77
+
78
+ **When an `ai_client` *is* configured, it's tried first for every rule** — including the six above
79
+ — so what gets written is a context-aware "real developer" fix rather than only the one
80
+ mechanically-derivable rewrite, whenever AI is actually available to provide one. The mechanical
81
+ fixer is the fallback: it only runs when AI isn't configured at all, declined to produce anything
82
+ usable, raised an error, or its rewrite didn't independently verify. Same verify-then-write pipeline
83
+ either way, so neither path is ever trusted more than the other — a fix only gets written once
84
+ `FixVerifier` confirms the targeted rule actually stops firing, regardless of which one produced it.
85
+
86
+ **Troubleshooting: "I configured an `ai_client` but everything still says 'needs manual
87
+ review'."** `Scryer.configuration.ai_client` is only set once whatever file calls
88
+ `Scryer.configure` actually *runs* — for the standalone `scryer` executable that means either
89
+ passing `-r path/to/your_config.rb`, or having a `config/initializers/scryer.rb` under `--path`
90
+ (auto-required with no `-r` at all — see below), on **every invocation**, including `scryer fix`.
91
+ With neither, `ai_client` stays `nil` for that run, and it fails exactly the same way "no client
92
+ configured at all" does: silently, with the rule's own generic `suggested_fix` text and no error.
93
+ If a config file is in play (explicit `-r` or auto-discovered) and it's still not working, check
94
+ the live progress output for a line reading `Skipped (AI client error): ...` followed by the
95
+ actual exception (a bad API key, a network timeout, a malformed response) — see
96
+ [`Scryer::AiFixSuggester`](#ai-assisted-fix-suggestions) below for how that's surfaced instead of
97
+ swallowed.
98
+
99
+ **No `-r` needed if `config/initializers/scryer.rb` already exists.** The standalone executable
100
+ auto-requires `config/initializers/scryer.rb` under `--path` whenever `-r`/`--require` is omitted
101
+ entirely — the same file a Rails app booted against this project would already autoload, so
102
+ running `scryer`/`scryer fix` against a Rails app that already has one just works with no `-r`
103
+ at all. Prints a one-line notice (`Scryer: no -r/--require given — found and requiring ...`) so
104
+ this is never a silent switch. Passing `-r` explicitly always wins and skips this entirely.
105
+
106
+ ```bash
107
+ scryer fix # no ai_client needed for the mechanically-fixable rules
108
+ scryer fix -r ./scryer_config.rb # writes every independently-verified fix it can, AI included
109
+ scryer fix -r ./scryer_config.rb --dry-run # preview only — same output, nothing written
110
+ scryer fix -r ./scryer_config.rb --rule sql_injection --file app/models/order.rb
111
+ ```
112
+
113
+ ```
114
+ scryer fix: 4 candidate finding(s) — asking the configured AI client for a rewrite of each,
115
+ applying only the ones independently verified to clear the finding...
116
+ Fixed: mass_assignment — app/controllers/orders_controller.rb:8
117
+ Switches to strong parameters so only explicitly permitted attributes reach `Order.new`.
118
+ Fixed: sql_injection — app/models/order.rb:12
119
+ Replaces the interpolated string with a parameterized `where` call, so user input can never
120
+ become part of the SQL text.
121
+ Skipped (needs manual review): idor — app/controllers/invoices_controller.rb:14
122
+ Skipped (needs manual review): csrf_protection_disabled — app/controllers/api/webhooks_controller.rb:5
123
+
124
+ Fixed 2 finding(s):
125
+ mass_assignment — app/controllers/orders_controller.rb:8
126
+ sql_injection — app/models/order.rb:12
127
+
128
+ 2 finding(s) need manual review (fix not independently verified):
129
+ idor — app/controllers/invoices_controller.rb:14
130
+ csrf_protection_disabled — app/controllers/api/webhooks_controller.rb:5
131
+
132
+ Re-scanning to verify every applied fix...
133
+ Verified: all 2 applied fix(es) confirmed clean on a full re-scan.
134
+ ```
135
+
136
+ Each finding prints as soon as it's resolved — not just in the final summary — with a short 1-2
137
+ sentence explanation pulled from the AI's own reply (it's asked to lead with plain-English
138
+ reasoning before any code; see the `--rule`/`--file` prompt in `AiFixSuggester`), so you can follow
139
+ along as it works instead of waiting on a wall of silence.
140
+
141
+ **The safety gate is the same FixVerifier check described above, not a new one** — a fix only ever
142
+ gets written when re-parsing the file with that one line replaced, and re-running the one rule that
143
+ flagged it, confirms the rule no longer fires. Anything that couldn't produce a usable `AFTER:`
144
+ block (mechanical or AI), or whose rewrite still fires (or doesn't even parse), is left completely
145
+ alone and listed under "not applied" — same as it would show up in a normal report. Needs *either*
146
+ an `ai_client` configured *or* at least one matched finding covered by `Scryer::MechanicalFixer`
147
+ (see above) — refuses to run only when neither is true, since there'd be nothing it could possibly
148
+ fix.
149
+
150
+ Three things worth knowing before running it:
151
+
152
+ - **This modifies real files.** Run it in a repo under version control and review the diff
153
+ (`git diff`) before committing — same as you'd review any auto-formatter's output
154
+ (`rubocop -A`, `prettier --write`). Nothing here is any more "trusted" than an LLM's raw
155
+ suggestion; the verification only confirms the *targeted rule* stops firing, not that the
156
+ rewrite is otherwise correct, idiomatic, or free of a different problem.
157
+ - **Multiple fixes in the same file are applied highest-line-number first**, so an earlier fix
158
+ that expands one line into several doesn't shift the line numbers a later (in file order, earlier
159
+ in processing order) fix depends on — covered by an automated test against exactly this scenario
160
+ (`test/fix_runner_test.rb`'s `test_line_shifting_fix_applied_first_does_not_break_an_earlier_finding`).
161
+ - **The final re-scan is the real "verify" step**, not the per-fix check — it catches anything the
162
+ narrower per-file check couldn't see, like two fixes interacting across files. This is the same
163
+ reason `scryer verify`'s single-rule check and a full `scryer` run answer different questions.
164
+
165
+ **Interactive per-finding review.** Run `scryer fix` at an actual terminal (not CI, not piped
166
+ stdin) without `--yes`, and every independently-verified fix is shown — rule, message, a short
167
+ explanation, and the actual `AFTER:` rewrite — and confirmed one at a time before it's written,
168
+ instead of silently applying everything that verified clean:
169
+
170
+ ```
171
+ sql_injection — app/models/order.rb:12
172
+ `where` is called with a string built via interpolation, which lets user-controlled input change the SQL executed.
173
+ Fix: Replaces the string interpolation inside the SQL string with a `?` bind parameter, so the value is always sent as a query parameter rather than parsed as SQL text.
174
+ AFTER:
175
+ Order.where("status = ?", params[:status])
176
+ Apply this fix?
177
+ 1) Yes
178
+ 2) Skip
179
+ 3) Yes to all remaining
180
+ 4) Cancel (stop reviewing — nothing further will be attempted)
181
+ Choice:
182
+ ```
183
+
184
+ Numeric choices only — no y/n/a/s letters, and a blank or unrecognized answer just re-prompts
185
+ rather than guessing. `1`/`2` decide just the one shown; `3` ("yes to all remaining") latches
186
+ acceptance for every later finding this run, so accepting the rest doesn't mean answering one at a
187
+ time — that's also the fastest way to fix everything from an interactive session: pick `3` on the
188
+ very first finding. `4` ("cancel") stops immediately: every remaining candidate is marked skipped
189
+ without even asking the AI client or the mechanical fixer for a rewrite, since cancelling means
190
+ stop working, not just stop writing. A non-interactive run (CI, piped stdin) or `--dry-run` never
191
+ prompts — everything verified gets applied automatically, same as before this existed. For a
192
+ single non-interactive command that fixes everything in one shot (no terminal, no prompts, e.g. in
193
+ a script or CI), pass `--yes` instead:
194
+
195
+ ```bash
196
+ scryer fix -r ./scryer_config.rb --yes
197
+ ```
198
+
199
+ **Picking specific findings up front**, when `--rule`/`--file` still leave more than one candidate
200
+ and you'd rather choose by number than review each one interactively:
201
+
202
+ ```bash
203
+ scryer fix --rule sql_injection --list # numbered list, no AI calls, nothing written
204
+ scryer fix --rule sql_injection --number 2 # fix only candidate #2 from that list
205
+ scryer fix --rule sql_injection --number 1,3 # or a comma-separated set
206
+ ```
207
+
208
+ Numbering is stable across runs as long as `--rule`/`--file` stay the same (candidates are sorted
209
+ by file, then line, before being numbered) — list once, then fix by number in a second, scriptable
210
+ call; `--number` skips the per-finding review entirely for whatever it selects.
211
+
212
+ **`frozen_string_literal` is opt-in.** An unscoped `scryer fix` (no `--rule`) excludes it by
213
+ default — it's a cosmetic, `info`-severity finding that would otherwise touch nearly every file in
214
+ a project. At a real terminal it asks once, up front, whether to include it (a separate, coarser
215
+ yes/no than the per-finding review above, meant to skip hundreds of individual prompts for a
216
+ low-value category in one keystroke); non-interactively it's excluded with a one-line notice.
217
+ Passing `--rule frozen_string_literal` explicitly always includes it — that's already informed
218
+ consent.
219
+
220
+ Inside a Rails app: `rails scryer:fix` (optionally `rails 'scryer:fix[rule_id]'` to scope to one
221
+ rule, `SCRYER_FIX_DRY_RUN=1 rails scryer:fix` to preview, `SCRYER_FIX_LIST=1 rails scryer:fix` to
222
+ list numbered candidates, `SCRYER_FIX_NUMBERS=2,3 rails scryer:fix` to fix only those, or
223
+ `SCRYER_FIX_YES=1 rails scryer:fix` for the non-interactive apply-everything behavior) — same
224
+ behavior, `c.ai_client` from `config/initializers/scryer.rb` instead of `-r`.
225
+
226
+ **Dependency findings (`scryer --audit-deps`'s output) are not part of any of the above** — a
227
+ `vulnerable_dependency`/`insecure_source`/`ruby_eol`/`credentials_exposure` finding has no
228
+ `.line`/`.file`/`.rule_id` to rewrite, so none of `FixRunner`/`MechanicalFixer`/`FixVerifier` apply
229
+ to it. `scryer fix --deps` is a separate, smaller pipeline (`Scryer::DependencyFixer`) that only
230
+ handles the one kind of dependency finding with an unambiguous fix — a vulnerable gem with a
231
+ published patched version:
232
+
233
+ ```
234
+ scryer fix --deps --path . # bundle update GEM --conservative per vulnerable gem, then re-check OSV.dev
235
+ scryer fix --deps --dry-run --path . # show which gems would be updated, without running anything
236
+ ```
237
+
238
+ No `ai_client` is involved — this runs a real `bundle update GEM --conservative` per distinct
239
+ vulnerable gem (never one command per advisory; a gem with two open advisories still only gets
240
+ updated once), then re-queries OSV.dev for that one gem to confirm the bump actually cleared every
241
+ advisory it had before reporting it as fixed. A gem with no published patched version yet, an
242
+ insecure `git://`/`http://` source, a past-EOL Ruby version, or an exposed `config/master.key` are
243
+ always left for manual review — none of those are a gem-version bump. This is a real, working-tree
244
+ mutation (it rewrites `Gemfile.lock`), same caution as running `bundle update` yourself directly.
245
+
246
+ Inside a Rails app: `SCRYER_FIX_DEPS=1 rails scryer:fix` (add `SCRYER_FIX_DRY_RUN=1` to preview
247
+ first).
248
+
249
+
250
+ ## AI-assisted fix suggestions
251
+
252
+ Every rule already ships a generic, human-reviewable `suggested_fix` — that's always there and
253
+ needs nothing configured. `Scryer::AiFixSuggester` optionally rewrites that text per finding using
254
+ an LLM, so the suggestion is written against the finding's actual offending line instead of a
255
+ generic template. This is entirely opt-in: with no client configured, `AiFixSuggester` makes zero
256
+ network calls and every finding keeps its original `suggested_fix` — nothing below is required to
257
+ use the rest of Scryer.
258
+
259
+ ### Step by step
260
+
261
+ **Inside a Rails app** — `config/initializers/scryer.rb` is autoloaded at boot, so setting
262
+ `c.ai_client` there is picked up automatically the next time you scan:
263
+
264
+ ```ruby
265
+ # config/initializers/scryer.rb
266
+ Scryer.configure do |c|
267
+ c.ai_client = ->(prompt) { MyLlmClient.chat(prompt) } # any callable — see below for real examples
268
+ end
269
+ ```
270
+
271
+ ```bash
272
+ bin/rails scryer:report
273
+ ```
274
+
275
+ That's the whole flow — no extra flag, no second command. Look for `Scryer: rewriting suggested
276
+ fixes via the configured AI client...` in the task's own output, then open the report: every
277
+ finding's `suggested_fix` is now the LLM's rewrite instead of the generic template.
278
+
279
+ **Outside Rails (the `scryer` executable)** — there's no `config/initializers/` to autoload here,
280
+ so `Scryer.configure` needs to actually run before the scan starts. That's what `-r`/`--require` is
281
+ for: point it at a small Ruby file that calls `Scryer.configure`, and `scryer` requires it first:
282
+
283
+ ```ruby
284
+ # scryer_config.rb — anywhere in your project, any filename
285
+ Scryer.configure do |c|
286
+ c.ai_client = ->(prompt) { MyLlmClient.chat(prompt) }
287
+ end
288
+ ```
289
+
290
+ ```bash
291
+ scryer -r ./scryer_config.rb
292
+ ```
293
+
294
+ Same output, same "rewriting suggested fixes..." line, same result — `-r` is the only difference
295
+ between the two paths, and it's required precisely because the standalone executable has nothing
296
+ else to make `Scryer.configure` code actually run before it scans.
297
+
298
+ **Provider-agnostic by design — bring any LLM.** Scryer doesn't depend on or assume any specific
299
+ vendor's API or SDK (consistent with the zero-runtime-dependency design described in the gemspec).
300
+ `c.ai_client` accepts any object, or even a bare `Proc`/lambda, that responds to `#call(prompt)`
301
+ (or `#complete(prompt)`) and returns the model's reply as a `String` — the two examples above used
302
+ a placeholder; `Scryer::AiClient` below is a ready-made adapter for a real HTTP endpoint.
303
+
304
+ ### `Scryer::AiClient` — a ready-made HTTP adapter
305
+
306
+ For the common case of a JSON/HTTP chat endpoint, `Scryer::AiClient` saves writing the request
307
+ plumbing by hand. It takes the two pieces of vendor-specific shape as plain `Proc`s and handles the
308
+ HTTP call itself (stdlib `Net::HTTP`, no gem):
309
+
310
+ ```ruby
311
+ # Claude (Messages API)
312
+ Scryer.configure do |c|
313
+ c.ai_client = Scryer::AiClient.new(
314
+ url: "https://api.anthropic.com/v1/messages",
315
+ headers: { "x-api-key" => ENV.fetch("ANTHROPIC_API_KEY"), "anthropic-version" => "2023-06-01" },
316
+ build_request: ->(prompt) { { model: "claude-opus-5", max_tokens: 1024, messages: [{ role: "user", content: prompt }] } },
317
+ parse_response: ->(json) { json.dig("content", 0, "text") }
318
+ )
319
+ end
320
+ ```
321
+
322
+ ```ruby
323
+ # Any OpenAI-compatible chat completions endpoint (OpenAI itself, a local
324
+ # Ollama/vLLM server, Azure OpenAI, ...) — same adapter, different shape.
325
+ Scryer.configure do |c|
326
+ c.ai_client = Scryer::AiClient.new(
327
+ url: "https://api.openai.com/v1/chat/completions",
328
+ headers: { "Authorization" => "Bearer #{ENV.fetch('OPENAI_API_KEY')}" },
329
+ build_request: ->(prompt) { { model: "gpt-4o-mini", messages: [{ role: "user", content: prompt }] } },
330
+ parse_response: ->(json) { json.dig("choices", 0, "message", "content") }
331
+ )
332
+ end
333
+ ```
334
+
335
+ Neither example pins Scryer to that vendor — `build_request`/`parse_response` are just data telling
336
+ `AiClient` how to shape one HTTP call; point it at any endpoint that takes a JSON body and returns
337
+ a JSON body.
338
+
339
+ ### What happens with it configured
340
+
341
+ `bin/rails scryer:report` and the `scryer` executable both check `Scryer.configuration.ai_client`
342
+ after scanning and, if set, call `Scryer::AiFixSuggester.enhance_result!(result)` before rendering
343
+ — one LLM call per security/performance/style finding (plus dependency findings too, if the
344
+ dependency audit ran), run across a small thread pool (same pattern as the dependency audit's
345
+ OSV.dev lookups) rather than one at a time. A client that raises, times out, or returns nothing
346
+ usable just leaves that finding's original `suggested_fix` in place — a failed enrichment never
347
+ fails the scan.
348
+
349
+ ```ruby
350
+ Scryer::AiFixSuggester.enhance!(finding) # one Finding, in place
351
+ Scryer::AiFixSuggester.enhance_result!(result) # every finding on a Scanner::Result, in place
352
+ ```
353
+
354
+ Both `bin/rails scryer:report` and the `scryer` executable also pass `root:` so each rewritten fix
355
+ gets verified against a re-scan automatically (`finding.fix_verified` — see
356
+ [Verifying a fix](#verifying-a-fix) for what that means and how to trigger it manually with
357
+ `scryer verify`); calling `enhance!`/`enhance_result!` directly as shown above skips verification
358
+ unless you pass `root:` yourself.
359
+
360
+ **This sends code snippets to whatever endpoint you configure.** `code_snippet`, `message`, and the
361
+ file path are included in the prompt — the same privacy consideration as any third-party service:
362
+ don't point this at an endpoint you don't trust with your source, and be mindful this is a second
363
+ place (besides the HTML report itself) where finding detail leaves your machine.
364
+
@@ -0,0 +1,162 @@
1
+ # Rails Integration
2
+
3
+ [← Back to Scryer — Ruby on Rails Security Auditor](../README.md)
4
+
5
+ ## Runtime query watcher
6
+
7
+ Everything above is a one-shot static scan — it reads source, never boots Rails, and can't see
8
+ what actually happens at request time. `Scryer::QueryWatcher` is different: it's an opt-in
9
+ runtime instrumentation that watches a *running* app for two of the problems
10
+ [Bullet](https://github.com/flyerhzm/bullet) is best known for catching — a collection query
11
+ followed by one repeat query per row ("N+1"), and an `.includes`/`.preload`/`.eager_load`
12
+ association that gets fetched but never actually read ("unused eager loading"). It was built
13
+ independently of Bullet, on a different mechanism (SQL-shape correlation via
14
+ `ActiveSupport::Notifications`, plus a `Module#prepend` on `ActiveRecord::QueryMethods`'s eager-load
15
+ methods and `Association#reader` — not Bullet's own per-request association bookkeeping); no
16
+ Bullet source was read or copied to build it.
17
+
18
+ Enable it from an initializer (typically gated to development/test, though nothing stops you from
19
+ running it in production if you want the noise):
20
+
21
+ ```ruby
22
+ # config/initializers/scryer.rb
23
+ if Rails.env.development? || Rails.env.test?
24
+ require "scryer/query_watcher"
25
+ Scryer::QueryWatcher.enable!
26
+ Rails.application.config.middleware.use Scryer::QueryWatcher::Middleware
27
+ end
28
+ ```
29
+
30
+ The middleware opens one tracking scope per request and logs (via `Rails.logger` by default —
31
+ override with `Scryer::QueryWatcher.enable!(logger: ...)`) anything it finds when the request
32
+ ends. Outside a request — a Sidekiq job, a rake task, a console session — wrap the code yourself:
33
+
34
+ ```ruby
35
+ findings = Scryer::QueryWatcher.watch { SomeJob.new.perform }
36
+ ```
37
+
38
+ `enable!(n_plus_one_threshold: 2)` controls how many repeats of the same query shape from the same
39
+ call site count as N+1 (default: the second occurrence already means one collection load produced
40
+ more than one query). Each finding is a `Scryer::QueryWatcher::Finding` with `kind`
41
+ (`n_plus_one_query_runtime` or `unused_eager_load`), `message`, `call_site`, `count`, and
42
+ `suggested_fix` — the same shape as everything else in this gem, so it's straightforward to feed
43
+ into your own logging/alerting instead of (or alongside) the built-in logger call.
44
+
45
+ This is genuinely runtime-only: with no queries running, it finds nothing, and it never appears in
46
+ the static `tmp/scryer_report.html` output. Think of it as this gem's answer to "what actually
47
+ happened during this request", where the rest of Scryer answers "what does this code look like".
48
+
49
+ ## Runtime authorization watcher
50
+
51
+ The static `idor`/`missing_authorization`/`missing_policy_scope` rules can only ever say "no call
52
+ to a known authorization method is visible anywhere in this controller's source" — which is exactly
53
+ as wrong as it sounds whenever the real check happens somewhere the static AST walk can't see (a
54
+ shared base controller, a concern, a class-level macro). `Scryer::AuthorizationWatcher` answers a
55
+ narrower but far more reliable question instead: for *this actual request*, did Pundit's
56
+ `authorize`/`policy_scope` or CanCanCan's `authorize!` genuinely get called? Both libraries already
57
+ track this themselves internally (for their own `verify_authorized`/`check_authorization` helpers)
58
+ — this watcher registers one more `after_action` alongside those and checks the same flags
59
+ (`Pundit::Authorization#pundit_policy_authorized?`/`#pundit_policy_scoped?`, CanCanCan's
60
+ `@_authorized` ivar — verified by reading both gems' actual source, not guessed).
61
+
62
+ ```ruby
63
+ # config/initializers/scryer.rb
64
+ require "scryer/authorization_watcher"
65
+ Scryer::AuthorizationWatcher.enable!
66
+ ```
67
+
68
+ No middleware to install and no per-request scope to open (unlike `QueryWatcher` above) — a Rails
69
+ controller instance is already fresh per request, so the `after_action` above is all `enable!`
70
+ needs. It flags a `create`/`update`/`destroy` action (or any `POST`/`PUT`/`PATCH`/`DELETE` request)
71
+ that completed successfully (status < 400) with neither flag set:
72
+
73
+ ```ruby
74
+ Scryer::AuthorizationWatcher.findings
75
+ # => [#<struct Scryer::AuthorizationWatcher::Finding kind="runtime_missing_authorization",
76
+ # message="WidgetsController#update completed a PATCH request (status 200) with no
77
+ # authorization check actually invoked during it ...", controller="WidgetsController",
78
+ # action="update", method="PATCH", path="/widgets/1", suggested_fix="...">]
79
+ ```
80
+
81
+ Two honesty points worth being precise about:
82
+
83
+ - **Pundit/CanCanCan-only, same scope as the static rules it complements.** With neither gem
84
+ loaded, `enable!` still runs, but every request is silently skipped — an app with fully custom,
85
+ non-object-level authorization (a single `before_action :require_admin!`) gets no findings and no
86
+ false-positive flood, rather than being flagged for a pattern this watcher has no way to
87
+ recognize as intentional.
88
+ - **Write actions only.** Read-scoping gaps (an unscoped `index` — see the static
89
+ `missing_policy_scope` rule) aren't covered here: verifying "was the returned data correctly
90
+ scoped" at runtime, rather than "was a method called," is a materially different and harder check
91
+ this class doesn't attempt.
92
+
93
+
94
+ ## Generators
95
+
96
+ Scryer ships one generator: `scryer:install`. Run `bin/rails generate scryer:install --help`
97
+ in a host app for the full description (also in
98
+ [lib/generators/scryer/USAGE](../lib/generators/scryer/USAGE)); short version:
99
+
100
+ - **What it creates**: a single file, `config/initializers/scryer.rb`, templated from
101
+ [lib/generators/scryer/templates/scryer_initializer.rb](../lib/generators/scryer/templates/scryer_initializer.rb).
102
+ Nothing else — no routes, no migrations, no controllers/views.
103
+ - **What it doesn't do**: the rake tasks (`scryer:report`) are registered by
104
+ [Scryer::Railtie](../lib/scryer/railtie.rb) as soon as the gem is in your `Gemfile`, whether or
105
+ not you ever run this generator. The generator exists purely to give you an editable config file.
106
+ - **Settings it exposes**: `c.project_name` (report header label), `c.dirs` (which top-level
107
+ directories get scanned), `c.branch` (override the git branch label — see
108
+ [Branch reporting](./usage.md#branch-reporting)), `c.skip_rules` (silence specific rule_ids — see
109
+ [Skipping rules](./usage.md#skipping-rules)), `c.ai_client` (see
110
+ [AI-assisted fix suggestions](./fix-mode.md#ai-assisted-fix-suggestions)). All are optional; the
111
+ commented-out initializer works as-is with just `bundle install` + the generator.
112
+ - **Re-running it**: standard Thor/Rails::Generators behavior — if
113
+ `config/initializers/scryer.rb` already exists, you'll be prompted to overwrite, skip, or diff
114
+ rather than have it silently clobbered.
115
+
116
+ ## Testing Scryer results in your own test suite
117
+
118
+ Two opt-in files turn "this app's own scan stays clean" into a normal test-suite assertion, so a
119
+ regression fails the suite the same way any other regression would, instead of only showing up the
120
+ next time someone runs `scryer` by hand. Neither loads with the gem automatically (RSpec/Minitest
121
+ are never Scryer runtime dependencies — see the gemspec) — require the one matching your test
122
+ framework yourself:
123
+
124
+ ```ruby
125
+ # spec/spec_helper.rb (RSpec)
126
+ require "scryer/rspec"
127
+
128
+ RSpec.describe "security" do
129
+ it "has no critical findings" do
130
+ expect(Scryer.scan(root: Rails.root.to_s)).to have_no_critical_findings
131
+ end
132
+
133
+ it "never reintroduces the mass-assignment bug fixed in PR #123" do
134
+ expect(Scryer.scan(root: Rails.root.to_s)).to have_no_findings_for("mass_assignment")
135
+ end
136
+ end
137
+ ```
138
+
139
+ ```ruby
140
+ # test/test_helper.rb (Minitest / ActiveSupport::TestCase)
141
+ require "scryer/minitest"
142
+
143
+ class SecurityTest < ActiveSupport::TestCase
144
+ include Scryer::MinitestAssertions
145
+
146
+ test "no critical findings" do
147
+ assert_no_critical_scryer_findings(Scryer.scan(root: Rails.root.to_s))
148
+ end
149
+ end
150
+ ```
151
+
152
+ `Scryer.scan(root:)` runs the same static scan `scryer:report`/the `scryer` executable do (using
153
+ `c.dirs`/`c.skip_rules` from your initializer), without needing a report written to disk —
154
+ `have_no_critical_findings`/`assert_no_critical_scryer_findings` only look at *security* findings
155
+ (same scoping as the [security score](./architecture.md#security-score)); `have_no_findings_for`/
156
+ `assert_no_scryer_findings_for` check a specific `rule_id` across all three static categories,
157
+ for pinning a specific bug so it can't come back unnoticed. This talks to the live filesystem on
158
+ every test run (a real `Ripper`-based scan, same cost as running `scryer` itself) — for a large app
159
+ this is meaningfully slower than a typical unit test, so it's usually one dedicated test/spec file
160
+ run occasionally (a nightly job, a pre-release check) rather than part of every `rspec`/`rails test`
161
+ invocation.
162
+