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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +414 -0
- data/README.md +114 -649
- data/docs/architecture.md +268 -0
- data/docs/contributing.md +42 -0
- data/docs/fix-mode.md +364 -0
- data/docs/rails-integration.md +162 -0
- data/docs/rules.md +310 -0
- data/docs/usage.md +301 -0
- data/lib/generators/scryer/USAGE +10 -2
- data/lib/generators/scryer/templates/scryer_initializer.rb +15 -0
- data/lib/scryer/ai_fix_suggester.rb +37 -11
- data/lib/scryer/ast.rb +26 -0
- data/lib/scryer/authorization_watcher.rb +156 -0
- data/lib/scryer/baseline.rb +75 -0
- data/lib/scryer/cli.rb +688 -14
- data/lib/scryer/colorizer.rb +56 -0
- data/lib/scryer/dependency_fixer.rb +96 -0
- data/lib/scryer/finding.rb +6 -0
- data/lib/scryer/fix_runner.rb +161 -0
- data/lib/scryer/fix_verifier.rb +169 -0
- data/lib/scryer/mechanical_fixer.rb +288 -0
- data/lib/scryer/minitest.rb +48 -0
- data/lib/scryer/performance_rules/inefficient_save_loop_rule.rb +32 -0
- data/lib/scryer/performance_rules/missing_pagination_rule.rb +1 -0
- data/lib/scryer/performance_rules/n_plus_one_query_rule.rb +1 -0
- data/lib/scryer/performance_rules/unbounded_table_scan_rule.rb +1 -0
- data/lib/scryer/report_renderer.rb +539 -46
- data/lib/scryer/rspec.rb +55 -0
- data/lib/scryer/rule.rb +22 -2
- data/lib/scryer/rules/action_cable_forgery_protection_rule.rb +3 -0
- data/lib/scryer/rules/active_storage_inline_disposition_rule.rb +3 -0
- data/lib/scryer/rules/active_storage_missing_content_type_validation_rule.rb +3 -0
- data/lib/scryer/rules/authentication_bypass_rule.rb +30 -7
- data/lib/scryer/rules/command_injection_rule.rb +3 -0
- data/lib/scryer/rules/consider_all_requests_local_rule.rb +51 -0
- data/lib/scryer/rules/cors_misconfiguration_rule.rb +51 -20
- data/lib/scryer/rules/csrf_protection_rule.rb +60 -11
- data/lib/scryer/rules/force_ssl_rule.rb +3 -0
- data/lib/scryer/rules/graphql_missing_query_limits_rule.rb +31 -0
- data/lib/scryer/rules/hardcoded_basic_auth_rule.rb +3 -0
- data/lib/scryer/rules/hardcoded_secret_key_base_rule.rb +3 -0
- data/lib/scryer/rules/hardcoded_secret_rule.rb +3 -0
- data/lib/scryer/rules/host_authorization_disabled_rule.rb +50 -0
- data/lib/scryer/rules/idor_rule.rb +63 -9
- data/lib/scryer/rules/insecure_cookie_serializer_rule.rb +3 -0
- data/lib/scryer/rules/job_raw_params_rule.rb +40 -7
- data/lib/scryer/rules/jwt_insecure_rule.rb +3 -0
- data/lib/scryer/rules/mass_assignment_rule.rb +32 -5
- data/lib/scryer/rules/missing_authorization_rule.rb +103 -0
- data/lib/scryer/rules/missing_policy_scope_rule.rb +134 -0
- data/lib/scryer/rules/open_redirect_rule.rb +3 -0
- data/lib/scryer/rules/path_traversal_rule.rb +22 -1
- data/lib/scryer/rules/security_headers_rule.rb +3 -0
- data/lib/scryer/rules/sql_injection_rule.rb +3 -0
- data/lib/scryer/rules/ssrf_rule.rb +67 -13
- data/lib/scryer/rules/unsafe_deserialization_rule.rb +3 -0
- data/lib/scryer/rules/verbose_production_log_level_rule.rb +53 -0
- data/lib/scryer/rules/weak_crypto_rule.rb +37 -2
- data/lib/scryer/rules/weak_session_cookie_rule.rb +3 -0
- data/lib/scryer/rules/xss_unsafe_html_rule.rb +41 -0
- data/lib/scryer/scanner.rb +25 -12
- data/lib/scryer/style_rules/frozen_string_literal_rule.rb +1 -0
- data/lib/scryer/version.rb +1 -1
- data/lib/scryer.rb +30 -1
- data/lib/tasks/scryer.rake +447 -20
- 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
|
+
|