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